release: 2026-03-20 (124건 커밋) #108

병합
dnlee develop 에서 main 로 7 commits 를 머지했습니다 2026-03-20 10:38:46 +09:00
11개의 변경된 파일365개의 추가작업 그리고 378개의 파일을 삭제
Showing only changes of commit 9881b99ee7 - Show all commits

파일 보기

@ -1,15 +1,29 @@
import { Router } from 'express';
import { requireAuth } from '../auth/authMiddleware.js';
import { listJurisdictions, listZones, listSections, getSection } from './scatService.js';
import { listOffices, listJurisdictions, listZones, listSections, getSection } from './scatService.js';
const router = Router();
// ============================================================
// GET /api/scat/offices — 관할청 목록
// ============================================================
router.get('/offices', requireAuth, async (_req, res) => {
try {
const offices = await listOffices();
res.json(offices);
} catch (err) {
console.error('[scat] 관할청 목록 조회 오류:', err);
res.status(500).json({ error: '관할청 목록 조회 중 오류가 발생했습니다.' });
}
});
// ============================================================
// GET /api/scat/jurisdictions — 관할서 목록
// ============================================================
router.get('/jurisdictions', requireAuth, async (_req, res) => {
router.get('/jurisdictions', requireAuth, async (req, res) => {
try {
const jurisdictions = await listJurisdictions();
const { officeCd } = req.query as { officeCd?: string };
const jurisdictions = await listJurisdictions(officeCd);
res.json(jurisdictions);
} catch (err) {
console.error('[scat] 관할서 목록 조회 오류:', err);
@ -22,8 +36,8 @@ router.get('/jurisdictions', requireAuth, async (_req, res) => {
// ============================================================
router.get('/zones', requireAuth, async (req, res) => {
try {
const { jurisdiction } = req.query as { jurisdiction?: string };
const zones = await listZones({ jurisdiction });
const { jurisdiction, officeCd } = req.query as { jurisdiction?: string; officeCd?: string };
const zones = await listZones({ jurisdiction, officeCd });
res.json(zones);
} catch (err) {
console.error('[scat] 조사구역 목록 조회 오류:', err);
@ -36,14 +50,15 @@ router.get('/zones', requireAuth, async (req, res) => {
// ============================================================
router.get('/sections', requireAuth, async (req, res) => {
try {
const { zone, status, sensitivity, jurisdiction, search } = req.query as {
const { zone, status, sensitivity, jurisdiction, search, officeCd } = req.query as {
zone?: string;
status?: string;
sensitivity?: string;
jurisdiction?: string;
search?: string;
officeCd?: string;
};
const sections = await listSections({ zone, status, sensitivity, jurisdiction, search });
const sections = await listSections({ zone, status, sensitivity, jurisdiction, search, officeCd });
res.json(sections);
} catch (err) {
console.error('[scat] 해안구간 목록 조회 오류:', err);

파일 보기

@ -60,18 +60,40 @@ interface SectionDetail {
notes: string[];
}
// ============================================================
// 관할청 목록 조회
// ============================================================
export async function listOffices(): Promise<string[]> {
const sql = `
SELECT DISTINCT OFFICE_CD
FROM wing.CST_SRVY_ZONE
WHERE USE_YN = 'Y' AND OFFICE_CD IS NOT NULL
ORDER BY OFFICE_CD
`;
const { rows } = await wingPool.query(sql);
return rows.map((r: Record<string, unknown>) => r.office_cd as string);
}
// ============================================================
// 관할서 목록 조회
// ============================================================
export async function listJurisdictions(): Promise<string[]> {
export async function listJurisdictions(officeCd?: string): Promise<string[]> {
const conditions: string[] = ["USE_YN = 'Y'", 'JRSD_NM IS NOT NULL'];
const params: unknown[] = [];
let idx = 1;
if (officeCd) {
conditions.push(`OFFICE_CD = $${idx++}`);
params.push(officeCd);
}
const sql = `
SELECT DISTINCT JRSD_NM
FROM wing.CST_SRVY_ZONE
WHERE USE_YN = 'Y' AND JRSD_NM IS NOT NULL
WHERE ${conditions.join(' AND ')}
ORDER BY JRSD_NM
`;
const { rows } = await wingPool.query(sql);
const { rows } = await wingPool.query(sql, params);
return rows.map((r: Record<string, unknown>) => r.jrsd_nm as string);
}
@ -81,6 +103,7 @@ export async function listJurisdictions(): Promise<string[]> {
export async function listZones(filters?: {
jurisdiction?: string;
officeCd?: string;
}): Promise<ZoneItem[]> {
const conditions: string[] = ["USE_YN = 'Y'"];
const params: unknown[] = [];
@ -90,6 +113,10 @@ export async function listZones(filters?: {
conditions.push(`JRSD_NM ILIKE '%' || $${idx++} || '%'`);
params.push(filters.jurisdiction);
}
if (filters?.officeCd) {
conditions.push(`OFFICE_CD = $${idx++}`);
params.push(filters.officeCd);
}
const where = 'WHERE ' + conditions.join(' AND ');
@ -126,6 +153,7 @@ export async function listSections(filters: {
sensitivity?: string;
jurisdiction?: string;
search?: string;
officeCd?: string;
}): Promise<SectionListItem[]> {
const conditions: string[] = [];
const params: unknown[] = [];
@ -151,6 +179,10 @@ export async function listSections(filters: {
conditions.push(`s.SECT_NM ILIKE '%' || $${idx++} || '%'`);
params.push(filters.search);
}
if (filters.officeCd) {
conditions.push(`z.OFFICE_CD = $${idx++}`);
params.push(filters.officeCd);
}
conditions.push("s.USE_YN = 'Y'");
conditions.push("z.USE_YN = 'Y'");

파일 보기

@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS CST_SRVY_ZONE (
ZONE_CD VARCHAR(10) NOT NULL UNIQUE,
ZONE_NM VARCHAR(100) NOT NULL,
JRSD_NM VARCHAR(20),
OFFICE_CD VARCHAR(20),
SECT_CNT INTEGER DEFAULT 0,
LAT_CENTER NUMERIC(9,6),
LNG_CENTER NUMERIC(9,6),
@ -29,9 +30,9 @@ CREATE TABLE IF NOT EXISTS CST_SRVY_ZONE (
CREATE TABLE IF NOT EXISTS CST_SECT (
CST_SECT_SN SERIAL PRIMARY KEY,
CST_SRVY_ZONE_SN INTEGER REFERENCES CST_SRVY_ZONE(CST_SRVY_ZONE_SN),
SECT_CD VARCHAR(20) NOT NULL UNIQUE,
SECT_CD VARCHAR(30) NOT NULL UNIQUE,
SECT_NM VARCHAR(200),
CST_TP_CD VARCHAR(30),
CST_TP_CD VARCHAR(100),
ESI_CD VARCHAR(5),
ESI_NUM SMALLINT,
LEN_M NUMERIC(8,1),

파일 보기

@ -32,6 +32,7 @@
"maplibre-gl": "^5.19.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-window": "^2.2.7",
"socket.io-client": "^4.8.3",
"xlsx": "^0.18.5",
"zustand": "^5.0.11"
@ -41,6 +42,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24",
"eslint": "^9.39.1",
@ -2499,6 +2501,16 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-window": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
@ -5439,6 +5451,16 @@
"node": ">=0.10.0"
}
},
"node_modules/react-window": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.7.tgz",
"integrity": "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

파일 보기

@ -34,6 +34,7 @@
"maplibre-gl": "^5.19.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-window": "^2.2.7",
"socket.io-client": "^4.8.3",
"xlsx": "^0.18.5",
"zustand": "^5.0.11"
@ -43,6 +44,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24",
"eslint": "^9.39.1",

파일 보기

@ -1,10 +1,9 @@
import { useState, useCallback, useEffect } from 'react';
import type { ScatSegment, ScatDetail } from './scatTypes';
import { fetchSections, fetchSectionDetail, fetchZones, fetchJurisdictions } from '../services/scatApi';
import { fetchOffices, fetchSections, fetchSectionDetail, fetchZones, fetchJurisdictions } from '../services/scatApi';
import type { ApiZoneItem } from '../services/scatApi';
import ScatLeftPanel from './ScatLeftPanel';
import ScatMap from './ScatMap';
import ScatTimeline from './ScatTimeline';
import ScatPopup from './ScatPopup';
import ScatRightPanel from './ScatRightPanel';
@ -14,6 +13,8 @@ export function PreScatView() {
const [segments, setSegments] = useState<ScatSegment[]>([]);
const [zones, setZones] = useState<ApiZoneItem[]>([]);
const [jurisdictions, setJurisdictions] = useState<string[]>([]);
const [offices, setOffices] = useState<string[]>([]);
const [selectedOffice, setSelectedOffice] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedSeg, setSelectedSeg] = useState<ScatSegment | null>(null);
@ -25,30 +26,34 @@ export function PreScatView() {
const [popupData, setPopupData] = useState<ScatDetail | null>(null);
const [panelDetail, setPanelDetail] = useState<ScatDetail | null>(null);
const [panelLoading, setPanelLoading] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [timelineIdx, setTimelineIdx] = useState(6);
// 초기 관할 목록 로딩
// 초기 관할 목록 로딩
useEffect(() => {
let cancelled = false;
async function loadInit() {
try {
setLoading(true);
const jrsdList = await fetchJurisdictions();
const officeList = await fetchOffices();
if (cancelled) return;
setOffices(officeList);
const defaultOffice = officeList.includes('제주청') ? '제주청' : officeList[0] || '';
setSelectedOffice(defaultOffice);
const jrsdList = await fetchJurisdictions(defaultOffice);
if (cancelled) return;
setJurisdictions(jrsdList);
// 기본 관할해경: '제주' 또는 첫 항목
const defaultJrsd = jrsdList.includes('제주') ? '제주' : jrsdList[0] || '';
setJurisdictionFilter('');
const [zonesData, sectionsData] = await Promise.all([
fetchZones(defaultJrsd),
fetchSections({ jurisdiction: defaultJrsd }),
fetchZones(undefined, defaultOffice),
fetchSections({ officeCd: defaultOffice }),
]);
if (cancelled) return;
setZones(zonesData);
setSegments(sectionsData);
setJurisdictionFilter(defaultJrsd);
if (sectionsData.length > 0) {
setSelectedSeg(sectionsData[0]);
}
if (sectionsData.length > 0) setSelectedSeg(sectionsData[0]);
} catch (err) {
console.error('[SCAT] 데이터 로딩 오류:', err);
if (!cancelled) setError('데이터를 불러오지 못했습니다.');
@ -60,30 +65,58 @@ export function PreScatView() {
return () => { cancelled = true; };
}, []);
// 관할서 필터 변경 시 zones + sections 재로딩
// 관할청 변경 시 관할구역 + zones + sections 재로딩
useEffect(() => {
// 초기 로딩 시에는 스킵 (위의 useEffect에서 처리)
if (jurisdictions.length === 0 || !jurisdictionFilter) return;
if (offices.length === 0 || !selectedOffice) return;
let cancelled = false;
async function reload() {
try {
setLoading(true);
const jrsdList = await fetchJurisdictions(selectedOffice);
if (cancelled) return;
setJurisdictions(jrsdList);
setJurisdictionFilter('');
const [zonesData, sectionsData] = await Promise.all([
fetchZones(jurisdictionFilter),
fetchSections({ jurisdiction: jurisdictionFilter }),
fetchZones(undefined, selectedOffice),
fetchSections({ officeCd: selectedOffice }),
]);
if (cancelled) return;
setZones(zonesData);
setSegments(sectionsData);
setAreaFilter('');
if (sectionsData.length > 0) {
setSelectedSeg(sectionsData[0]);
} else {
setSelectedSeg(null);
}
if (sectionsData.length > 0) setSelectedSeg(sectionsData[0]);
else setSelectedSeg(null);
} catch (err) {
console.error('[SCAT] 데이터 재로딩 오류:', err);
console.error('[SCAT] 관할청 변경 오류:', err);
} finally {
if (!cancelled) setLoading(false);
}
}
reload();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedOffice]);
// 관할구역 필터 변경 시 zones + sections 재로딩 (선택된 관할청 내에서 필터링)
useEffect(() => {
if (!selectedOffice || !jurisdictionFilter) return;
let cancelled = false;
async function reload() {
try {
setLoading(true);
const [zonesData, sectionsData] = await Promise.all([
fetchZones(jurisdictionFilter, selectedOffice),
fetchSections({ jurisdiction: jurisdictionFilter, officeCd: selectedOffice }),
]);
if (cancelled) return;
setZones(zonesData);
setSegments(sectionsData);
setAreaFilter('');
if (sectionsData.length > 0) setSelectedSeg(sectionsData[0]);
else setSelectedSeg(null);
} catch (err) {
console.error('[SCAT] 관할구역 변경 오류:', err);
} finally {
if (!cancelled) setLoading(false);
}
@ -121,21 +154,6 @@ export function PreScatView() {
setPopupData(null);
}, []);
const handleTimelineSeek = useCallback(
(idx: number) => {
if (idx === -1) {
setTimelineIdx((prev) => {
const next = (prev + 1) % Math.min(segments.length, 12);
if (segments[next]) setSelectedSeg(segments[next]);
return next;
});
} else {
setTimelineIdx(idx);
if (segments[idx]) setSelectedSeg(segments[idx]);
}
},
[segments],
);
if (error) {
return (
@ -165,6 +183,9 @@ export function PreScatView() {
segments={segments}
zones={zones}
jurisdictions={jurisdictions}
offices={offices}
selectedOffice={selectedOffice}
onOfficeChange={setSelectedOffice}
selectedSeg={selectedSeg}
onSelectSeg={setSelectedSeg}
onOpenPopup={handleOpenPopup}
@ -189,11 +210,11 @@ export function PreScatView() {
onSelectSeg={setSelectedSeg}
onOpenPopup={handleOpenPopup}
/>
<ScatTimeline
{/* <ScatTimeline
segments={segments}
currentIdx={timelineIdx}
onSeek={handleTimelineSeek}
/>
/> */}
</div>
<ScatRightPanel

파일 보기

@ -1,3 +1,5 @@
import { useState, useEffect, useRef, type CSSProperties, type ReactElement } from 'react';
import { List } from 'react-window';
import type { ScatSegment } from './scatTypes';
import type { ApiZoneItem } from '../services/scatApi';
import { esiColor, sensColor, statusColor, esiLevel } from './scatConstants';
@ -6,6 +8,9 @@ interface ScatLeftPanelProps {
segments: ScatSegment[];
zones: ApiZoneItem[];
jurisdictions: string[];
offices: string[];
selectedOffice: string;
onOfficeChange: (v: string) => void;
selectedSeg: ScatSegment;
onSelectSeg: (s: ScatSegment) => void;
onOpenPopup: (sn: number) => void;
@ -21,16 +26,97 @@ interface ScatLeftPanelProps {
onSearchChange: (v: string) => void;
}
interface SegRowData {
filtered: ScatSegment[];
selectedId: number;
onSelectSeg: (s: ScatSegment) => void;
onOpenPopup: (sn: number) => void;
}
function SegRow(props: {
ariaAttributes: { 'aria-posinset': number; 'aria-setsize': number; role: 'listitem' };
index: number;
style: CSSProperties;
} & SegRowData): ReactElement | null {
const { index, style, filtered, selectedId, onSelectSeg, onOpenPopup } = props;
const seg = filtered[index];
if (!seg) return null;
const lvl = esiLevel(seg.esiNum);
const borderColor =
lvl === 'h'
? 'border-l-status-red'
: lvl === 'm'
? 'border-l-status-orange'
: 'border-l-status-green';
const isSelected = selectedId === seg.id;
return (
<div style={{ ...style, paddingBottom: 6, paddingRight: 2 }}>
<div
onClick={() => {
onSelectSeg(seg);
onOpenPopup(seg.id);
}}
className={`bg-bg-3 border border-border rounded-sm p-2.5 px-3 cursor-pointer transition-all border-l-4 ${borderColor} ${
isSelected
? 'border-status-green bg-[rgba(34,197,94,0.05)]'
: 'hover:border-border-light hover:bg-bg-hover'
}`}
>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] font-semibold font-korean flex items-center gap-1.5">
📍 {seg.code} {seg.area}
</span>
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded-lg text-white"
style={{ background: esiColor(seg.esiNum) }}
>
ESI {seg.esi}
</span>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="text-text-1 font-medium font-mono text-[11px]">{seg.type}</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="text-text-1 font-medium font-mono text-[11px]">{seg.length}</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="font-medium font-mono text-[11px]" style={{ color: sensColor[seg.sensitivity] }}>
{seg.sensitivity}
</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="font-medium font-mono text-[11px]" style={{ color: statusColor[seg.status] }}>
{seg.status}
</span>
</div>
</div>
</div>
</div>
);
}
function ScatLeftPanel({
segments,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
zones,
jurisdictions,
offices,
selectedOffice,
onOfficeChange,
selectedSeg,
onSelectSeg,
onOpenPopup,
jurisdictionFilter,
onJurisdictionChange,
areaFilter,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onAreaChange,
phaseFilter,
onPhaseChange,
@ -46,6 +132,21 @@ function ScatLeftPanel({
return true;
});
const listContainerRef = useRef<HTMLDivElement>(null);
const [listHeight, setListHeight] = useState(400);
useEffect(() => {
const el = listContainerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
setListHeight(entry.contentRect.height);
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div className="w-[340px] min-w-[340px] bg-bg-1 border-r border-border flex flex-col overflow-hidden">
{/* Filters */}
@ -59,18 +160,34 @@ function ScatLeftPanel({
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
</label>
<select
value={selectedOffice}
onChange={(e) => onOfficeChange(e.target.value)}
className="prd-i w-full"
>
{offices.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
</div>
<div className="mb-2.5">
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
</label>
<select
value={jurisdictionFilter}
onChange={(e) => onJurisdictionChange(e.target.value)}
className="prd-i w-full"
>
<option value=""></option>
{jurisdictions.map((j) => (
<option key={j} value={j}>{j}</option>
))}
</select>
</div>
<div className="mb-2.5">
{/* <div className="mb-2.5">
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
</label>
@ -86,7 +203,7 @@ function ScatLeftPanel({
</option>
))}
</select>
</div>
</div> */}
<div className="mb-2.5">
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
@ -135,75 +252,20 @@ function ScatLeftPanel({
{filtered.length}
</span>
</div>
<div className="flex-1 overflow-y-auto scrollbar-thin flex flex-col gap-1.5">
{filtered.map((seg) => {
const lvl = esiLevel(seg.esiNum);
const borderColor =
lvl === 'h'
? 'border-l-status-red'
: lvl === 'm'
? 'border-l-status-orange'
: 'border-l-status-green';
const isSelected = selectedSeg.id === seg.id;
return (
<div
key={seg.id}
onClick={() => {
onSelectSeg(seg);
onOpenPopup(seg.id);
}}
className={`bg-bg-3 border border-border rounded-sm p-2.5 px-3 cursor-pointer transition-all border-l-4 ${borderColor} ${
isSelected
? 'border-status-green bg-[rgba(34,197,94,0.05)]'
: 'hover:border-border-light hover:bg-bg-hover'
}`}
>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] font-semibold font-korean flex items-center gap-1.5">
📍 {seg.code} {seg.area}
</span>
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded-lg text-white"
style={{ background: esiColor(seg.esiNum) }}
>
ESI {seg.esi}
</span>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="text-text-1 font-medium font-mono text-[11px]">
{seg.type}
</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="text-text-1 font-medium font-mono text-[11px]">
{seg.length}
</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span
className="font-medium font-mono text-[11px]"
style={{ color: sensColor[seg.sensitivity] }}
>
{seg.sensitivity}
</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span
className="font-medium font-mono text-[11px]"
style={{ color: statusColor[seg.status] }}
>
{seg.status}
</span>
</div>
</div>
</div>
);
})}
<div className="flex-1 overflow-hidden" ref={listContainerRef}>
<List<SegRowData>
rowCount={filtered.length}
rowHeight={88}
overscanCount={5}
style={{ height: listHeight }}
rowComponent={SegRow}
rowProps={{
filtered,
selectedId: selectedSeg.id,
onSelectSeg,
onOpenPopup,
}}
/>
</div>
</div>
</div>

파일 보기

@ -367,7 +367,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
</div>
{/* Coordinates */}
<div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-border rounded-sm px-3 py-1.5 font-mono text-[11px] text-text-2 flex gap-3.5">
{/* <div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-border rounded-sm px-3 py-1.5 font-mono text-[11px] text-text-2 flex gap-3.5">
<span>
<span className="text-status-green font-medium">{selectedSeg.lat.toFixed(4)}°N</span>
</span>
@ -377,7 +377,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
<span>
<span className="text-status-green font-medium">1:25,000</span>
</span>
</div>
</div> */}
</div>
)
}

파일 보기

@ -15,6 +15,7 @@ const tabs = [
{ id: 2, label: '방제 권고', icon: '🛡️' },
] as const;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default function ScatRightPanel({ detail, loading, onOpenReport, onNewSurvey }: ScatRightPanelProps) {
const [activeTab, setActiveTab] = useState(0);
@ -79,7 +80,7 @@ export default function ScatRightPanel({ detail, loading, onOpenReport, onNewSur
</div>
{/* 하단 버튼 */}
<div className="flex flex-col gap-1.5 p-2.5 border-t border-border shrink-0">
{/* <div className="flex flex-col gap-1.5 p-2.5 border-t border-border shrink-0">
<button onClick={onOpenReport}
className="w-full py-2 text-[11px] font-semibold rounded-md cursor-pointer text-primary-cyan"
style={{ background: 'rgba(6,182,212,.08)', border: '1px solid rgba(6,182,212,.25)' }}>
@ -90,7 +91,7 @@ export default function ScatRightPanel({ detail, loading, onOpenReport, onNewSur
style={{ background: 'rgba(34,197,94,.08)', border: '1px solid rgba(34,197,94,.25)' }}>
</button>
</div>
</div> */}
</div>
);
}

파일 보기

@ -102,14 +102,24 @@ function toScatDetail(item: ApiSectionDetail): ScatDetail {
// API 호출 함수
// ============================================================
export async function fetchJurisdictions(): Promise<string[]> {
const { data } = await api.get<string[]>('/scat/jurisdictions');
export async function fetchOffices(): Promise<string[]> {
const { data } = await api.get<string[]>('/scat/offices');
return data;
}
export async function fetchZones(jurisdiction?: string): Promise<ApiZoneItem[]> {
const params = jurisdiction ? `?jurisdiction=${encodeURIComponent(jurisdiction)}` : '';
const { data } = await api.get<ApiZoneItem[]>(`/scat/zones${params}`);
export async function fetchJurisdictions(officeCd?: string): Promise<string[]> {
const params = officeCd ? `?officeCd=${encodeURIComponent(officeCd)}` : '';
const { data } = await api.get<string[]>(`/scat/jurisdictions${params}`);
return data;
}
export async function fetchZones(jurisdiction?: string, officeCd?: string): Promise<ApiZoneItem[]> {
const params = new URLSearchParams();
if (jurisdiction) params.set('jurisdiction', jurisdiction);
if (officeCd) params.set('officeCd', officeCd);
const query = params.toString();
const url = query ? `/scat/zones?${query}` : '/scat/zones';
const { data } = await api.get<ApiZoneItem[]>(url);
return data;
}
@ -119,6 +129,7 @@ export interface SectionFilters {
sensitivity?: string;
jurisdiction?: string;
search?: string;
officeCd?: string;
}
export async function fetchSections(filters?: SectionFilters): Promise<ScatSegment[]> {
@ -128,6 +139,7 @@ export async function fetchSections(filters?: SectionFilters): Promise<ScatSegme
if (filters?.sensitivity) params.set('sensitivity', filters.sensitivity);
if (filters?.jurisdiction) params.set('jurisdiction', filters.jurisdiction);
if (filters?.search) params.set('search', filters.search);
if (filters?.officeCd) params.set('officeCd', filters.officeCd);
const query = params.toString();
const url = query ? `/scat/sections?${query}` : '/scat/sections';

파일 보기

@ -46,76 +46,26 @@ interface WeatherRightPanelProps {
weatherData: WeatherData | null;
}
/* ── Local Helpers (not exported) ─────────────────────────── */
function WindCompass({ degrees }: { degrees: number }) {
// center=28, radius=22
return (
<svg width="56" height="56" viewBox="0 0 56 56" className="shrink-0">
{/* arcs connecting N→E→S→W→N */}
<path d="M 28,6 A 22,22 0 0,1 50,28" fill="none" stroke="#1e2a42" strokeWidth="1" />
<path d="M 50,28 A 22,22 0 0,1 28,50" fill="none" stroke="#1e2a42" strokeWidth="1" />
<path d="M 28,50 A 22,22 0 0,1 6,28" fill="none" stroke="#1e2a42" strokeWidth="1" />
<path d="M 6,28 A 22,22 0 0,1 28,6" fill="none" stroke="#1e2a42" strokeWidth="1" />
{/* cardinal labels — same color as 풍향/기압 text (#edf0f7 = text-1) */}
<text x="28" y="10" textAnchor="middle" fontSize="8" fill="#edf0f7" fontWeight="600">N</text>
<text x="28" y="53" textAnchor="middle" fontSize="8" fill="#edf0f7" fontWeight="600">S</text>
<text x="50" y="31" textAnchor="middle" fontSize="8" fill="#edf0f7" fontWeight="600">E</text>
<text x="6" y="31" textAnchor="middle" fontSize="8" fill="#edf0f7" fontWeight="600">W</text>
{/* clock-hand needle */}
<g style={{ transform: `rotate(${degrees}deg)`, transformOrigin: '28px 28px' }}>
<line x1="28" y1="28" x2="28" y2="10" stroke="#eab308" strokeWidth="2" strokeLinecap="round" />
<circle cx="28" cy="10" r="2" fill="#eab308" />
</g>
{/* center dot */}
<circle cx="28" cy="28" r="2" fill="#8690a6" />
</svg>
);
}
function ProgressBar({ value, max, gradient, label }: { value: number; max: number; gradient: string; label: string }) {
const pct = Math.min(100, (value / max) * 100);
return (
<div className="mt-3 flex items-center gap-2">
<div className="h-1.5 flex-1 rounded-full bg-bg-2 overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: gradient }} />
</div>
<span className="text-[10px] text-text-3 shrink-0">{label}</span>
</div>
);
}
function StatCard({ value, label, valueClass = 'text-primary-cyan' }: { value: string; label: string; valueClass?: string }) {
return (
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-0.5">
<span className={`text-sm font-bold font-mono ${valueClass}`}>{value}</span>
<span className="text-[10px] text-text-3">{label}</span>
</div>
);
}
/* ── Main Component ───────────────────────────────────────── */
/** 풍속 등급 색상 */
function windColor(speed: number): string {
if (speed >= 14) return '#ef4444'
if (speed >= 10) return '#f97316'
if (speed >= 6) return '#eab308'
return '#22c55e'
if (speed >= 14) return '#ef4444';
if (speed >= 10) return '#f97316';
if (speed >= 6) return '#eab308';
return '#22c55e';
}
/** 파고 등급 색상 */
function waveColor(height: number): string {
if (height >= 3) return '#ef4444'
if (height >= 2) return '#f97316'
if (height >= 1) return '#eab308'
return '#22c55e'
if (height >= 3) return '#ef4444';
if (height >= 2) return '#f97316';
if (height >= 1) return '#eab308';
return '#22c55e';
}
/** 풍향 텍스트 */
function windDirText(deg: number): string {
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
return dirs[Math.round(deg / 22.5) % 16]
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
return dirs[Math.round(deg / 22.5) % 16];
}
export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
@ -129,20 +79,13 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
);
}
const {
wind, wave, temperature, pressure, visibility,
salinity, astronomy, alert, forecast,
} = weatherData;
const { wind, wave, temperature, pressure, visibility, salinity, astronomy, alert, forecast } = weatherData;
const wSpd = wind.speed;
const wHgt = wave.height;
const wTemp = temperature.current;
const windDir = windDirText(wind.direction);
return (
<div className="flex flex-col bg-bg-1 border-l border-border overflow-hidden w-[380px] shrink-0">
{/* ── Header ─────────────────────────────────────────── */}
<div className="px-5 py-3 border-b border-border">
<div className="flex items-center gap-2 mb-1">
<span className="text-primary-cyan text-sm font-semibold">📍 {weatherData.stationName}</span>
<span className="px-2 py-0.5 text-[10px] rounded bg-primary-cyan/20 text-primary-cyan font-semibold">
</span>
<div className="flex flex-col bg-bg-1 border-l border-border overflow-hidden w-[320px] shrink-0">
{/* 헤더 */}
<div className="px-4 py-3 border-b border-border bg-bg-2">
@ -150,29 +93,11 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
<span className="text-[13px] font-bold text-primary-cyan font-korean">📍 {weatherData.stationName}</span>
<span className="px-1.5 py-px text-[11px] rounded bg-primary-cyan/15 text-primary-cyan font-bold"></span>
</div>
<p className="text-[11px] text-text-3 font-mono">
{weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E · {weatherData.currentTime}
<p className="text-[11px] text-text-3 font-mono">
{weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E · {weatherData.currentTime}
</p>
</div>
{/* ── Scrollable Content ─────────────────────────────── */}
<div className="flex-1 overflow-auto">
{/* ── Summary Cards ──────────────────────────────── */}
<div className="px-5 py-3 border-b border-border">
<div className="grid grid-cols-3 gap-2">
<div className="bg-bg-2 border border-border rounded-lg p-3 flex flex-col items-center">
<span className="text-[22px] font-bold text-primary-cyan font-mono leading-none">
{wind.speed.toFixed(1)}
</span>
<span className="text-[12px] text-text-3 mt-1"> (m/s)</span>
</div>
<div className="bg-bg-2 border border-border rounded-lg p-3 flex flex-col items-center">
<span className="text-[22px] font-bold text-primary-cyan font-mono leading-none">
{wave.height.toFixed(1)}
</span>
<span className="text-[12px] text-text-3 mt-1"> (m)</span>
{/* 스크롤 콘텐츠 */}
<div className="flex-1 overflow-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
@ -202,33 +127,28 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
<circle cx="25" cy="25" r="22" fill="none" stroke="var(--bd)" strokeWidth="1" />
<circle cx="25" cy="25" r="16" fill="none" stroke="var(--bd)" strokeWidth="0.5" strokeDasharray="2 2" />
{['N', 'E', 'S', 'W'].map((d, i) => {
const angle = i * 90
const rad = (angle - 90) * Math.PI / 180
const x = 25 + 20 * Math.cos(rad)
const y = 25 + 20 * Math.sin(rad)
return <text key={d} x={x} y={y} textAnchor="middle" dominantBaseline="central" fill="var(--t3)" fontSize="6" fontWeight="bold">{d}</text>
const angle = i * 90;
const rad = (angle - 90) * Math.PI / 180;
const x = 25 + 20 * Math.cos(rad);
const y = 25 + 20 * Math.sin(rad);
return <text key={d} x={x} y={y} textAnchor="middle" dominantBaseline="central" fill="var(--t3)" fontSize="6" fontWeight="bold">{d}</text>;
})}
{/* 풍향 화살표 */}
<line
x1="25" y1="25"
x2={25 + 14 * Math.sin(weatherData.wind.direction * Math.PI / 180)}
y2={25 - 14 * Math.cos(weatherData.wind.direction * Math.PI / 180)}
x2={25 + 14 * Math.sin(wind.direction * Math.PI / 180)}
y2={25 - 14 * Math.cos(wind.direction * Math.PI / 180)}
stroke={windColor(wSpd)} strokeWidth="2" strokeLinecap="round"
/>
<circle cx="25" cy="25" r="3" fill={windColor(wSpd)} />
</svg>
</div>
<div className="bg-bg-2 border border-border rounded-lg p-3 flex flex-col items-center">
<span className="text-[22px] font-bold text-primary-cyan font-mono leading-none">
{temperature.current.toFixed(1)}
</span>
<span className="text-[12px] text-text-3 mt-1"> (°C)</span>
<div className="flex-1 grid grid-cols-2 gap-x-3 gap-y-1.5 text-[11px]">
<div className="flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-mono font-bold text-[13px]">{windDir} {weatherData.wind.direction}°</span></div>
<div className="flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-mono text-[13px]">{weatherData.pressure} hPa</span></div>
<div className="flex justify-between"><span className="text-text-3">1k </span><span className="font-mono text-[13px]" style={{ color: windColor(weatherData.wind.speed_1k) }}>{Number(weatherData.wind.speed_1k).toFixed(1)}</span></div>
<div className="flex justify-between"><span className="text-text-3">3k </span><span className="font-mono text-[13px]" style={{ color: windColor(weatherData.wind.speed_3k) }}>{Number(weatherData.wind.speed_3k).toFixed(1)}</span></div>
<div className="col-span-2 flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-mono text-[13px]">{weatherData.visibility} km</span></div>
<div className="flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-mono font-bold text-[13px]">{windDir} {wind.direction}°</span></div>
<div className="flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-mono text-[13px]">{pressure} hPa</span></div>
<div className="flex justify-between"><span className="text-text-3">1k </span><span className="font-mono text-[13px]" style={{ color: windColor(wind.speed_1k) }}>{Number(wind.speed_1k).toFixed(1)}</span></div>
<div className="flex justify-between"><span className="text-text-3">3k </span><span className="font-mono text-[13px]" style={{ color: windColor(wind.speed_3k) }}>{Number(wind.speed_3k).toFixed(1)}</span></div>
<div className="col-span-2 flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-mono text-[13px]">{visibility} km</span></div>
</div>
</div>
{/* 풍속 게이지 바 */}
@ -240,17 +160,6 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
</div>
</div>
{/* ── 바람 현황 ──────────────────────────────────── */}
<div className="px-5 py-3 border-b border-border">
<div className="text-text-3 mb-3">🏳 </div>
<div className="flex gap-4 items-start">
<WindCompass degrees={wind.direction} />
<div className="flex-1 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[13px]">
<span className="flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-semibold font-mono">{wind.directionLabel} {wind.direction}°</span></span>
<span className="flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-semibold font-mono">{pressure} hPa</span></span>
<span className="flex justify-between"><span className="text-text-3">1k </span><span className="text-text-1 font-semibold font-mono">{wind.speed_1k.toFixed(1)}</span></span>
<span className="flex justify-between"><span className="text-text-3">3k </span><span className="text-text-1 font-semibold font-mono">{wind.speed_3k.toFixed(1)}</span></span>
<span className="flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-semibold font-mono">{visibility} km</span></span>
{/* ── 파도 상세 ── */}
<div className="px-3 py-2 border-b border-border">
<div className="text-[11px] font-bold text-text-3 font-korean mb-2">🌊 </div>
@ -260,15 +169,15 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
<div className="text-[10px] text-text-3"></div>
</div>
<div className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-[14px] font-bold font-mono text-status-red">{(wHgt * 1.6).toFixed(1)}m</div>
<div className="text-[14px] font-bold font-mono text-status-red">{wave.maxHeight.toFixed(1)}m</div>
<div className="text-[10px] text-text-3"></div>
</div>
<div className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-[14px] font-bold font-mono text-primary-cyan">{weatherData.wave.period}s</div>
<div className="text-[14px] font-bold font-mono text-primary-cyan">{wave.period}s</div>
<div className="text-[10px] text-text-3"></div>
</div>
<div className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-[14px] font-bold font-mono text-text-1">NW</div>
<div className="text-[14px] font-bold font-mono text-text-1">{wave.direction}</div>
<div className="text-[10px] text-text-3"></div>
</div>
</div>
@ -279,38 +188,8 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
</div>
<span className="text-[11px] font-mono text-text-3 shrink-0">{wHgt.toFixed(1)}/5m</span>
</div>
<ProgressBar
value={wind.speed}
max={20}
gradient="linear-gradient(to right, #f97316, #eab308)"
label={`${wind.speed.toFixed(1)}/20`}
/>
</div>
{/* ── 파도 ───────────────────────────────────────── */}
<div className="px-5 py-3 border-b border-border">
<div className="text-[11px] text-text-3 mb-3">🌊 </div>
<div className="grid grid-cols-4 gap-1.5">
<StatCard value={`${wave.height.toFixed(1)}m`} label="유의파고" />
<StatCard value={`${wave.maxHeight.toFixed(1)}m`} label="최고파고" />
<StatCard value={`${wave.period}s`} label="주기" />
<StatCard value={wave.direction} label="파향" />
</div>
<ProgressBar
value={wave.height}
max={5}
gradient="linear-gradient(to right, #f97316, #6b7280)"
label={`${wave.height.toFixed(1)}/5m`}
/>
</div>
{/* ── 수온 · 공기 ────────────────────────────────── */}
<div className="px-5 py-3 border-b border-border">
<div className="text-[11px] text-text-3 mb-3">💧 · </div>
<div className="grid grid-cols-3 gap-1.5">
<StatCard value={`${temperature.current.toFixed(1)}°`} label="수온" />
<StatCard value={`${temperature.feelsLike.toFixed(1)}°`} label="기온" />
<StatCard value={`${salinity.toFixed(1)}`} label="염분(PSU)" />
{/* ── 수온/공기 ── */}
<div className="px-3 py-2 border-b border-border">
<div className="text-[11px] font-bold text-text-3 font-korean mb-2">🌡 · </div>
@ -320,34 +199,21 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
<div className="text-[10px] text-text-3"></div>
</div>
<div className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-[14px] font-bold font-mono text-text-1">2.1°</div>
<div className="text-[14px] font-bold font-mono text-text-1">{temperature.feelsLike.toFixed(1)}°</div>
<div className="text-[10px] text-text-3"></div>
</div>
<div className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-[14px] font-bold font-mono text-text-1">31.2</div>
<div className="text-[14px] font-bold font-mono text-text-1">{salinity.toFixed(1)}</div>
<div className="text-[10px] text-text-3">(PSU)</div>
</div>
</div>
</div>
{/* ── 시간별 예보 ────────────────────────────────── */}
<div className="px-5 py-3 border-b border-border">
<div className="text-[11px] text-text-3 mb-3">🕐 </div>
<div className="grid grid-cols-5 gap-1.5">
{forecast.map((fc, i) => (
<div
key={i}
className="bg-bg-2 border border-border rounded-md p-2 flex flex-col items-center gap-0.5"
>
<span className="text-[10px] text-text-3">{fc.hour}</span>
<span className="text-lg">{fc.icon}</span>
<span className="text-sm font-bold text-primary-cyan">{fc.temperature}°</span>
<span className="text-[10px] text-text-3">{fc.windSpeed}</span>
{/* ── 시간별 예보 ── */}
<div className="px-3 py-2 border-b border-border">
<div className="text-[11px] font-bold text-text-3 font-korean mb-2"> </div>
<div className="grid grid-cols-5 gap-1">
{weatherData.forecast.map((f, i) => (
{forecast.map((f, i) => (
<div key={i} className="flex flex-col items-center py-2 px-1 bg-bg-0 border border-border rounded">
<span className="text-[11px] text-text-3 mb-0.5">{f.hour}</span>
<span className="text-lg mb-0.5">{f.icon}</span>
@ -358,91 +224,44 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
</div>
</div>
{/* ── 천문 · 조석 ────────────────────────────────── */}
<div className="px-5 py-3 border-b border-border">
<div className="text-[11px] text-text-3 mb-3"> · </div>
{astronomy && (
<>
<div className="grid grid-cols-4 gap-1.5">
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-1">
<span className="text-base">🌅</span>
<span className="text-[9px] text-text-3"></span>
<span className="text-xs font-bold text-text-1 font-mono">{astronomy.sunrise}</span>
</div>
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-1">
<span className="text-base">🌇</span>
<span className="text-[9px] text-text-3"></span>
<span className="text-xs font-bold text-text-1 font-mono">{astronomy.sunset}</span>
</div>
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-1">
<span className="text-base">🌙</span>
<span className="text-[9px] text-text-3"></span>
<span className="text-xs font-bold text-text-1 font-mono">{astronomy.moonrise}</span>
</div>
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-1">
<span className="text-base">🌜</span>
<span className="text-[9px] text-text-3"></span>
<span className="text-xs font-bold text-text-1 font-mono">{astronomy.moonset}</span>
</div>
</div>
<div className="flex items-center justify-between mt-2 text-[11px] bg-bg-2 border border-border rounded-md p-2.5">
<div className="flex items-center gap-2">
<span>🌓</span>
<span className="text-text-3">{astronomy.moonPhase}</span>
</div>
<div className="text-text-3">
<span className="ml-2 text-text-1 font-semibold font-mono">{astronomy.tidalRange}m</span>
</div>
</div>
</>
)}
</div>
{/* ── 날씨 특보 ──────────────────────────────────── */}
{alert && (
<div className="px-5 py-3">
<div className="text-[11px] text-text-3 mb-3">🚨 </div>
<div className="p-3 bg-status-red/10 border border-status-red/30 rounded-md">
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-status-red text-white"></span>
<span className="text-text-1 text-xs">{alert}</span>
</div>
</div>
{/* ── 천문/조석 ── */}
<div className="px-3 py-2 border-b border-border">
<div className="text-[11px] font-bold text-text-3 font-korean mb-2"> · </div>
<div className="grid grid-cols-4 gap-1">
{[
{ icon: '🌅', label: '일출', value: sunriseTime },
{ icon: '🌄', label: '일몰', value: sunsetTime },
{ icon: '🌙', label: '월출', value: moonrise },
{ icon: '🌜', label: '월몰', value: moonset },
].map((item, i) => (
<div key={i} className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-base mb-0.5">{item.icon}</div>
<div className="text-[10px] text-text-3">{item.label}</div>
<div className="text-[13px] font-bold font-mono text-text-1">{item.value}</div>
</div>
))}
</div>
<div className="flex items-center gap-2 mt-1.5 px-2 py-1 bg-bg-0 border border-border rounded text-[11px]">
<span className="text-sm">🌓</span>
<span className="text-text-3"> 14</span>
<span className="ml-auto text-text-1 font-mono"> 6.7m</span>
{astronomy && (
<div className="px-3 py-2 border-b border-border">
<div className="text-[11px] font-bold text-text-3 font-korean mb-2"> · </div>
<div className="grid grid-cols-4 gap-1">
{[
{ icon: '🌅', label: '일출', value: astronomy.sunrise },
{ icon: '🌄', label: '일몰', value: astronomy.sunset },
{ icon: '🌙', label: '월출', value: astronomy.moonrise },
{ icon: '🌜', label: '월몰', value: astronomy.moonset },
].map((item, i) => (
<div key={i} className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-base mb-0.5">{item.icon}</div>
<div className="text-[10px] text-text-3">{item.label}</div>
<div className="text-[13px] font-bold font-mono text-text-1">{item.value}</div>
</div>
))}
</div>
<div className="flex items-center gap-2 mt-1.5 px-2 py-1 bg-bg-0 border border-border rounded text-[11px]">
<span className="text-sm">🌓</span>
<span className="text-text-3">{astronomy.moonPhase}</span>
<span className="ml-auto text-text-1 font-mono"> {astronomy.tidalRange}m</span>
</div>
</div>
)}
</div>
{/* ── 날씨 특보 ── */}
<div className="px-3 py-2">
<div className="text-[11px] font-bold text-text-3 font-korean mb-2">🚨 </div>
<div className="px-2.5 py-2 rounded border" style={{ background: 'rgba(239,68,68,.06)', borderColor: 'rgba(239,68,68,.2)' }}>
<div className="flex items-center gap-2 text-[11px]">
<span className="px-1.5 py-px rounded text-[11px] font-bold" style={{ background: 'rgba(239,68,68,.15)', color: 'var(--red)' }}></span>
<span className="text-text-1 font-korean"> 08:00~</span>
{alert && (
<div className="px-3 py-2">
<div className="text-[11px] font-bold text-text-3 font-korean mb-2">🚨 </div>
<div className="px-2.5 py-2 rounded border" style={{ background: 'rgba(239,68,68,.06)', borderColor: 'rgba(239,68,68,.2)' }}>
<div className="flex items-center gap-2 text-[11px]">
<span className="px-1.5 py-px rounded text-[11px] font-bold" style={{ background: 'rgba(239,68,68,.15)', color: 'var(--red)' }}></span>
<span className="text-text-1 font-korean">{alert}</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>