Merge pull request 'feat(hns): HNS 정보 레이어 패널 추가 및 분석 파라미터 보강' (#192) from feature/hns-improvements into develop
This commit is contained in:
커밋
70fe23e40b
1
.gitignore
vendored
1
.gitignore
vendored
@ -106,6 +106,7 @@ backend/scripts/hns-import/out/
|
||||
|
||||
# mcp
|
||||
.mcp.json
|
||||
.playwright-mcp/
|
||||
|
||||
# python
|
||||
.venv
|
||||
10
database/migration/033_spil_qty_expand.sql
Normal file
10
database/migration/033_spil_qty_expand.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- 033: SPIL_QTY 정수부 확대 — HNS 대용량 유출량 지원
|
||||
-- 031에서 NUMERIC(14,10)으로 변경된 결과 정수부가 4자리(|x| < 10^4)로 좁아져
|
||||
-- HNS 기본 유출량(5000~20000 g) 입력 시 22003 오버플로우 발생.
|
||||
-- 소수 10자리는 유지하여 이미지 분석 1e-7 정밀도와 호환 유지.
|
||||
|
||||
ALTER TABLE wing.SPIL_DATA ALTER COLUMN SPIL_QTY TYPE NUMERIC(22,10);
|
||||
ALTER TABLE wing.HNS_ANALYSIS ALTER COLUMN SPIL_QTY TYPE NUMERIC(22,10);
|
||||
|
||||
COMMENT ON COLUMN wing.SPIL_DATA.SPIL_QTY IS '유출량 (정수 12자리 + 소수 10자리, |x| < 10^12)';
|
||||
COMMENT ON COLUMN wing.HNS_ANALYSIS.SPIL_QTY IS '유출량 (정수 12자리 + 소수 10자리, |x| < 10^12)';
|
||||
@ -4,6 +4,16 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- HNS: 정보 레이어 패널 통합 (레이어 표시/불투명도/밝기/색상 제어)
|
||||
- HNS: 분석 생성 시 유출량·단위·예측 시간·알고리즘·기준 모델 파라미터 전달
|
||||
|
||||
### 변경
|
||||
- InfoLayerSection을 공통 컴포넌트로 이동 (prediction → common/layer)
|
||||
|
||||
### 기타
|
||||
- DB migration 033: SPIL_QTY NUMERIC(22,10) 확장 (대용량 HNS 유출량 지원)
|
||||
|
||||
## [2026-04-17]
|
||||
|
||||
### 추가
|
||||
|
||||
176
frontend/src/components/common/layer/InfoLayerSection.tsx
Normal file
176
frontend/src/components/common/layer/InfoLayerSection.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import { LayerTree } from '@components/common/layer/LayerTree';
|
||||
import { useLayerTree } from '@common/hooks/useLayers';
|
||||
import type { Layer } from '@common/services/layerService';
|
||||
|
||||
interface InfoLayerSectionProps {
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
enabledLayers: Set<string>;
|
||||
onToggleLayer: (layerId: string, enabled: boolean) => void;
|
||||
layerOpacity: number;
|
||||
onLayerOpacityChange: (val: number) => void;
|
||||
layerBrightness: number;
|
||||
onLayerBrightnessChange: (val: number) => void;
|
||||
layerColors: Record<string, string>;
|
||||
onLayerColorChange: (layerId: string, color: string) => void;
|
||||
}
|
||||
|
||||
const InfoLayerSection = ({
|
||||
expanded,
|
||||
onToggle,
|
||||
enabledLayers,
|
||||
onToggleLayer,
|
||||
layerOpacity,
|
||||
onLayerOpacityChange,
|
||||
layerBrightness,
|
||||
onLayerBrightnessChange,
|
||||
layerColors,
|
||||
onLayerColorChange,
|
||||
}: InfoLayerSectionProps) => {
|
||||
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
|
||||
const { data: layerTree, isLoading } = useLayerTree();
|
||||
|
||||
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
|
||||
const effectiveLayers: Layer[] = layerTree ?? [];
|
||||
|
||||
return (
|
||||
<div className="border-b border-stroke">
|
||||
<div className="flex items-center justify-between p-4 hover:bg-[rgba(255,255,255,0.02)]">
|
||||
<h3
|
||||
onClick={onToggle}
|
||||
className="text-title-4 font-bold text-fg-default font-korean cursor-pointer"
|
||||
>
|
||||
정보 레이어
|
||||
</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Get all layer IDs from layerTree recursively
|
||||
const getAllLayerIds = (layers: Layer[]): string[] => {
|
||||
const ids: string[] = [];
|
||||
layers?.forEach((layer) => {
|
||||
ids.push(layer.id);
|
||||
if (layer.children) {
|
||||
ids.push(...getAllLayerIds(layer.children));
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
const allIds = getAllLayerIds(effectiveLayers);
|
||||
allIds.forEach((id) => onToggleLayer(id, true));
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: 'var(--font-size-label-2)',
|
||||
fontWeight: 500,
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'transparent',
|
||||
color: 'var(--fg-sub)',
|
||||
cursor: 'pointer',
|
||||
transition: '0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-surface-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
전체 켜기
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Get all layer IDs from layerTree recursively
|
||||
const getAllLayerIds = (layers: Layer[]): string[] => {
|
||||
const ids: string[] = [];
|
||||
layers?.forEach((layer) => {
|
||||
ids.push(layer.id);
|
||||
if (layer.children) {
|
||||
ids.push(...getAllLayerIds(layer.children));
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
const allIds = getAllLayerIds(effectiveLayers);
|
||||
allIds.forEach((id) => onToggleLayer(id, false));
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: 'var(--font-size-label-2)',
|
||||
fontWeight: 500,
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'transparent',
|
||||
color: 'var(--fg-sub)',
|
||||
cursor: 'pointer',
|
||||
transition: '0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-surface-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
전체 끄기
|
||||
</button>
|
||||
<span onClick={onToggle} className="text-label-2 text-fg-default cursor-pointer">
|
||||
{expanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 pb-2">
|
||||
{isLoading && effectiveLayers.length === 0 ? (
|
||||
<p className="text-label-2 text-fg-default py-2">레이어 로딩 중...</p>
|
||||
) : effectiveLayers.length === 0 ? (
|
||||
<p className="text-label-2 text-fg-default py-2">레이어 데이터가 없습니다.</p>
|
||||
) : (
|
||||
<LayerTree
|
||||
layers={effectiveLayers}
|
||||
enabledLayers={enabledLayers}
|
||||
onToggleLayer={onToggleLayer}
|
||||
layerColors={layerColors}
|
||||
onColorChange={onLayerColorChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 레이어 스타일 조절 */}
|
||||
<div className="lyr-style-box">
|
||||
<div className="lyr-style-label">레이어 스타일</div>
|
||||
<div className="lyr-style-row">
|
||||
<span className="lyr-style-name">투명도</span>
|
||||
<input
|
||||
type="range"
|
||||
className="lyr-style-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layerOpacity}
|
||||
onChange={(e) => onLayerOpacityChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="lyr-style-val">{layerOpacity}%</span>
|
||||
</div>
|
||||
<div className="lyr-style-row">
|
||||
<span className="lyr-style-name">밝기</span>
|
||||
<input
|
||||
type="range"
|
||||
className="lyr-style-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layerBrightness}
|
||||
onChange={(e) => onLayerBrightnessChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="lyr-style-val">{layerBrightness}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoLayerSection;
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
import type { ReleaseType } from '@/types/hns/HnsType';
|
||||
import { fetchGscAccidents } from '@components/prediction/services/predictionApi';
|
||||
import type { GscAccidentListItem } from '@interfaces/prediction/PredictionInterface';
|
||||
import InfoLayerSection from '@components/common/layer/InfoLayerSection';
|
||||
|
||||
interface HNSLeftPanelProps {
|
||||
activeSubTab: 'analysis' | 'list';
|
||||
@ -21,6 +22,14 @@ interface HNSLeftPanelProps {
|
||||
onReset?: () => void;
|
||||
loadedParams?: Partial<HNSInputParams> | null;
|
||||
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
|
||||
enabledLayers: Set<string>;
|
||||
onToggleLayer: (layerId: string, enabled: boolean) => void;
|
||||
layerOpacity: number;
|
||||
onLayerOpacityChange: (val: number) => void;
|
||||
layerBrightness: number;
|
||||
onLayerBrightnessChange: (val: number) => void;
|
||||
layerColors: Record<string, string>;
|
||||
onLayerColorChange: (layerId: string, color: string) => void;
|
||||
}
|
||||
|
||||
/** 십진 좌표 → 도분초 변환 */
|
||||
@ -45,12 +54,20 @@ export function HNSLeftPanel({
|
||||
onReset,
|
||||
loadedParams,
|
||||
onFlyToCoord,
|
||||
enabledLayers,
|
||||
onToggleLayer,
|
||||
layerOpacity,
|
||||
onLayerOpacityChange,
|
||||
layerBrightness,
|
||||
onLayerBrightnessChange,
|
||||
layerColors,
|
||||
onLayerColorChange,
|
||||
}: HNSLeftPanelProps) {
|
||||
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
|
||||
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
|
||||
const [selectedAcdntSn, setSelectedAcdntSn] = useState<number | undefined>(undefined);
|
||||
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true });
|
||||
const toggleSection = (key: 'accident' | 'params') =>
|
||||
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true, infoLayer: false });
|
||||
const toggleSection = (key: 'accident' | 'params' | 'infoLayer') =>
|
||||
setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const [accidentName, setAccidentName] = useState('');
|
||||
@ -691,6 +708,19 @@ export function HNSLeftPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<InfoLayerSection
|
||||
expanded={expandedSections.infoLayer}
|
||||
onToggle={() => toggleSection('infoLayer')}
|
||||
enabledLayers={enabledLayers}
|
||||
onToggleLayer={onToggleLayer}
|
||||
layerOpacity={layerOpacity}
|
||||
onLayerOpacityChange={onLayerOpacityChange}
|
||||
layerBrightness={layerBrightness}
|
||||
onLayerBrightnessChange={onLayerBrightnessChange}
|
||||
layerColors={layerColors}
|
||||
onLayerColorChange={onLayerColorChange}
|
||||
/>
|
||||
|
||||
{/* 실행 버튼 */}
|
||||
<div className="flex flex-col gap-1 px-4 py-3">
|
||||
<button
|
||||
|
||||
@ -78,6 +78,10 @@ export function HNSView() {
|
||||
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||
const vessels = useVesselSignals(mapBounds);
|
||||
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set());
|
||||
const [layerOpacity, setLayerOpacity] = useState(50);
|
||||
const [layerBrightness, setLayerBrightness] = useState(50);
|
||||
const [layerColors, setLayerColors] = useState<Record<string, string>>({});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [dispersionResult, setDispersionResult] = useState<any>(null);
|
||||
const [recalcModalOpen, setRecalcModalOpen] = useState(false);
|
||||
@ -107,6 +111,18 @@ export function HNSView() {
|
||||
hasRunOnce.current = false;
|
||||
}, []);
|
||||
|
||||
const handleToggleLayer = (layerId: string, enabled: boolean) => {
|
||||
setEnabledLayers((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (enabled) next.add(layerId);
|
||||
else next.delete(layerId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleLayerColorChange = (layerId: string, color: string) =>
|
||||
setLayerColors((prev) => ({ ...prev, [layerId]: color }));
|
||||
|
||||
const handleParamsChange = useCallback((params: HNSInputParams) => {
|
||||
setInputParams(params);
|
||||
}, []);
|
||||
@ -341,7 +357,10 @@ export function HNSView() {
|
||||
params?.accidentDate && params?.accidentTime
|
||||
? `${params.accidentDate}T${params.accidentTime}:00`
|
||||
: params?.accidentDate || undefined;
|
||||
const result = await createHnsAnalysis({
|
||||
const fcstHrNum = parseInt(params?.predictionTime ?? '') || 24;
|
||||
const spilQtyVal =
|
||||
params?.releaseType === '순간 유출' ? params?.totalRelease : params?.emissionRate;
|
||||
const created = await createHnsAnalysis({
|
||||
anlysNm: params?.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`,
|
||||
acdntSn: params?.selectedAcdntSn,
|
||||
acdntDtm,
|
||||
@ -349,6 +368,9 @@ export function HNSView() {
|
||||
lat: incidentCoord.lat,
|
||||
locNm: `${incidentCoord.lat.toFixed(4)} / ${incidentCoord.lon.toFixed(4)}`,
|
||||
sbstNm: params?.substance,
|
||||
spilQty: spilQtyVal,
|
||||
spilUnitCd: params?.releaseType === '순간 유출' ? 'g' : 'g/s',
|
||||
fcstHr: fcstHrNum,
|
||||
windSpd: params?.weather?.windSpeed,
|
||||
windDir:
|
||||
params?.weather?.windDirection != null
|
||||
@ -357,14 +379,66 @@ export function HNSView() {
|
||||
temp: params?.weather?.temperature,
|
||||
humid: params?.weather?.humidity,
|
||||
atmStblCd: params?.weather?.stability,
|
||||
algoCd: params?.algorithm,
|
||||
critMdlCd: params?.criteriaModel,
|
||||
analystNm: user?.name || undefined,
|
||||
});
|
||||
// DB 저장 성공 시 SN 업데이트
|
||||
|
||||
// 실행 결과를 즉시 DB에 저장하여 목록에 바로 반영
|
||||
const runZones = [
|
||||
{ level: 'AEGL-3', color: '#ef4444', radius: resultForZones.aeglDistances.aegl3, angle: meteo.windDirDeg },
|
||||
{ level: 'AEGL-2', color: '#f97316', radius: resultForZones.aeglDistances.aegl2, angle: meteo.windDirDeg },
|
||||
{ level: 'AEGL-1', color: '#eab308', radius: resultForZones.aeglDistances.aegl1, angle: meteo.windDirDeg },
|
||||
].filter((z) => z.radius > 0);
|
||||
const runRsltData: Record<string, unknown> = {
|
||||
inputParams: {
|
||||
substance: params?.substance,
|
||||
releaseType: params?.releaseType,
|
||||
emissionRate: params?.emissionRate,
|
||||
totalRelease: params?.totalRelease,
|
||||
releaseHeight: params?.releaseHeight,
|
||||
releaseDuration: params?.releaseDuration,
|
||||
poolRadius: params?.poolRadius,
|
||||
algorithm: params?.algorithm,
|
||||
criteriaModel: params?.criteriaModel,
|
||||
accidentDate: params?.accidentDate,
|
||||
accidentTime: params?.accidentTime,
|
||||
predictionTime: params?.predictionTime,
|
||||
accidentName: params?.accidentName,
|
||||
},
|
||||
coord: { lon: incidentCoord.lon, lat: incidentCoord.lat },
|
||||
zones: runZones,
|
||||
aeglDistances: resultForZones.aeglDistances,
|
||||
aeglAreas: resultForZones.aeglAreas,
|
||||
maxConcentration: resultForZones.maxConcentration,
|
||||
modelType: resultForZones.modelType,
|
||||
weather: {
|
||||
windSpeed: params?.weather?.windSpeed,
|
||||
windDirection: params?.weather?.windDirection,
|
||||
temperature: params?.weather?.temperature,
|
||||
humidity: params?.weather?.humidity,
|
||||
stability: params?.weather?.stability,
|
||||
},
|
||||
aegl3: (resultForZones.aeglDistances?.aegl3 ?? 0) > 0,
|
||||
aegl2: (resultForZones.aeglDistances?.aegl2 ?? 0) > 0,
|
||||
aegl1: (resultForZones.aeglDistances?.aegl1 ?? 0) > 0,
|
||||
damageRadius: `${((resultForZones.aeglDistances?.aegl1 ?? 0) / 1000).toFixed(1)} km`,
|
||||
};
|
||||
let runRiskCd = 'LOW';
|
||||
if ((resultForZones.aeglDistances?.aegl3 ?? 0) > 0) runRiskCd = 'CRITICAL';
|
||||
else if ((resultForZones.aeglDistances?.aegl2 ?? 0) > 0) runRiskCd = 'HIGH';
|
||||
else if ((resultForZones.aeglDistances?.aegl1 ?? 0) > 0) runRiskCd = 'MEDIUM';
|
||||
// 생성 성공 즉시 SN 기록 — saveHnsAnalysis 실패 시에도 중복 생성 방지
|
||||
setDispersionResult((prev: Record<string, unknown> | null) =>
|
||||
prev ? { ...prev, hnsAnlysSn: result.hnsAnlysSn } : prev,
|
||||
prev ? { ...prev, hnsAnlysSn: created.hnsAnlysSn } : prev,
|
||||
);
|
||||
} catch {
|
||||
// API 실패 시 무시 (히트맵은 이미 표시됨)
|
||||
await saveHnsAnalysis(created.hnsAnlysSn, {
|
||||
rsltData: runRsltData,
|
||||
execSttsCd: 'COMPLETED',
|
||||
riskCd: runRiskCd,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[HNS] 분석 DB 저장 실패 (히트맵은 유지됨):', err);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('대기확산 예측 오류:', error);
|
||||
@ -760,6 +834,14 @@ export function HNSView() {
|
||||
onReset={handleReset}
|
||||
loadedParams={loadedParams}
|
||||
onFlyToCoord={(c) => setFlyToCoord({ lat: c.lat, lon: c.lon })}
|
||||
enabledLayers={enabledLayers}
|
||||
onToggleLayer={handleToggleLayer}
|
||||
layerOpacity={layerOpacity}
|
||||
onLayerOpacityChange={setLayerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
onLayerBrightnessChange={setLayerBrightness}
|
||||
layerColors={layerColors}
|
||||
onLayerColorChange={handleLayerColorChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -825,7 +907,10 @@ export function HNSView() {
|
||||
isSelectingLocation={isSelectingLocation}
|
||||
onMapClick={handleMapClick}
|
||||
oilTrajectory={[]}
|
||||
enabledLayers={new Set()}
|
||||
enabledLayers={enabledLayers}
|
||||
layerOpacity={layerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
layerColors={layerColors}
|
||||
dispersionResult={dispersionResult}
|
||||
dispersionHeatmap={heatmapData}
|
||||
mapCaptureRef={mapCaptureRef}
|
||||
|
||||
@ -1,176 +1 @@
|
||||
import { LayerTree } from '@components/common/layer/LayerTree';
|
||||
import { useLayerTree } from '@common/hooks/useLayers';
|
||||
import type { Layer } from '@common/services/layerService';
|
||||
|
||||
interface InfoLayerSectionProps {
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
enabledLayers: Set<string>;
|
||||
onToggleLayer: (layerId: string, enabled: boolean) => void;
|
||||
layerOpacity: number;
|
||||
onLayerOpacityChange: (val: number) => void;
|
||||
layerBrightness: number;
|
||||
onLayerBrightnessChange: (val: number) => void;
|
||||
layerColors: Record<string, string>;
|
||||
onLayerColorChange: (layerId: string, color: string) => void;
|
||||
}
|
||||
|
||||
const InfoLayerSection = ({
|
||||
expanded,
|
||||
onToggle,
|
||||
enabledLayers,
|
||||
onToggleLayer,
|
||||
layerOpacity,
|
||||
onLayerOpacityChange,
|
||||
layerBrightness,
|
||||
onLayerBrightnessChange,
|
||||
layerColors,
|
||||
onLayerColorChange,
|
||||
}: InfoLayerSectionProps) => {
|
||||
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
|
||||
const { data: layerTree, isLoading } = useLayerTree();
|
||||
|
||||
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
|
||||
const effectiveLayers: Layer[] = layerTree ?? [];
|
||||
|
||||
return (
|
||||
<div className="border-b border-stroke">
|
||||
<div className="flex items-center justify-between p-4 hover:bg-[rgba(255,255,255,0.02)]">
|
||||
<h3
|
||||
onClick={onToggle}
|
||||
className="text-title-4 font-bold text-fg-default font-korean cursor-pointer"
|
||||
>
|
||||
정보 레이어
|
||||
</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Get all layer IDs from layerTree recursively
|
||||
const getAllLayerIds = (layers: Layer[]): string[] => {
|
||||
const ids: string[] = [];
|
||||
layers?.forEach((layer) => {
|
||||
ids.push(layer.id);
|
||||
if (layer.children) {
|
||||
ids.push(...getAllLayerIds(layer.children));
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
const allIds = getAllLayerIds(effectiveLayers);
|
||||
allIds.forEach((id) => onToggleLayer(id, true));
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: 'var(--font-size-label-2)',
|
||||
fontWeight: 500,
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'transparent',
|
||||
color: 'var(--fg-sub)',
|
||||
cursor: 'pointer',
|
||||
transition: '0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-surface-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
전체 켜기
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Get all layer IDs from layerTree recursively
|
||||
const getAllLayerIds = (layers: Layer[]): string[] => {
|
||||
const ids: string[] = [];
|
||||
layers?.forEach((layer) => {
|
||||
ids.push(layer.id);
|
||||
if (layer.children) {
|
||||
ids.push(...getAllLayerIds(layer.children));
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
const allIds = getAllLayerIds(effectiveLayers);
|
||||
allIds.forEach((id) => onToggleLayer(id, false));
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: 'var(--font-size-label-2)',
|
||||
fontWeight: 500,
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'transparent',
|
||||
color: 'var(--fg-sub)',
|
||||
cursor: 'pointer',
|
||||
transition: '0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-surface-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
전체 끄기
|
||||
</button>
|
||||
<span onClick={onToggle} className="text-label-2 text-fg-default cursor-pointer">
|
||||
{expanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 pb-2">
|
||||
{isLoading && effectiveLayers.length === 0 ? (
|
||||
<p className="text-label-2 text-fg-default py-2">레이어 로딩 중...</p>
|
||||
) : effectiveLayers.length === 0 ? (
|
||||
<p className="text-label-2 text-fg-default py-2">레이어 데이터가 없습니다.</p>
|
||||
) : (
|
||||
<LayerTree
|
||||
layers={effectiveLayers}
|
||||
enabledLayers={enabledLayers}
|
||||
onToggleLayer={onToggleLayer}
|
||||
layerColors={layerColors}
|
||||
onColorChange={onLayerColorChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 레이어 스타일 조절 */}
|
||||
<div className="lyr-style-box">
|
||||
<div className="lyr-style-label">레이어 스타일</div>
|
||||
<div className="lyr-style-row">
|
||||
<span className="lyr-style-name">투명도</span>
|
||||
<input
|
||||
type="range"
|
||||
className="lyr-style-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layerOpacity}
|
||||
onChange={(e) => onLayerOpacityChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="lyr-style-val">{layerOpacity}%</span>
|
||||
</div>
|
||||
<div className="lyr-style-row">
|
||||
<span className="lyr-style-name">밝기</span>
|
||||
<input
|
||||
type="range"
|
||||
className="lyr-style-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layerBrightness}
|
||||
onChange={(e) => onLayerBrightnessChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="lyr-style-val">{layerBrightness}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoLayerSection;
|
||||
export { default } from '@components/common/layer/InfoLayerSection';
|
||||
|
||||
@ -59,7 +59,7 @@ const CATEGORY_ICON_MAP: Record<string, CategoryMeta> = {
|
||||
|
||||
const FALLBACK_META: CategoryMeta = { icon: '🌊', bg: 'rgba(148,163,184,0.15)' };
|
||||
import PredictionInputSection from './PredictionInputSection';
|
||||
import InfoLayerSection from './InfoLayerSection';
|
||||
import InfoLayerSection from '@components/common/layer/InfoLayerSection';
|
||||
import OilBoomSection from './OilBoomSection';
|
||||
|
||||
export type { LeftPanelProps };
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user