Merge pull request 'release: 2026-03-24 (160건 커밋)' (#118) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 36s
@ -5,29 +5,29 @@
|
|||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(curl -s *)",
|
"Bash(npm run *)",
|
||||||
"Bash(fnm *)",
|
"Bash(npm install *)",
|
||||||
"Bash(git add *)",
|
"Bash(npm test *)",
|
||||||
|
"Bash(npx *)",
|
||||||
|
"Bash(node *)",
|
||||||
|
"Bash(git status)",
|
||||||
|
"Bash(git diff *)",
|
||||||
|
"Bash(git log *)",
|
||||||
"Bash(git branch *)",
|
"Bash(git branch *)",
|
||||||
"Bash(git checkout *)",
|
"Bash(git checkout *)",
|
||||||
|
"Bash(git add *)",
|
||||||
"Bash(git commit *)",
|
"Bash(git commit *)",
|
||||||
"Bash(git config *)",
|
|
||||||
"Bash(git diff *)",
|
|
||||||
"Bash(git fetch *)",
|
|
||||||
"Bash(git log *)",
|
|
||||||
"Bash(git merge *)",
|
|
||||||
"Bash(git pull *)",
|
"Bash(git pull *)",
|
||||||
|
"Bash(git fetch *)",
|
||||||
|
"Bash(git merge *)",
|
||||||
|
"Bash(git stash *)",
|
||||||
"Bash(git remote *)",
|
"Bash(git remote *)",
|
||||||
|
"Bash(git config *)",
|
||||||
"Bash(git rev-parse *)",
|
"Bash(git rev-parse *)",
|
||||||
"Bash(git show *)",
|
"Bash(git show *)",
|
||||||
"Bash(git stash *)",
|
|
||||||
"Bash(git status)",
|
|
||||||
"Bash(git tag *)",
|
"Bash(git tag *)",
|
||||||
"Bash(node *)",
|
"Bash(curl -s *)",
|
||||||
"Bash(npm install *)",
|
"Bash(fnm *)"
|
||||||
"Bash(npm run *)",
|
|
||||||
"Bash(npm test *)",
|
|
||||||
"Bash(npx *)"
|
|
||||||
],
|
],
|
||||||
"deny": [
|
"deny": [
|
||||||
"Bash(git push --force*)",
|
"Bash(git push --force*)",
|
||||||
@ -83,7 +83,5 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
"deny": [],
|
|
||||||
"allow": []
|
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-03-19",
|
"applied_date": "2026-03-24",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
getIncident,
|
getIncident,
|
||||||
listIncidentPredictions,
|
listIncidentPredictions,
|
||||||
getIncidentWeather,
|
getIncidentWeather,
|
||||||
|
saveIncidentWeather,
|
||||||
getIncidentMedia,
|
getIncidentMedia,
|
||||||
} from './incidentsService.js';
|
} from './incidentsService.js';
|
||||||
|
|
||||||
@ -92,6 +93,24 @@ router.get('/:sn/weather', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// POST /api/incidents/:sn/weather — 기상정보 저장
|
||||||
|
// ============================================================
|
||||||
|
router.post('/:sn/weather', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sn = parseInt(req.params.sn as string, 10);
|
||||||
|
if (isNaN(sn)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const weatherSn = await saveIncidentWeather(sn, req.body as Record<string, unknown>);
|
||||||
|
res.json({ weatherSn });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[incidents] 기상정보 저장 오류:', err);
|
||||||
|
res.status(500).json({ error: '기상정보 저장 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// GET /api/incidents/:sn/media — 미디어 정보
|
// GET /api/incidents/:sn/media — 미디어 정보
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -254,24 +254,143 @@ export async function getIncidentWeather(acdntSn: number): Promise<WeatherInfo |
|
|||||||
const r = rows[0] as Record<string, unknown>;
|
const r = rows[0] as Record<string, unknown>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locNm: r.loc_nm as string,
|
locNm: (r.loc_nm as string | null) ?? '-',
|
||||||
obsDtm: (r.obs_dtm as Date).toISOString(),
|
obsDtm: r.obs_dtm ? (r.obs_dtm as Date).toISOString() : '-',
|
||||||
icon: r.icon as string,
|
icon: (r.icon as string | null) ?? '',
|
||||||
temp: r.temp as string,
|
temp: (r.temp as string | null) ?? '-',
|
||||||
weatherDc: r.weather_dc as string,
|
weatherDc: (r.weather_dc as string | null) ?? '-',
|
||||||
wind: r.wind as string,
|
wind: (r.wind as string | null) ?? '-',
|
||||||
wave: r.wave as string,
|
wave: (r.wave as string | null) ?? '-',
|
||||||
humid: r.humid as string,
|
humid: (r.humid as string | null) ?? '-',
|
||||||
vis: r.vis as string,
|
vis: (r.vis as string | null) ?? '-',
|
||||||
sst: r.sst as string,
|
sst: (r.sst as string | null) ?? '-',
|
||||||
tide: r.tide as string,
|
tide: (r.tide as string | null) ?? '-',
|
||||||
highTide: r.high_tide as string,
|
highTide: (r.high_tide as string | null) ?? '-',
|
||||||
lowTide: r.low_tide as string,
|
lowTide: (r.low_tide as string | null) ?? '-',
|
||||||
forecast: (r.forecast as Array<{ hour: string; icon: string; temp: string }>) ?? [],
|
forecast: (r.forecast as Array<{ hour: string; icon: string; temp: string }>) ?? [],
|
||||||
impactDc: r.impact_dc as string,
|
impactDc: (r.impact_dc as string | null) ?? '-',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 기상정보 저장 (예측 실행 시 스냅샷 저장)
|
||||||
|
// ============================================================
|
||||||
|
interface WeatherSnapshotPayload {
|
||||||
|
stationName?: string;
|
||||||
|
capturedAt?: string;
|
||||||
|
wind?: {
|
||||||
|
speed?: number;
|
||||||
|
direction?: number;
|
||||||
|
directionLabel?: string;
|
||||||
|
speed_1k?: number;
|
||||||
|
speed_3k?: number;
|
||||||
|
};
|
||||||
|
wave?: {
|
||||||
|
height?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
period?: number;
|
||||||
|
direction?: string;
|
||||||
|
};
|
||||||
|
temperature?: {
|
||||||
|
current?: number;
|
||||||
|
feelsLike?: number;
|
||||||
|
};
|
||||||
|
pressure?: number;
|
||||||
|
visibility?: number;
|
||||||
|
salinity?: number;
|
||||||
|
astronomy?: {
|
||||||
|
sunrise?: string;
|
||||||
|
sunset?: string;
|
||||||
|
moonrise?: string;
|
||||||
|
moonset?: string;
|
||||||
|
moonPhase?: string;
|
||||||
|
tidalRange?: number;
|
||||||
|
} | null;
|
||||||
|
alert?: string | null;
|
||||||
|
forecast?: unknown[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveIncidentWeather(
|
||||||
|
acdntSn: number,
|
||||||
|
snapshot: WeatherSnapshotPayload,
|
||||||
|
): Promise<number> {
|
||||||
|
// 팝업 표시용 포맷 문자열
|
||||||
|
const windStr = (snapshot.wind?.directionLabel && snapshot.wind?.speed != null)
|
||||||
|
? `${snapshot.wind.directionLabel} ${snapshot.wind.speed}m/s` : null;
|
||||||
|
const waveStr = snapshot.wave?.height != null ? `${snapshot.wave.height}m` : null;
|
||||||
|
const tempStr = snapshot.temperature?.feelsLike != null ? `${snapshot.temperature.feelsLike}°C` : null;
|
||||||
|
const vis = snapshot.visibility != null ? String(snapshot.visibility) : null;
|
||||||
|
const sst = snapshot.temperature?.current != null ? String(snapshot.temperature.current) : null;
|
||||||
|
const highTideStr = snapshot.astronomy?.tidalRange != null
|
||||||
|
? `조차 ${snapshot.astronomy.tidalRange}m` : null;
|
||||||
|
|
||||||
|
// 24h 예보: WeatherSnapshot 형식 → 팝업 표시 형식 변환
|
||||||
|
type ForecastItem = { time?: string; icon?: string; temperature?: number };
|
||||||
|
const forecastDisplay = (snapshot.forecast as ForecastItem[] | null)?.map(f => ({
|
||||||
|
hour: f.time ?? '',
|
||||||
|
icon: f.icon ?? '⛅',
|
||||||
|
temp: f.temperature != null ? `${Math.round(f.temperature)}°` : '-',
|
||||||
|
})) ?? null;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO wing.ACDNT_WEATHER (
|
||||||
|
ACDNT_SN, LOC_NM, OBS_DTM,
|
||||||
|
WIND_SPEED, WIND_DIR, WIND_DIR_LBL, WIND_SPEED_1K, WIND_SPEED_3K,
|
||||||
|
PRESSURE, VIS,
|
||||||
|
WAVE_HEIGHT, WAVE_MAX_HT, WAVE_PERIOD, WAVE_DIR,
|
||||||
|
SST, AIR_TEMP, SALINITY,
|
||||||
|
SUNRISE, SUNSET, MOONRISE, MOONSET, MOON_PHASE, TIDAL_RANGE,
|
||||||
|
WEATHER_ALERT, FORECAST,
|
||||||
|
TEMP, WIND, WAVE, ICON, HIGH_TIDE, IMPACT_DC
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, NOW(),
|
||||||
|
$3, $4, $5, $6, $7,
|
||||||
|
$8, $9,
|
||||||
|
$10, $11, $12, $13,
|
||||||
|
$14, $15, $16,
|
||||||
|
$17, $18, $19, $20, $21, $22,
|
||||||
|
$23, $24,
|
||||||
|
$25, $26, $27, $28, $29, $30
|
||||||
|
)
|
||||||
|
RETURNING WEATHER_SN
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await wingPool.query(sql, [
|
||||||
|
acdntSn,
|
||||||
|
snapshot.stationName ?? null,
|
||||||
|
snapshot.wind?.speed ?? null,
|
||||||
|
snapshot.wind?.direction ?? null,
|
||||||
|
snapshot.wind?.directionLabel ?? null,
|
||||||
|
snapshot.wind?.speed_1k ?? null,
|
||||||
|
snapshot.wind?.speed_3k ?? null,
|
||||||
|
snapshot.pressure ?? null,
|
||||||
|
vis,
|
||||||
|
snapshot.wave?.height ?? null,
|
||||||
|
snapshot.wave?.maxHeight ?? null,
|
||||||
|
snapshot.wave?.period ?? null,
|
||||||
|
snapshot.wave?.direction ?? null,
|
||||||
|
sst,
|
||||||
|
snapshot.temperature?.feelsLike ?? null,
|
||||||
|
snapshot.salinity ?? null,
|
||||||
|
snapshot.astronomy?.sunrise ?? null,
|
||||||
|
snapshot.astronomy?.sunset ?? null,
|
||||||
|
snapshot.astronomy?.moonrise ?? null,
|
||||||
|
snapshot.astronomy?.moonset ?? null,
|
||||||
|
snapshot.astronomy?.moonPhase ?? null,
|
||||||
|
snapshot.astronomy?.tidalRange ?? null,
|
||||||
|
snapshot.alert ?? null,
|
||||||
|
forecastDisplay ? JSON.stringify(forecastDisplay) : null,
|
||||||
|
tempStr,
|
||||||
|
windStr,
|
||||||
|
waveStr,
|
||||||
|
'🌊',
|
||||||
|
highTideStr,
|
||||||
|
snapshot.alert ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (rows[0] as Record<string, unknown>).weather_sn as number;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 미디어 정보 조회
|
// 미디어 정보 조회
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import multer from 'multer';
|
|||||||
import {
|
import {
|
||||||
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
|
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
|
||||||
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
||||||
|
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
|
||||||
|
getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn,
|
||||||
} from './predictionService.js';
|
} from './predictionService.js';
|
||||||
import { analyzeImageFile } from './imageAnalyzeService.js';
|
import { analyzeImageFile } from './imageAnalyzeService.js';
|
||||||
import { isValidNumber } from '../middleware/security.js';
|
import { isValidNumber } from '../middleware/security.js';
|
||||||
@ -64,6 +66,70 @@ router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('pred
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/prediction/analyses/:acdntSn/sensitive-resources — 예측 영역 내 민감자원 집계
|
||||||
|
router.get('/analyses/:acdntSn/sensitive-resources', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await getSensitiveResourcesByAcdntSn(acdntSn);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[prediction] 민감자원 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '민감자원 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/prediction/analyses/:acdntSn/sensitive-resources/geojson — 예측 영역 내 민감자원 GeoJSON
|
||||||
|
router.get('/analyses/:acdntSn/sensitive-resources/geojson', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await getSensitiveResourcesGeoJsonByAcdntSn(acdntSn);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[prediction] 민감자원 GeoJSON 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '민감자원 GeoJSON 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/prediction/analyses/:acdntSn/spread-particles — 예측 확산 파티클 GeoJSON
|
||||||
|
router.get('/analyses/:acdntSn/spread-particles', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await getPredictionParticlesGeojsonByAcdntSn(acdntSn);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[prediction] 확산 파티클 GeoJSON 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '확산 파티클 GeoJSON 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/prediction/analyses/:acdntSn/sensitivity-evaluation — 통합민감도 평가 GeoJSON
|
||||||
|
router.get('/analyses/:acdntSn/sensitivity-evaluation', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
||||||
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await getSensitivityEvaluationGeojsonByAcdntSn(acdntSn);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[prediction] 통합민감도 평가 GeoJSON 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '통합민감도 평가 GeoJSON 조회 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/prediction/backtrack — 사고별 역추적 목록
|
// GET /api/prediction/backtrack — 사고별 역추적 목록
|
||||||
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -432,6 +432,8 @@ interface TrajectoryTimeStep {
|
|||||||
particles: TrajectoryParticle[];
|
particles: TrajectoryParticle[];
|
||||||
remaining_volume_m3: number;
|
remaining_volume_m3: number;
|
||||||
weathered_volume_m3: number;
|
weathered_volume_m3: number;
|
||||||
|
evaporation_volume_m3?: number;
|
||||||
|
dispersion_volume_m3?: number;
|
||||||
pollution_area_km2: number;
|
pollution_area_km2: number;
|
||||||
beached_volume_m3: number;
|
beached_volume_m3: number;
|
||||||
pollution_coast_length_m: number;
|
pollution_coast_length_m: number;
|
||||||
@ -453,6 +455,8 @@ interface SingleModelTrajectoryResult {
|
|||||||
summary: {
|
summary: {
|
||||||
remainingVolume: number;
|
remainingVolume: number;
|
||||||
weatheredVolume: number;
|
weatheredVolume: number;
|
||||||
|
evaporationVolume: number;
|
||||||
|
dispersionVolume: number;
|
||||||
pollutionArea: number;
|
pollutionArea: number;
|
||||||
beachedVolume: number;
|
beachedVolume: number;
|
||||||
pollutionCoastLength: number;
|
pollutionCoastLength: number;
|
||||||
@ -460,6 +464,8 @@ interface SingleModelTrajectoryResult {
|
|||||||
stepSummaries: Array<{
|
stepSummaries: Array<{
|
||||||
remainingVolume: number;
|
remainingVolume: number;
|
||||||
weatheredVolume: number;
|
weatheredVolume: number;
|
||||||
|
evaporationVolume: number;
|
||||||
|
dispersionVolume: number;
|
||||||
pollutionArea: number;
|
pollutionArea: number;
|
||||||
beachedVolume: number;
|
beachedVolume: number;
|
||||||
pollutionCoastLength: number;
|
pollutionCoastLength: number;
|
||||||
@ -474,6 +480,8 @@ interface TrajectoryResult {
|
|||||||
summary: {
|
summary: {
|
||||||
remainingVolume: number;
|
remainingVolume: number;
|
||||||
weatheredVolume: number;
|
weatheredVolume: number;
|
||||||
|
evaporationVolume: number;
|
||||||
|
dispersionVolume: number;
|
||||||
pollutionArea: number;
|
pollutionArea: number;
|
||||||
beachedVolume: number;
|
beachedVolume: number;
|
||||||
pollutionCoastLength: number;
|
pollutionCoastLength: number;
|
||||||
@ -500,6 +508,8 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin
|
|||||||
const summary = {
|
const summary = {
|
||||||
remainingVolume: lastStep.remaining_volume_m3,
|
remainingVolume: lastStep.remaining_volume_m3,
|
||||||
weatheredVolume: lastStep.weathered_volume_m3,
|
weatheredVolume: lastStep.weathered_volume_m3,
|
||||||
|
evaporationVolume: lastStep.evaporation_volume_m3 ?? lastStep.weathered_volume_m3 * 0.65,
|
||||||
|
dispersionVolume: lastStep.dispersion_volume_m3 ?? lastStep.weathered_volume_m3 * 0.35,
|
||||||
pollutionArea: lastStep.pollution_area_km2,
|
pollutionArea: lastStep.pollution_area_km2,
|
||||||
beachedVolume: lastStep.beached_volume_m3,
|
beachedVolume: lastStep.beached_volume_m3,
|
||||||
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
||||||
@ -514,6 +524,8 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin
|
|||||||
const stepSummaries = rawResult.map((step) => ({
|
const stepSummaries = rawResult.map((step) => ({
|
||||||
remainingVolume: step.remaining_volume_m3,
|
remainingVolume: step.remaining_volume_m3,
|
||||||
weatheredVolume: step.weathered_volume_m3,
|
weatheredVolume: step.weathered_volume_m3,
|
||||||
|
evaporationVolume: step.evaporation_volume_m3 ?? step.weathered_volume_m3 * 0.65,
|
||||||
|
dispersionVolume: step.dispersion_volume_m3 ?? step.weathered_volume_m3 * 0.35,
|
||||||
pollutionArea: step.pollution_area_km2,
|
pollutionArea: step.pollution_area_km2,
|
||||||
beachedVolume: step.beached_volume_m3,
|
beachedVolume: step.beached_volume_m3,
|
||||||
pollutionCoastLength: step.pollution_coast_length_m,
|
pollutionCoastLength: step.pollution_coast_length_m,
|
||||||
@ -585,6 +597,160 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise<Trajectory
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSensitiveResourcesByAcdntSn(
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<{ category: string; count: number; totalArea: number | null }[]> {
|
||||||
|
const sql = `
|
||||||
|
WITH all_wkts AS (
|
||||||
|
SELECT step_data ->> 'wkt' AS wkt
|
||||||
|
FROM wing.PRED_EXEC,
|
||||||
|
jsonb_array_elements(RSLT_DATA) AS step_data
|
||||||
|
WHERE ACDNT_SN = $1
|
||||||
|
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||||
|
AND EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND RSLT_DATA IS NOT NULL
|
||||||
|
),
|
||||||
|
union_geom AS (
|
||||||
|
SELECT ST_Union(ST_GeomFromText(wkt, 4326)) AS geom
|
||||||
|
FROM all_wkts
|
||||||
|
WHERE wkt IS NOT NULL AND wkt <> ''
|
||||||
|
)
|
||||||
|
SELECT sr.CATEGORY,
|
||||||
|
COUNT(*)::int AS count,
|
||||||
|
CASE
|
||||||
|
WHEN bool_and(sr.PROPERTIES ? 'area')
|
||||||
|
THEN SUM((sr.PROPERTIES->>'area')::float)
|
||||||
|
ELSE NULL
|
||||||
|
END AS total_area
|
||||||
|
FROM wing.SENSITIVE_RESOURCE sr, union_geom
|
||||||
|
WHERE union_geom.geom IS NOT NULL
|
||||||
|
AND ST_Intersects(sr.GEOM, union_geom.geom)
|
||||||
|
GROUP BY sr.CATEGORY
|
||||||
|
ORDER BY sr.CATEGORY
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||||
|
return rows.map((r: Record<string, unknown>) => ({
|
||||||
|
category: String(r['category'] ?? ''),
|
||||||
|
count: Number(r['count'] ?? 0),
|
||||||
|
totalArea: r['total_area'] != null ? Number(r['total_area']) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSensitiveResourcesGeoJsonByAcdntSn(
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<{ type: 'FeatureCollection'; features: unknown[] }> {
|
||||||
|
const sql = `
|
||||||
|
WITH all_wkts AS (
|
||||||
|
SELECT step_data ->> 'wkt' AS wkt
|
||||||
|
FROM wing.PRED_EXEC,
|
||||||
|
jsonb_array_elements(RSLT_DATA) AS step_data
|
||||||
|
WHERE ACDNT_SN = $1
|
||||||
|
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||||
|
AND EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND RSLT_DATA IS NOT NULL
|
||||||
|
),
|
||||||
|
union_geom AS (
|
||||||
|
SELECT ST_Union(ST_GeomFromText(wkt, 4326)) AS geom
|
||||||
|
FROM all_wkts
|
||||||
|
WHERE wkt IS NOT NULL AND wkt <> ''
|
||||||
|
)
|
||||||
|
SELECT sr.SR_ID, sr.CATEGORY, sr.PROPERTIES,
|
||||||
|
ST_AsGeoJSON(sr.GEOM)::jsonb AS geom_json
|
||||||
|
FROM wing.SENSITIVE_RESOURCE sr, union_geom
|
||||||
|
WHERE union_geom.geom IS NOT NULL
|
||||||
|
AND ST_Intersects(sr.GEOM, union_geom.geom)
|
||||||
|
ORDER BY sr.CATEGORY, sr.SR_ID
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||||
|
const features = rows.map((r: Record<string, unknown>) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: r['geom_json'],
|
||||||
|
properties: {
|
||||||
|
srId: Number(r['sr_id']),
|
||||||
|
category: String(r['category'] ?? ''),
|
||||||
|
...(r['properties'] as Record<string, unknown> ?? {}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return { type: 'FeatureCollection', features };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSensitivityEvaluationGeojsonByAcdntSn(
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<{ type: 'FeatureCollection'; features: unknown[] }> {
|
||||||
|
const acdntSql = `SELECT LAT, LNG FROM wing.ACDNT WHERE ACDNT_SN = $1 AND USE_YN = 'Y'`;
|
||||||
|
const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]);
|
||||||
|
if (acdntRows.length === 0 || acdntRows[0]['lat'] == null) return { type: 'FeatureCollection', features: [] };
|
||||||
|
const lat = Number(acdntRows[0]['lat']);
|
||||||
|
const lng = Number(acdntRows[0]['lng']);
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT SR_ID, PROPERTIES,
|
||||||
|
ST_AsGeoJSON(GEOM)::jsonb AS geom_json,
|
||||||
|
ST_Area(GEOM::geography) / 1000000.0 AS area_km2
|
||||||
|
FROM wing.SENSITIVE_EVALUATION
|
||||||
|
WHERE ST_DWithin(
|
||||||
|
GEOM::geography,
|
||||||
|
ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography,
|
||||||
|
10000
|
||||||
|
)
|
||||||
|
ORDER BY SR_ID
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql, [lat, lng]);
|
||||||
|
const features = rows.map((r: Record<string, unknown>) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: r['geom_json'],
|
||||||
|
properties: {
|
||||||
|
srId: Number(r['sr_id']),
|
||||||
|
area_km2: Number(r['area_km2']),
|
||||||
|
...(r['properties'] as Record<string, unknown> ?? {}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return { type: 'FeatureCollection', features };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPredictionParticlesGeojsonByAcdntSn(
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<{ type: 'FeatureCollection'; features: unknown[]; maxStep: number }> {
|
||||||
|
const sql = `
|
||||||
|
SELECT ALGO_CD, RSLT_DATA
|
||||||
|
FROM wing.PRED_EXEC
|
||||||
|
WHERE ACDNT_SN = $1
|
||||||
|
AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
|
||||||
|
AND EXEC_STTS_CD = 'COMPLETED'
|
||||||
|
AND RSLT_DATA IS NOT NULL
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||||
|
if (rows.length === 0) return { type: 'FeatureCollection', features: [], maxStep: 0 };
|
||||||
|
|
||||||
|
const ALGO_TO_MODEL: Record<string, string> = { OPENDRIFT: 'OpenDrift', POSEIDON: 'POSEIDON' };
|
||||||
|
const features: unknown[] = [];
|
||||||
|
let globalMaxStep = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const model = ALGO_TO_MODEL[String(row['algo_cd'])] ?? String(row['algo_cd']);
|
||||||
|
const steps = row['rslt_data'] as TrajectoryTimeStep[];
|
||||||
|
const maxStep = steps.length - 1;
|
||||||
|
if (maxStep > globalMaxStep) globalMaxStep = maxStep;
|
||||||
|
|
||||||
|
steps.forEach((step, stepIdx) => {
|
||||||
|
step.particles.forEach(p => {
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
|
||||||
|
properties: {
|
||||||
|
model,
|
||||||
|
time: stepIdx,
|
||||||
|
stranded: p.stranded ?? 0,
|
||||||
|
isLastStep: stepIdx === maxStep,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'FeatureCollection', features, maxStep: globalMaxStep };
|
||||||
|
}
|
||||||
|
|
||||||
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD,
|
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD,
|
||||||
|
|||||||
@ -92,7 +92,7 @@ router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
|
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, mapCaptureImg } = req.body;
|
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, step3MapImg, step6MapImg } = req.body;
|
||||||
const result = await createReport({
|
const result = await createReport({
|
||||||
tmplSn,
|
tmplSn,
|
||||||
ctgrSn,
|
ctgrSn,
|
||||||
@ -101,7 +101,8 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req
|
|||||||
jrsdCd,
|
jrsdCd,
|
||||||
sttsCd,
|
sttsCd,
|
||||||
authorId: req.user!.sub,
|
authorId: req.user!.sub,
|
||||||
mapCaptureImg,
|
step3MapImg,
|
||||||
|
step6MapImg,
|
||||||
sections,
|
sections,
|
||||||
});
|
});
|
||||||
res.status(201).json(result);
|
res.status(201).json(result);
|
||||||
@ -125,8 +126,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'),
|
|||||||
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
|
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg } = req.body;
|
const { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg } = req.body;
|
||||||
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg }, req.user!.sub);
|
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg }, req.user!.sub);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AuthError) {
|
if (err instanceof AuthError) {
|
||||||
|
|||||||
@ -60,6 +60,7 @@ interface ReportListItem {
|
|||||||
sttsCd: string;
|
sttsCd: string;
|
||||||
authorId: string;
|
authorId: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
|
acdntSn: number | null;
|
||||||
regDtm: string;
|
regDtm: string;
|
||||||
mdfcnDtm: string | null;
|
mdfcnDtm: string | null;
|
||||||
hasMapCapture: boolean;
|
hasMapCapture: boolean;
|
||||||
@ -75,7 +76,8 @@ interface SectionData {
|
|||||||
interface ReportDetail extends ReportListItem {
|
interface ReportDetail extends ReportListItem {
|
||||||
acdntSn: number | null;
|
acdntSn: number | null;
|
||||||
sections: SectionData[];
|
sections: SectionData[];
|
||||||
mapCaptureImg: string | null;
|
step3MapImg: string | null;
|
||||||
|
step6MapImg: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListReportsInput {
|
interface ListReportsInput {
|
||||||
@ -102,7 +104,8 @@ interface CreateReportInput {
|
|||||||
jrsdCd?: string;
|
jrsdCd?: string;
|
||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
authorId: string;
|
authorId: string;
|
||||||
mapCaptureImg?: string;
|
step3MapImg?: string;
|
||||||
|
step6MapImg?: string;
|
||||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,7 +114,8 @@ interface UpdateReportInput {
|
|||||||
jrsdCd?: string;
|
jrsdCd?: string;
|
||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
acdntSn?: number | null;
|
acdntSn?: number | null;
|
||||||
mapCaptureImg?: string | null;
|
step3MapImg?: string | null;
|
||||||
|
step6MapImg?: string | null;
|
||||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -260,8 +264,10 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
|
|||||||
c.CTGR_CD, c.CTGR_NM,
|
c.CTGR_CD, c.CTGR_NM,
|
||||||
r.TITLE, r.JRSD_CD, r.STTS_CD,
|
r.TITLE, r.JRSD_CD, r.STTS_CD,
|
||||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||||
r.REG_DTM, r.MDFCN_DTM,
|
r.ACDNT_SN, r.REG_DTM, r.MDFCN_DTM,
|
||||||
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE
|
CASE WHEN (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
|
||||||
|
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
|
||||||
|
THEN true ELSE false END AS HAS_MAP_CAPTURE
|
||||||
FROM REPORT r
|
FROM REPORT r
|
||||||
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
|
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
|
||||||
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
|
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
|
||||||
@ -284,6 +290,7 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
|
|||||||
sttsCd: r.stts_cd,
|
sttsCd: r.stts_cd,
|
||||||
authorId: r.author_id,
|
authorId: r.author_id,
|
||||||
authorName: r.author_name || '',
|
authorName: r.author_name || '',
|
||||||
|
acdntSn: r.acdnt_sn,
|
||||||
regDtm: r.reg_dtm,
|
regDtm: r.reg_dtm,
|
||||||
mdfcnDtm: r.mdfcn_dtm,
|
mdfcnDtm: r.mdfcn_dtm,
|
||||||
hasMapCapture: r.has_map_capture,
|
hasMapCapture: r.has_map_capture,
|
||||||
@ -300,8 +307,10 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
|
|||||||
c.CTGR_CD, c.CTGR_NM,
|
c.CTGR_CD, c.CTGR_NM,
|
||||||
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
|
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
|
||||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||||
r.REG_DTM, r.MDFCN_DTM, r.MAP_CAPTURE_IMG,
|
r.REG_DTM, r.MDFCN_DTM, r.STEP3_MAP_IMG, r.STEP6_MAP_IMG,
|
||||||
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE
|
CASE WHEN (r.STEP3_MAP_IMG IS NOT NULL AND r.STEP3_MAP_IMG <> '')
|
||||||
|
OR (r.STEP6_MAP_IMG IS NOT NULL AND r.STEP6_MAP_IMG <> '')
|
||||||
|
THEN true ELSE false END AS HAS_MAP_CAPTURE
|
||||||
FROM REPORT r
|
FROM REPORT r
|
||||||
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
|
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
|
||||||
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
|
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
|
||||||
@ -338,7 +347,8 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
|
|||||||
authorName: r.author_name || '',
|
authorName: r.author_name || '',
|
||||||
regDtm: r.reg_dtm,
|
regDtm: r.reg_dtm,
|
||||||
mdfcnDtm: r.mdfcn_dtm,
|
mdfcnDtm: r.mdfcn_dtm,
|
||||||
mapCaptureImg: r.map_capture_img,
|
step3MapImg: r.step3_map_img,
|
||||||
|
step6MapImg: r.step6_map_img,
|
||||||
hasMapCapture: r.has_map_capture,
|
hasMapCapture: r.has_map_capture,
|
||||||
sections: sectRes.rows.map((s) => ({
|
sections: sectRes.rows.map((s) => ({
|
||||||
sectCd: s.sect_cd,
|
sectCd: s.sect_cd,
|
||||||
@ -359,8 +369,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
|
|||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
const res = await client.query(
|
const res = await client.query(
|
||||||
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, MAP_CAPTURE_IMG)
|
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, STEP3_MAP_IMG, STEP6_MAP_IMG)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING REPORT_SN`,
|
RETURNING REPORT_SN`,
|
||||||
[
|
[
|
||||||
input.tmplSn || null,
|
input.tmplSn || null,
|
||||||
@ -370,7 +380,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
|
|||||||
input.jrsdCd || null,
|
input.jrsdCd || null,
|
||||||
input.sttsCd || 'DRAFT',
|
input.sttsCd || 'DRAFT',
|
||||||
input.authorId,
|
input.authorId,
|
||||||
input.mapCaptureImg || null,
|
input.step3MapImg || null,
|
||||||
|
input.step6MapImg || null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
const reportSn = res.rows[0].report_sn;
|
const reportSn = res.rows[0].report_sn;
|
||||||
@ -442,9 +453,13 @@ export async function updateReport(
|
|||||||
sets.push(`ACDNT_SN = $${idx++}`);
|
sets.push(`ACDNT_SN = $${idx++}`);
|
||||||
params.push(input.acdntSn);
|
params.push(input.acdntSn);
|
||||||
}
|
}
|
||||||
if (input.mapCaptureImg !== undefined) {
|
if (input.step3MapImg !== undefined) {
|
||||||
sets.push(`MAP_CAPTURE_IMG = $${idx++}`);
|
sets.push(`STEP3_MAP_IMG = $${idx++}`);
|
||||||
params.push(input.mapCaptureImg);
|
params.push(input.step3MapImg);
|
||||||
|
}
|
||||||
|
if (input.step6MapImg !== undefined) {
|
||||||
|
sets.push(`STEP6_MAP_IMG = $${idx++}`);
|
||||||
|
params.push(input.step6MapImg);
|
||||||
}
|
}
|
||||||
|
|
||||||
params.push(reportSn);
|
params.push(reportSn);
|
||||||
|
|||||||
@ -18,6 +18,7 @@ interface Layer {
|
|||||||
cmn_cd_nm: string
|
cmn_cd_nm: string
|
||||||
cmn_cd_level: number
|
cmn_cd_level: number
|
||||||
clnm: string | null
|
clnm: string | null
|
||||||
|
data_tbl_nm: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지)
|
// DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지)
|
||||||
@ -27,7 +28,21 @@ const LAYER_COLUMNS = `
|
|||||||
LAYER_FULL_NM AS cmn_cd_full_nm,
|
LAYER_FULL_NM AS cmn_cd_full_nm,
|
||||||
LAYER_NM AS cmn_cd_nm,
|
LAYER_NM AS cmn_cd_nm,
|
||||||
LAYER_LEVEL AS cmn_cd_level,
|
LAYER_LEVEL AS cmn_cd_level,
|
||||||
WMS_LAYER_NM AS clnm
|
WMS_LAYER_NM AS clnm,
|
||||||
|
DATA_TBL_NM AS data_tbl_nm
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
// 조상 중 하나라도 USE_YN='N'이면 제외하는 재귀 CTE
|
||||||
|
// 부모가 비활성화되면 자식도 공개 API에서 제외됨 (상속 방식)
|
||||||
|
const ACTIVE_TREE_CTE = `
|
||||||
|
WITH RECURSIVE active_tree AS (
|
||||||
|
SELECT LAYER_CD FROM LAYER
|
||||||
|
WHERE UP_LAYER_CD IS NULL AND USE_YN = 'Y' AND DEL_YN = 'N'
|
||||||
|
UNION ALL
|
||||||
|
SELECT l.LAYER_CD FROM LAYER l
|
||||||
|
JOIN active_tree a ON l.UP_LAYER_CD = a.LAYER_CD
|
||||||
|
WHERE l.USE_YN = 'Y' AND l.DEL_YN = 'N'
|
||||||
|
)
|
||||||
`.trim()
|
`.trim()
|
||||||
|
|
||||||
// 모든 라우트에 파라미터 살균 적용
|
// 모든 라우트에 파라미터 살균 적용
|
||||||
@ -37,7 +52,10 @@ router.use(sanitizeParams)
|
|||||||
router.get('/', async (_req, res) => {
|
router.get('/', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`
|
`${ACTIVE_TREE_CTE}
|
||||||
|
SELECT ${LAYER_COLUMNS} FROM LAYER
|
||||||
|
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree)
|
||||||
|
ORDER BY LAYER_CD`
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
res.json(enrichedLayers)
|
res.json(enrichedLayers)
|
||||||
@ -50,7 +68,10 @@ router.get('/', async (_req, res) => {
|
|||||||
router.get('/tree/all', async (_req, res) => {
|
router.get('/tree/all', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`
|
`${ACTIVE_TREE_CTE}
|
||||||
|
SELECT ${LAYER_COLUMNS} FROM LAYER
|
||||||
|
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree)
|
||||||
|
ORDER BY LAYER_CD`
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
|
|
||||||
@ -82,7 +103,10 @@ router.get('/tree/all', async (_req, res) => {
|
|||||||
router.get('/wms/all', async (_req, res) => {
|
router.get('/wms/all', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE WMS_LAYER_NM IS NOT NULL AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`
|
`${ACTIVE_TREE_CTE}
|
||||||
|
SELECT ${LAYER_COLUMNS} FROM LAYER
|
||||||
|
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) AND WMS_LAYER_NM IS NOT NULL
|
||||||
|
ORDER BY LAYER_CD`
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
res.json(enrichedLayers)
|
res.json(enrichedLayers)
|
||||||
@ -104,7 +128,10 @@ router.get('/level/:level', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { rows } = await wingPool.query<Layer>(
|
const { rows } = await wingPool.query<Layer>(
|
||||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_LEVEL = $1 AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`,
|
`${ACTIVE_TREE_CTE}
|
||||||
|
SELECT ${LAYER_COLUMNS} FROM LAYER
|
||||||
|
WHERE LAYER_CD IN (SELECT LAYER_CD FROM active_tree) AND LAYER_LEVEL = $1
|
||||||
|
ORDER BY LAYER_CD`,
|
||||||
[level]
|
[level]
|
||||||
)
|
)
|
||||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||||
@ -191,6 +218,15 @@ router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) =>
|
|||||||
conditions.push(`USE_YN = $${params.length}`)
|
conditions.push(`USE_YN = $${params.length}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rootCd = sanitizeString(String(req.query.rootCd ?? '')).trim()
|
||||||
|
if (rootCd) {
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(rootCd) || !isValidStringLength(rootCd, 50)) {
|
||||||
|
return res.status(400).json({ error: '유효하지 않은 루트 레이어코드' })
|
||||||
|
}
|
||||||
|
params.push(`${rootCd}%`)
|
||||||
|
conditions.push(`LAYER_CD LIKE $${params.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||||
|
|
||||||
// 데이터 쿼리 파라미터: WHERE 조건 + LIMIT + OFFSET
|
// 데이터 쿼리 파라미터: WHERE 조건 + LIMIT + OFFSET
|
||||||
@ -201,19 +237,27 @@ router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) =>
|
|||||||
const [dataResult, countResult] = await Promise.all([
|
const [dataResult, countResult] = await Promise.all([
|
||||||
wingPool.query(
|
wingPool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
|
t.*,
|
||||||
|
p.USE_YN AS "parentUseYn"
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
LAYER_CD AS "layerCd",
|
LAYER_CD AS "layerCd",
|
||||||
UP_LAYER_CD AS "upLayerCd",
|
UP_LAYER_CD AS "upLayerCd",
|
||||||
LAYER_FULL_NM AS "layerFullNm",
|
LAYER_FULL_NM AS "layerFullNm",
|
||||||
LAYER_NM AS "layerNm",
|
LAYER_NM AS "layerNm",
|
||||||
LAYER_LEVEL AS "layerLevel",
|
LAYER_LEVEL AS "layerLevel",
|
||||||
WMS_LAYER_NM AS "wmsLayerNm",
|
WMS_LAYER_NM AS "wmsLayerNm",
|
||||||
|
DATA_TBL_NM AS "dataTblNm",
|
||||||
USE_YN AS "useYn",
|
USE_YN AS "useYn",
|
||||||
SORT_ORD AS "sortOrd",
|
SORT_ORD AS "sortOrd",
|
||||||
TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm"
|
TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm"
|
||||||
FROM LAYER
|
FROM LAYER
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY LAYER_CD
|
ORDER BY LAYER_CD
|
||||||
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
LIMIT $${limitIdx} OFFSET $${offsetIdx}
|
||||||
|
) t
|
||||||
|
LEFT JOIN LAYER p ON t."upLayerCd" = p.LAYER_CD AND p.DEL_YN = 'N'
|
||||||
|
ORDER BY t."layerCd"`,
|
||||||
dataParams
|
dataParams
|
||||||
),
|
),
|
||||||
wingPool.query(
|
wingPool.query(
|
||||||
@ -288,11 +332,12 @@ router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res)
|
|||||||
layerNm?: string
|
layerNm?: string
|
||||||
layerLevel?: number
|
layerLevel?: number
|
||||||
wmsLayerNm?: string
|
wmsLayerNm?: string
|
||||||
|
dataTblNm?: string
|
||||||
useYn?: string
|
useYn?: string
|
||||||
sortOrd?: number
|
sortOrd?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, useYn, sortOrd } = body
|
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body
|
||||||
|
|
||||||
// 필수 필드 검증
|
// 필수 필드 검증
|
||||||
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
||||||
@ -319,20 +364,26 @@ router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res)
|
|||||||
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' })
|
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') {
|
||||||
|
if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) {
|
||||||
|
return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sanitizedLayerCd = sanitizeString(layerCd)
|
const sanitizedLayerCd = sanitizeString(layerCd)
|
||||||
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
|
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
|
||||||
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
|
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
|
||||||
const sanitizedLayerNm = sanitizeString(layerNm)
|
const sanitizedLayerNm = sanitizeString(layerNm)
|
||||||
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
|
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
|
||||||
|
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
|
||||||
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
|
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
|
||||||
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
|
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
|
||||||
|
|
||||||
const { rows } = await wingPool.query(
|
const { rows } = await wingPool.query(
|
||||||
`INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, USE_YN, SORT_ORD, DEL_YN)
|
`INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, DATA_TBL_NM, USE_YN, SORT_ORD, DEL_YN)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'N')
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'N')
|
||||||
RETURNING LAYER_CD AS "layerCd"`,
|
RETURNING LAYER_CD AS "layerCd"`,
|
||||||
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedUseYn, sanitizedSortOrd]
|
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd]
|
||||||
)
|
)
|
||||||
|
|
||||||
res.json(rows[0])
|
res.json(rows[0])
|
||||||
@ -355,11 +406,12 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res)
|
|||||||
layerNm?: string
|
layerNm?: string
|
||||||
layerLevel?: number
|
layerLevel?: number
|
||||||
wmsLayerNm?: string
|
wmsLayerNm?: string
|
||||||
|
dataTblNm?: string
|
||||||
useYn?: string
|
useYn?: string
|
||||||
sortOrd?: number
|
sortOrd?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, useYn, sortOrd } = body
|
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, dataTblNm, useYn, sortOrd } = body
|
||||||
|
|
||||||
// 필수 필드 검증
|
// 필수 필드 검증
|
||||||
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
||||||
@ -386,22 +438,28 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res)
|
|||||||
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' })
|
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (dataTblNm !== undefined && dataTblNm !== null && dataTblNm !== '') {
|
||||||
|
if (!isValidStringLength(dataTblNm, 100) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dataTblNm)) {
|
||||||
|
return res.status(400).json({ error: '데이터 테이블명은 100자 이내의 유효한 PostgreSQL 테이블명이어야 합니다.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sanitizedLayerCd = sanitizeString(layerCd)
|
const sanitizedLayerCd = sanitizeString(layerCd)
|
||||||
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
|
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
|
||||||
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
|
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
|
||||||
const sanitizedLayerNm = sanitizeString(layerNm)
|
const sanitizedLayerNm = sanitizeString(layerNm)
|
||||||
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
|
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
|
||||||
|
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
|
||||||
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
|
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
|
||||||
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
|
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
|
||||||
|
|
||||||
const { rows } = await wingPool.query(
|
const { rows } = await wingPool.query(
|
||||||
`UPDATE LAYER
|
`UPDATE LAYER
|
||||||
SET UP_LAYER_CD = $2, LAYER_FULL_NM = $3, LAYER_NM = $4, LAYER_LEVEL = $5,
|
SET UP_LAYER_CD = $2, LAYER_FULL_NM = $3, LAYER_NM = $4, LAYER_LEVEL = $5,
|
||||||
WMS_LAYER_NM = $6, USE_YN = $7, SORT_ORD = $8
|
WMS_LAYER_NM = $6, DATA_TBL_NM = $7, USE_YN = $8, SORT_ORD = $9
|
||||||
WHERE LAYER_CD = $1
|
WHERE LAYER_CD = $1
|
||||||
RETURNING LAYER_CD AS "layerCd"`,
|
RETURNING LAYER_CD AS "layerCd"`,
|
||||||
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedUseYn, sanitizedSortOrd]
|
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedDataTblNm, sanitizedUseYn, sanitizedSortOrd]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@ -428,6 +486,18 @@ router.post('/admin/delete', requireAuth, requireRole('ADMIN'), async (req, res)
|
|||||||
|
|
||||||
const sanitizedCd = sanitizeString(layerCd)
|
const sanitizedCd = sanitizeString(layerCd)
|
||||||
|
|
||||||
|
// 하위 레이어 존재 여부 확인 (자식이 있으면 삭제 차단)
|
||||||
|
const { rows: childRows } = await wingPool.query(
|
||||||
|
`SELECT COUNT(*)::int AS cnt FROM LAYER WHERE UP_LAYER_CD = $1 AND DEL_YN = 'N'`,
|
||||||
|
[sanitizedCd]
|
||||||
|
)
|
||||||
|
const childCount: number = childRows[0].cnt
|
||||||
|
if (childCount > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `하위 레이어 ${childCount}개가 있어 삭제할 수 없습니다. 하위 레이어를 먼저 삭제해주세요.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const { rows } = await wingPool.query(
|
const { rows } = await wingPool.query(
|
||||||
`UPDATE LAYER SET DEL_YN = 'Y' WHERE LAYER_CD = $1 AND DEL_YN = 'N'
|
`UPDATE LAYER SET DEL_YN = 'Y' WHERE LAYER_CD = $1 AND DEL_YN = 'N'
|
||||||
RETURNING LAYER_CD AS "layerCd"`,
|
RETURNING LAYER_CD AS "layerCd"`,
|
||||||
|
|||||||
@ -585,6 +585,7 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
|
|||||||
status: 'DONE' | 'ERROR'
|
status: 'DONE' | 'ERROR'
|
||||||
trajectory?: ReturnType<typeof transformResult>['trajectory']
|
trajectory?: ReturnType<typeof transformResult>['trajectory']
|
||||||
summary?: ReturnType<typeof transformResult>['summary']
|
summary?: ReturnType<typeof transformResult>['summary']
|
||||||
|
stepSummaries?: ReturnType<typeof transformResult>['stepSummaries']
|
||||||
centerPoints?: ReturnType<typeof transformResult>['centerPoints']
|
centerPoints?: ReturnType<typeof transformResult>['centerPoints']
|
||||||
windData?: ReturnType<typeof transformResult>['windData']
|
windData?: ReturnType<typeof transformResult>['windData']
|
||||||
hydrData?: ReturnType<typeof transformResult>['hydrData']
|
hydrData?: ReturnType<typeof transformResult>['hydrData']
|
||||||
@ -656,9 +657,9 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
|
|||||||
WHERE PRED_EXEC_SN=$2`,
|
WHERE PRED_EXEC_SN=$2`,
|
||||||
[JSON.stringify(pythonData.result), predExecSn]
|
[JSON.stringify(pythonData.result), predExecSn]
|
||||||
)
|
)
|
||||||
const { trajectory, summary, centerPoints, windData, hydrData } =
|
const { trajectory, summary, stepSummaries, centerPoints, windData, hydrData } =
|
||||||
transformResult(pythonData.result, model)
|
transformResult(pythonData.result, model)
|
||||||
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, centerPoints, windData, hydrData }
|
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비동기 응답 (하위 호환)
|
// 비동기 응답 (하위 호환)
|
||||||
@ -691,8 +692,8 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
|
|||||||
// 결과 동기 대기
|
// 결과 동기 대기
|
||||||
try {
|
try {
|
||||||
const rawResult = await runModelSync(jobId!, predExecSn, apiUrl)
|
const rawResult = await runModelSync(jobId!, predExecSn, apiUrl)
|
||||||
const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(rawResult, model)
|
const { trajectory, summary, stepSummaries, centerPoints, windData, hydrData } = transformResult(rawResult, model)
|
||||||
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, centerPoints, windData, hydrData }
|
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
|
||||||
} catch (syncErr) {
|
} catch (syncErr) {
|
||||||
return { model, execSn: predExecSn, status: 'ERROR', error: (syncErr as Error).message }
|
return { model, execSn: predExecSn, status: 'ERROR', error: (syncErr as Error).message }
|
||||||
}
|
}
|
||||||
@ -848,6 +849,8 @@ interface PythonTimeStep {
|
|||||||
particles: PythonParticle[]
|
particles: PythonParticle[]
|
||||||
remaining_volume_m3: number
|
remaining_volume_m3: number
|
||||||
weathered_volume_m3: number
|
weathered_volume_m3: number
|
||||||
|
evaporation_m3?: number
|
||||||
|
dispersion_m3?: number
|
||||||
pollution_area_km2: number
|
pollution_area_km2: number
|
||||||
beached_volume_m3: number
|
beached_volume_m3: number
|
||||||
pollution_coast_length_m: number
|
pollution_coast_length_m: number
|
||||||
@ -884,6 +887,8 @@ function transformResult(rawResult: PythonTimeStep[], model: string) {
|
|||||||
const summary = {
|
const summary = {
|
||||||
remainingVolume: lastStep.remaining_volume_m3,
|
remainingVolume: lastStep.remaining_volume_m3,
|
||||||
weatheredVolume: lastStep.weathered_volume_m3,
|
weatheredVolume: lastStep.weathered_volume_m3,
|
||||||
|
evaporationVolume: lastStep.evaporation_m3 ?? lastStep.weathered_volume_m3 * 0.65,
|
||||||
|
dispersionVolume: lastStep.dispersion_m3 ?? lastStep.weathered_volume_m3 * 0.35,
|
||||||
pollutionArea: lastStep.pollution_area_km2,
|
pollutionArea: lastStep.pollution_area_km2,
|
||||||
beachedVolume: lastStep.beached_volume_m3,
|
beachedVolume: lastStep.beached_volume_m3,
|
||||||
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
||||||
@ -901,7 +906,16 @@ function transformResult(rawResult: PythonTimeStep[], model: string) {
|
|||||||
? { value: step.hydr_data, grid: step.hydr_grid }
|
? { value: step.hydr_data, grid: step.hydr_grid }
|
||||||
: null
|
: null
|
||||||
)
|
)
|
||||||
return { trajectory, summary, centerPoints, windData, hydrData }
|
const stepSummaries = rawResult.map((step) => ({
|
||||||
|
remainingVolume: step.remaining_volume_m3,
|
||||||
|
weatheredVolume: step.weathered_volume_m3,
|
||||||
|
evaporationVolume: step.evaporation_m3 ?? step.weathered_volume_m3 * 0.65,
|
||||||
|
dispersionVolume: step.dispersion_m3 ?? step.weathered_volume_m3 * 0.35,
|
||||||
|
pollutionArea: step.pollution_area_km2,
|
||||||
|
beachedVolume: step.beached_volume_m3,
|
||||||
|
pollutionCoastLength: step.pollution_coast_length_m,
|
||||||
|
}))
|
||||||
|
return { trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@ -77,7 +77,8 @@ CREATE TABLE IF NOT EXISTS REPORT (
|
|||||||
USE_YN CHAR(1) DEFAULT 'Y',
|
USE_YN CHAR(1) DEFAULT 'Y',
|
||||||
REG_DTM TIMESTAMPTZ DEFAULT NOW(),
|
REG_DTM TIMESTAMPTZ DEFAULT NOW(),
|
||||||
MDFCN_DTM TIMESTAMPTZ,
|
MDFCN_DTM TIMESTAMPTZ,
|
||||||
MAP_CAPTURE_IMG TEXT
|
STEP3_MAP_IMG TEXT,
|
||||||
|
STEP6_MAP_IMG TEXT,
|
||||||
CONSTRAINT CK_REPORT_STATUS CHECK (STTS_CD IN ('DRAFT','IN_PROGRESS','COMPLETED'))
|
CONSTRAINT CK_REPORT_STATUS CHECK (STTS_CD IN ('DRAFT','IN_PROGRESS','COMPLETED'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
57
database/migration/024_admin_perm_tree.sql
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
-- 관리자 권한 트리 확장: 게시판관리, 기준정보, 연계관리 섹션 추가
|
||||||
|
-- AdminView.tsx의 adminMenuConfig.ts에 정의된 전체 메뉴 구조를 AUTH_PERM_TREE에 반영
|
||||||
|
|
||||||
|
-- Level 1 섹션 노드 (3개)
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('admin:board-mgmt', 'admin', '게시판관리', 1, 5),
|
||||||
|
('admin:reference', 'admin', '기준정보', 1, 6),
|
||||||
|
('admin:external', 'admin', '연계관리', 1, 7)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 2 그룹/리프 노드
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('admin:notice', 'admin:board-mgmt', '공지사항', 2, 1),
|
||||||
|
('admin:board', 'admin:board-mgmt', '게시판', 2, 2),
|
||||||
|
('admin:qna', 'admin:board-mgmt', 'QNA', 2, 3),
|
||||||
|
('admin:map-mgmt', 'admin:reference', '지도관리', 2, 1),
|
||||||
|
('admin:sensitive-map', 'admin:reference', '민감자원지도', 2, 2),
|
||||||
|
('admin:coast-guard-assets', 'admin:reference', '해경자산', 2, 3),
|
||||||
|
('admin:collection', 'admin:external', '수집자료', 2, 1),
|
||||||
|
('admin:monitoring', 'admin:external', '연계모니터링', 2, 2)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- Level 3 리프 노드
|
||||||
|
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES
|
||||||
|
('admin:map-base', 'admin:map-mgmt', '지도백데이터', 3, 1),
|
||||||
|
('admin:map-layer', 'admin:map-mgmt', '레이어', 3, 2),
|
||||||
|
('admin:env-ecology', 'admin:sensitive-map', '환경/생태', 3, 1),
|
||||||
|
('admin:social-economy', 'admin:sensitive-map', '사회/경제', 3, 2),
|
||||||
|
('admin:cleanup-equip', 'admin:coast-guard-assets', '방제장비', 3, 1),
|
||||||
|
('admin:asset-upload', 'admin:coast-guard-assets', '자산현행화', 3, 2),
|
||||||
|
('admin:dispersant-zone', 'admin:coast-guard-assets', '유처리제 제한구역', 3, 3),
|
||||||
|
('admin:vessel-materials', 'admin:coast-guard-assets', '방제선 보유자재', 3, 4),
|
||||||
|
('admin:collect-vessel-signal', 'admin:collection', '선박신호', 3, 1),
|
||||||
|
('admin:collect-hr', 'admin:collection', '인사정보', 3, 2),
|
||||||
|
('admin:monitor-realtime', 'admin:monitoring', '실시간 관측자료', 3, 1),
|
||||||
|
('admin:monitor-forecast', 'admin:monitoring', '수치예측자료', 3, 2),
|
||||||
|
('admin:monitor-vessel', 'admin:monitoring', '선박위치정보', 3, 3),
|
||||||
|
('admin:monitor-hr', 'admin:monitoring', '인사', 3, 4)
|
||||||
|
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||||
|
|
||||||
|
-- AUTH_PERM: 신규 섹션/그룹 노드에 권한 복사
|
||||||
|
-- admin 권한이 있는 역할에 동일하게 부여 (permResolver의 parent READ gate 충족)
|
||||||
|
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN)
|
||||||
|
SELECT ap.ROLE_SN, nc.RSRC_CD, ap.OPER_CD, ap.GRANT_YN
|
||||||
|
FROM AUTH_PERM ap
|
||||||
|
CROSS JOIN (VALUES
|
||||||
|
('admin:board-mgmt'),
|
||||||
|
('admin:reference'),
|
||||||
|
('admin:external'),
|
||||||
|
('admin:map-mgmt'),
|
||||||
|
('admin:sensitive-map'),
|
||||||
|
('admin:coast-guard-assets'),
|
||||||
|
('admin:collection'),
|
||||||
|
('admin:monitoring')
|
||||||
|
) AS nc(RSRC_CD)
|
||||||
|
WHERE ap.RSRC_CD = 'admin'
|
||||||
|
ON CONFLICT (ROLE_SN, RSRC_CD, OPER_CD) DO NOTHING;
|
||||||
44
database/migration/025_weather_columns.sql
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
-- 027: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 추가
|
||||||
|
-- 확산예측 실행 시 WeatherRightPanel에 표시되는 모든 기상정보 저장을 위해
|
||||||
|
-- 기존 VARCHAR 컬럼(WIND, WAVE, TEMP, SST)은 하위 호환성 유지를 위해 보존
|
||||||
|
|
||||||
|
ALTER TABLE wing.ACDNT_WEATHER
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_SPEED NUMERIC(5,1), -- 풍속 (m/s)
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_DIR INTEGER, -- 풍향 (도)
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_DIR_LBL VARCHAR(10), -- 풍향 텍스트 (N, NW, ...)
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_SPEED_1K NUMERIC(5,1), -- 1k 최고 풍속 (m/s)
|
||||||
|
ADD COLUMN IF NOT EXISTS WIND_SPEED_3K NUMERIC(5,1), -- 3k 평균 풍속 (m/s)
|
||||||
|
ADD COLUMN IF NOT EXISTS PRESSURE NUMERIC(6,1), -- 기압 (hPa)
|
||||||
|
ADD COLUMN IF NOT EXISTS WAVE_HEIGHT NUMERIC(4,1), -- 유의파고 (m)
|
||||||
|
ADD COLUMN IF NOT EXISTS WAVE_MAX_HT NUMERIC(4,1), -- 최고파고 (m)
|
||||||
|
ADD COLUMN IF NOT EXISTS WAVE_PERIOD NUMERIC(4,1), -- 파도 주기 (s)
|
||||||
|
ADD COLUMN IF NOT EXISTS WAVE_DIR VARCHAR(10), -- 파향 (N, NE, ...)
|
||||||
|
ADD COLUMN IF NOT EXISTS AIR_TEMP NUMERIC(5,1), -- 기온 (°C)
|
||||||
|
ADD COLUMN IF NOT EXISTS SALINITY NUMERIC(5,1), -- 염분 (PSU)
|
||||||
|
ADD COLUMN IF NOT EXISTS SUNRISE VARCHAR(10), -- 일출 시각 (HH:MM)
|
||||||
|
ADD COLUMN IF NOT EXISTS SUNSET VARCHAR(10), -- 일몰 시각 (HH:MM)
|
||||||
|
ADD COLUMN IF NOT EXISTS MOONRISE VARCHAR(10), -- 월출 시각 (HH:MM)
|
||||||
|
ADD COLUMN IF NOT EXISTS MOONSET VARCHAR(10), -- 월몰 시각 (HH:MM)
|
||||||
|
ADD COLUMN IF NOT EXISTS MOON_PHASE VARCHAR(30), -- 월상 (예: 상현달 14일)
|
||||||
|
ADD COLUMN IF NOT EXISTS TIDAL_RANGE NUMERIC(4,1), -- 조차 (m)
|
||||||
|
ADD COLUMN IF NOT EXISTS WEATHER_ALERT TEXT; -- 날씨 특보
|
||||||
|
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED IS '풍속 (m/s)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_DIR IS '풍향 (도, 0-360)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_DIR_LBL IS '풍향 텍스트 (N/NE/E/...)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED_1K IS '1km 최고 풍속 (m/s)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WIND_SPEED_3K IS '3km 평균 풍속 (m/s)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.PRESSURE IS '기압 (hPa)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_HEIGHT IS '유의파고 (m)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_MAX_HT IS '최고파고 (m)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_PERIOD IS '파도 주기 (s)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WAVE_DIR IS '파향 (N/NE/E/...)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.AIR_TEMP IS '기온 (°C)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.SALINITY IS '염분 (PSU)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.SUNRISE IS '일출 시각 (HH:MM)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.SUNSET IS '일몰 시각 (HH:MM)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOONRISE IS '월출 시각 (HH:MM)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOONSET IS '월몰 시각 (HH:MM)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.MOON_PHASE IS '월상 (예: 상현달 14일)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.TIDAL_RANGE IS '조차 (m)';
|
||||||
|
COMMENT ON COLUMN wing.ACDNT_WEATHER.WEATHER_ALERT IS '날씨 특보 문자열';
|
||||||
41
database/migration/026_sensitive_resources.sql
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 027: 민감자원 테이블 생성
|
||||||
|
-- 모든 민감자원(양식장, 해수욕장, 무역항 등)을 단일 테이블로 관리
|
||||||
|
-- properties는 JSONB로 유연하게 저장
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET search_path TO wing, public;
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 민감자원 테이블
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS SENSITIVE_RESOURCE (
|
||||||
|
SR_ID BIGSERIAL PRIMARY KEY,
|
||||||
|
CATEGORY VARCHAR(50) NOT NULL, -- 민감자원 유형 (양식장, 해수욕장, 무역항 등)
|
||||||
|
GEOM public.geometry(Geometry, 4326) NOT NULL, -- 공간 데이터 (Point, LineString, Polygon 모두 수용)
|
||||||
|
PROPERTIES JSONB NOT NULL DEFAULT '{}', -- 원본 GeoJSON properties
|
||||||
|
REG_DT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
MOD_DT TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 공간 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SR_GEOM ON SENSITIVE_RESOURCE USING GIST(GEOM);
|
||||||
|
|
||||||
|
-- 카테고리 인덱스 (유형별 필터링)
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SR_CATEGORY ON SENSITIVE_RESOURCE (CATEGORY);
|
||||||
|
|
||||||
|
-- JSONB 인덱스 (properties 내부 검색용)
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SR_PROPERTIES ON SENSITIVE_RESOURCE USING GIN(PROPERTIES);
|
||||||
|
|
||||||
|
-- 카테고리 + 공간 복합 조회 최적화
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SR_CATEGORY_GEOM ON SENSITIVE_RESOURCE USING GIST(GEOM) WHERE CATEGORY IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE SENSITIVE_RESOURCE IS '민감자원 통합 테이블';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.SR_ID IS '민감자원 ID';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.CATEGORY IS '민감자원 유형 (양식장, 해수욕장, 무역항, 어항, 해안선_ESI 등)';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.GEOM IS '공간 데이터 (EPSG:4326)';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.PROPERTIES IS '원본 GeoJSON properties (JSONB)';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.REG_DT IS '등록일시';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_RESOURCE.MOD_DT IS '수정일시';
|
||||||
27
database/migration/027_sensitivity_evaluation.sql
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 027: 통합민감도 평가 테이블 생성
|
||||||
|
-- 계절별 민감도 평가 그리드 데이터 저장
|
||||||
|
-- properties 구조: { ID, FA_G, SM_G, SP_G, WT_G, MAX_G, GRID_LEVEL }
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET search_path TO wing, public;
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS SENSITIVE_EVALUATION (
|
||||||
|
SR_ID BIGSERIAL PRIMARY KEY,
|
||||||
|
CATEGORY VARCHAR(50) NOT NULL DEFAULT '민감도평가',
|
||||||
|
GEOM public.geometry(Geometry, 4326) NOT NULL,
|
||||||
|
PROPERTIES JSONB NOT NULL DEFAULT '{}',
|
||||||
|
REG_DT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
MOD_DT TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SE_GEOM ON SENSITIVE_EVALUATION USING GIST(GEOM);
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_SE_PROPERTIES ON SENSITIVE_EVALUATION USING GIN(PROPERTIES);
|
||||||
|
|
||||||
|
COMMENT ON TABLE SENSITIVE_EVALUATION IS '통합민감도 평가 그리드 테이블';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_EVALUATION.SR_ID IS '민감도 평가 ID';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_EVALUATION.CATEGORY IS '카테고리 (기본값: 민감도평가)';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_EVALUATION.GEOM IS '공간 데이터 (EPSG:4326)';
|
||||||
|
COMMENT ON COLUMN SENSITIVE_EVALUATION.PROPERTIES IS '계절별 민감도 값 { SP_G, SM_G, FA_G, WT_G, MAX_G, GRID_LEVEL }';
|
||||||
239
docs/DESIGN-SYSTEM.md
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
# WING-OPS 디자인 시스템
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
WING-OPS UI 디자인 시스템의 비주얼 레퍼런스 카탈로그.
|
||||||
|
Google Stitch MCP로 생성된 스크린을 기반으로 일관된 UI 구현을 유도한다.
|
||||||
|
|
||||||
|
## Stitch 프로젝트
|
||||||
|
|
||||||
|
- **프로젝트명**: WING-OPS Design System v1
|
||||||
|
- **프로젝트 ID**: `5453076280291618640`
|
||||||
|
|
||||||
|
## 스크린 목록
|
||||||
|
|
||||||
|
| # | 스크린 | Screen ID | 용도 |
|
||||||
|
|---|--------|-----------|------|
|
||||||
|
| 1 | Design Tokens | `ce520225d85c4c38b2024e93ec6a4fb2` | 색상, 타이포그래피, 간격, 라운딩 토큰 |
|
||||||
|
| 2 | Component Catalog (Buttons/Badges) | `42fa9cf1a3d341a7972a1bc10ba00a8c` | 버튼 variant, 뱃지, 아이콘 버튼 |
|
||||||
|
| 3 | Form Components | `7331ad8a598f4cc59f62a14226c1d023` | 입력, 선택, 날짜, 토글, 폼 레이아웃 |
|
||||||
|
| 4 | Table & List Patterns | `5967382c70f9422ba3a0f4da79922ecf` | 데이터 테이블, 사이드바 리스트, 페이지네이션 |
|
||||||
|
| 5 | Modal Catalog | `440be91f8db7423cbb5cc89e6dd6f9ca` | 모달 3사이즈, 확인 다이얼로그, 폼 모달 |
|
||||||
|
| 6 | Operational Shell (Layout) | `86fd57c9f3c749d288f6270838a9387d` | TopBar, SubMenu, 3컬럼 레이아웃 |
|
||||||
|
| 7 | Container & Navigation | `201c2c0c47b74fcfb3427d029319fa9d` | 카드, 섹션, 탭바, KV 행, 헤더바 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Foundations
|
||||||
|
|
||||||
|
### 색상 (Color Palette)
|
||||||
|
|
||||||
|
#### Primitive Colors
|
||||||
|
|
||||||
|
UI 전반에서 사용하는 기본 색조 팔레트. Navy는 배경 전용 5단계, 나머지는 00~100의 11단계 스케일.
|
||||||
|
|
||||||
|
**Navy** (배경 전용)
|
||||||
|
|
||||||
|
| Step | Hex |
|
||||||
|
|------|-----|
|
||||||
|
| 0 | `#0a0e1a` |
|
||||||
|
| 1 | `#0f1524` |
|
||||||
|
| 2 | `#121929` |
|
||||||
|
| 3 | `#1a2236` |
|
||||||
|
| hover | `#1e2844` |
|
||||||
|
|
||||||
|
**Cyan**
|
||||||
|
|
||||||
|
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|
||||||
|
|----|----|----|----|----|----|----|----|----|----|----|
|
||||||
|
| `#ecfeff` | `#cffafe` | `#a5f3fc` | `#67e8f9` | `#22d3ee` | `#06b6d4` | `#0891b2` | `#0e7490` | `#155e75` | `#164e63` | `#083344` |
|
||||||
|
|
||||||
|
**Blue**
|
||||||
|
|
||||||
|
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|
||||||
|
|----|----|----|----|----|----|----|----|----|----|----|
|
||||||
|
| `#eff6ff` | `#dbeafe` | `#bfdbfe` | `#93c5fd` | `#60a5fa` | `#3b82f6` | `#2563eb` | `#1d4ed8` | `#1e40af` | `#1e3a8a` | `#172554` |
|
||||||
|
|
||||||
|
**Red**
|
||||||
|
|
||||||
|
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|
||||||
|
|----|----|----|----|----|----|----|----|----|----|----|
|
||||||
|
| `#fef2f2` | `#fee2e2` | `#fecaca` | `#fca5a5` | `#f87171` | `#ef4444` | `#dc2626` | `#b91c1c` | `#991b1b` | `#7f1d1d` | `#450a0a` |
|
||||||
|
|
||||||
|
**Green**
|
||||||
|
|
||||||
|
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|
||||||
|
|----|----|----|----|----|----|----|----|----|----|----|
|
||||||
|
| `#f0fdf4` | `#dcfce7` | `#bbf7d0` | `#86efac` | `#4ade80` | `#22c55e` | `#16a34a` | `#15803d` | `#166534` | `#14532d` | `#052e16` |
|
||||||
|
|
||||||
|
**Orange**
|
||||||
|
|
||||||
|
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|
||||||
|
|----|----|----|----|----|----|----|----|----|----|----|
|
||||||
|
| `#fff7ed` | `#ffedd5` | `#fed7aa` | `#fdba74` | `#fb923c` | `#f97316` | `#ea580c` | `#c2410c` | `#9a3412` | `#7c2d12` | `#431407` |
|
||||||
|
|
||||||
|
**Yellow**
|
||||||
|
|
||||||
|
| 00 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 |
|
||||||
|
|----|----|----|----|----|----|----|----|----|----|----|
|
||||||
|
| `#fefce8` | `#fef9c3` | `#fef08a` | `#fde047` | `#facc15` | `#eab308` | `#ca8a04` | `#a16207` | `#854d0e` | `#713f12` | `#422006` |
|
||||||
|
|
||||||
|
#### Semantic Colors
|
||||||
|
|
||||||
|
컨텍스트에 따라 의미를 부여한 토큰. Dark/Light 두 테마 값 병기.
|
||||||
|
|
||||||
|
**Text**
|
||||||
|
|
||||||
|
| 토큰 | Dark | Light | 용도 |
|
||||||
|
|------|------|-------|------|
|
||||||
|
| `text-1` | `#edf0f7` | `#0f172a` | 기본 텍스트, 아이콘 기본 |
|
||||||
|
| `text-2` | `#b0b8cc` | `#475569` | 보조 텍스트 |
|
||||||
|
| `text-3` | `#8690a6` | `#94a3b8` | 비활성, 플레이스홀더 |
|
||||||
|
|
||||||
|
**Background**
|
||||||
|
|
||||||
|
| 토큰 | Dark | Light | 용도 |
|
||||||
|
|------|------|-------|------|
|
||||||
|
| `bg-0` | `#0a0e1a` | `#f8fafc` | 페이지 배경 |
|
||||||
|
| `bg-1` | `#0f1524` | `#ffffff` | 사이드바, 패널 |
|
||||||
|
| `bg-2` | `#121929` | `#f1f5f9` | 테이블 헤더 |
|
||||||
|
| `bg-3` | `#1a2236` | `#e2e8f0` | 카드 배경 |
|
||||||
|
| `bg-hover` | `#1e2844` | `#cbd5e1` | 호버 상태 |
|
||||||
|
|
||||||
|
**Border**
|
||||||
|
|
||||||
|
| 토큰 | Dark | Light | 용도 |
|
||||||
|
|------|------|-------|------|
|
||||||
|
| `border` | `#1e2a42` | `#cbd5e1` | 기본 구분선 |
|
||||||
|
| `border-light` | `#2a3a5c` | `#e2e8f0` | 연한 구분선 |
|
||||||
|
|
||||||
|
**Accent**
|
||||||
|
|
||||||
|
| 토큰 | Dark | Light | 용도 |
|
||||||
|
|------|------|-------|------|
|
||||||
|
| `primary-cyan` | `#06b6d4` | `#06b6d4` | 주요 강조, 활성 상태 |
|
||||||
|
| `primary-blue` | `#3b82f6` | `#0891b2` | 보조 강조 |
|
||||||
|
| `primary-purple` | `#a855f7` | `#6366f1` | 3차 강조 |
|
||||||
|
|
||||||
|
**Status**
|
||||||
|
|
||||||
|
| 토큰 | Dark | Light | 용도 |
|
||||||
|
|------|------|-------|------|
|
||||||
|
| `status-red` | `#ef4444` | `#dc2626` | 위험, 삭제 |
|
||||||
|
| `status-orange` | `#f97316` | `#c2410c` | 주의 |
|
||||||
|
| `status-yellow` | `#eab308` | `#b45309` | 경고 |
|
||||||
|
| `status-green` | `#22c55e` | `#047857` | 정상, 성공 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 타이포그래피 (Typography)
|
||||||
|
|
||||||
|
#### Font Family
|
||||||
|
|
||||||
|
| 이름 | className | Font Stack | 용도 |
|
||||||
|
|------|-----------|------------|------|
|
||||||
|
| Noto Sans KR | `font-korean` | `'Noto Sans KR', sans-serif` | 기본 UI 텍스트, 한국어 콘텐츠 전반 |
|
||||||
|
| JetBrains Mono | `font-mono` | `'JetBrains Mono', monospace` | 좌표, 수치, 코드, 토큰 이름 |
|
||||||
|
| Outfit | `font-sans` | `'Outfit', 'Noto Sans KR', sans-serif` | 영문 헤딩, 브랜드 타이틀 |
|
||||||
|
|
||||||
|
> Body 기본 스택: `font-family: 'Outfit', 'Noto Sans KR', sans-serif`
|
||||||
|
|
||||||
|
#### Typography Tokens (`.wing-*` 클래스)
|
||||||
|
|
||||||
|
| 클래스 | Size | Font | Weight | 용도 | 샘플 |
|
||||||
|
|--------|------|------|--------|------|------|
|
||||||
|
| `.wing-title` | 15px | font-korean | Bold (700) | 패널 제목 | 확산 예측 시뮬레이션 |
|
||||||
|
| `.wing-section-header` | 13px | font-korean | Bold (700) | 섹션 헤더 | 기본 정보 입력 |
|
||||||
|
| `.wing-label` | 11px | font-korean | Semibold (600) | 필드 레이블 | 유출량 (kL) |
|
||||||
|
| `.wing-btn` | 11px | font-korean | Semibold (600) | 버튼 텍스트 | 시뮬레이션 실행 |
|
||||||
|
| `.wing-value` | 11px | font-mono | Semibold (600) | 수치 / 데이터 값 | 35.1284° N, 129.0598° E |
|
||||||
|
| `.wing-input` | 11px | font-korean | Normal (400) | 입력 필드 | 서해 대산항 인근 해역 |
|
||||||
|
| `.wing-section-desc` | 10px | font-korean | Normal (400) | 섹션 설명 | 예측 결과는 기상 조건에 따라... |
|
||||||
|
| `.wing-subtitle` | 10px | font-korean | Normal (400) | 보조 설명 | 최근 업데이트: 2026-03-24 09:00 KST |
|
||||||
|
| `.wing-meta` | 9px | font-korean | Normal (400) | 메타 정보 | v2.1 \| 해양환경공단 |
|
||||||
|
| `.wing-badge` | 9px | font-korean | Bold (700) | 뱃지 / 태그 | 진행중 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Border Radius
|
||||||
|
|
||||||
|
#### Radius Tokens
|
||||||
|
|
||||||
|
| Tailwind 클래스 | 값 | 비고 |
|
||||||
|
|-----------------|-----|------|
|
||||||
|
| `rounded-sm` | 6px | **Custom** (Tailwind 기본값 오버라이드) |
|
||||||
|
| `rounded` | 4px (0.25rem) | Tailwind 기본 |
|
||||||
|
| `rounded-md` | 10px | **Custom** (Tailwind 기본값 오버라이드) |
|
||||||
|
| `rounded-lg` | 8px (0.5rem) | Tailwind 기본 |
|
||||||
|
| `rounded-xl` | 12px (0.75rem) | Tailwind 기본 |
|
||||||
|
| `rounded-2xl` | 16px (1rem) | Tailwind 기본 |
|
||||||
|
| `rounded-full` | 9999px | Tailwind 기본 |
|
||||||
|
|
||||||
|
#### 컴포넌트 매핑
|
||||||
|
|
||||||
|
| Radius | 값 | 적용 컴포넌트 |
|
||||||
|
|--------|-----|-------------|
|
||||||
|
| `rounded-sm` | 6px | `.wing-btn`, `.wing-input`, `.wing-card-sm` |
|
||||||
|
| `rounded` | 4px | `.wing-badge` |
|
||||||
|
| `rounded-md` | 10px | `.wing-card`, `.wing-section`, `.wing-tab` |
|
||||||
|
| `rounded-lg` | 8px | `.wing-tab-bar` |
|
||||||
|
| `rounded-xl` | 12px | `.wing-modal` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 레이아웃 (Layout)
|
||||||
|
|
||||||
|
#### Breakpoints
|
||||||
|
|
||||||
|
| Name | Prefix | Min Width | 사용 | 비고 |
|
||||||
|
|------|--------|-----------|------|------|
|
||||||
|
| sm | `sm:` | 640px | - | |
|
||||||
|
| md | `md:` | 768px | - | |
|
||||||
|
| lg | `lg:` | 1024px | - | |
|
||||||
|
| xl | `xl:` | 1280px | **사용 중** | TopBar 탭 레이블/아이콘 토글 |
|
||||||
|
| 2xl | `2xl:` | 1536px | - | |
|
||||||
|
|
||||||
|
> Desktop(≥ 1280px)만 지원. Tablet/Mobile 미지원.
|
||||||
|
|
||||||
|
| Device | Width | Columns | Gutter | Margin |
|
||||||
|
|--------|-------|---------|--------|--------|
|
||||||
|
| Desktop | ≥ 1280px | flex 기반 가변 | gap-2 ~ gap-6 | px-5 ~ px-8 |
|
||||||
|
| Tablet | 768px ~ 1279px | - | - | - |
|
||||||
|
| Mobile | < 768px | - | - | - |
|
||||||
|
|
||||||
|
#### Spacing Scale
|
||||||
|
|
||||||
|
| Scale | rem | px | 용도 |
|
||||||
|
|-------|-----|----|------|
|
||||||
|
| 0.5 | 0.125rem | 2px | 미세 간격 |
|
||||||
|
| 1 | 0.25rem | 4px | 최소 간격 (gap-1) |
|
||||||
|
| 1.5 | 0.375rem | 6px | 컴팩트 간격 (gap-1.5) |
|
||||||
|
| 2 | 0.5rem | 8px | 기본 간격 (gap-2, p-2) |
|
||||||
|
| 2.5 | 0.625rem | 10px | 중간 간격 |
|
||||||
|
| 3 | 0.75rem | 12px | 표준 간격 (gap-3, p-3) |
|
||||||
|
| 4 | 1rem | 16px | 넓은 간격 (p-4, gap-4) |
|
||||||
|
| 5 | 1.25rem | 20px | 패널 패딩 (px-5, py-5) |
|
||||||
|
| 6 | 1.5rem | 24px | 섹션 간격 (gap-6, p-6) |
|
||||||
|
| 8 | 2rem | 32px | 큰 간격 (px-8, gap-8) |
|
||||||
|
| 16 | 4rem | 64px | 최대 간격 |
|
||||||
|
|
||||||
|
#### Z-Index Layers
|
||||||
|
|
||||||
|
| Layer | z-index | Color | 설명 |
|
||||||
|
|-------|---------|-------|------|
|
||||||
|
| Tooltip | 60 | `#a855f7` | 툴팁, 드롭다운 메뉴 |
|
||||||
|
| Popup | 50 | `#f97316` | 팝업, 지도 오버레이 |
|
||||||
|
| Modal | 40 | `#ef4444` | 모달 다이얼로그, 백드롭 |
|
||||||
|
| TopBar | 30 | `#3b82f6` | 상단 네비게이션 바 |
|
||||||
|
| Sidebar | 20 | `#06b6d4` | 사이드바, 패널 |
|
||||||
|
| Content | 10 | `#22c55e` | 메인 콘텐츠 영역 |
|
||||||
|
| Base | 0 | `#8690a6` | 기본 레이어, 배경 |
|
||||||
|
|
||||||
|
#### App Shell Classes
|
||||||
|
|
||||||
|
| 클래스 | 역할 | Tailwind 스타일 |
|
||||||
|
|--------|------|----------------|
|
||||||
|
| `.wing-panel` | 탭 콘텐츠 패널 | `flex flex-col h-full overflow-hidden` |
|
||||||
|
| `.wing-panel-scroll` | 패널 내 스크롤 영역 | `flex-1 overflow-y-auto` |
|
||||||
|
| `.wing-header-bar` | 패널 헤더 | `flex items-center justify-between shrink-0 px-5 border-b` |
|
||||||
|
| `.wing-sidebar` | 사이드바 | `flex flex-col border-r border-border` |
|
||||||
@ -1,191 +0,0 @@
|
|||||||
# 확산 예측 기능 가이드
|
|
||||||
|
|
||||||
> 대상: 확산 예측(OpenDrift) 기능 개발 및 유지보수 담당자
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 아키텍처 개요
|
|
||||||
|
|
||||||
**폴링 방식** — HTTP 연결 불안정 문제 해결을 위해 비동기 폴링 구조를 채택했다.
|
|
||||||
|
|
||||||
```
|
|
||||||
[프론트] 실행 버튼
|
|
||||||
→ POST /api/simulation/run 즉시 { execSn, status:'RUNNING' } 반환
|
|
||||||
→ "분석 중..." UI 표시
|
|
||||||
→ 3초마다 GET /api/simulation/status/:execSn 폴링
|
|
||||||
|
|
||||||
[Express 백엔드]
|
|
||||||
→ PRED_EXEC INSERT (PENDING)
|
|
||||||
→ POST Python /run-model 즉시 { job_id } 수신
|
|
||||||
→ 응답 즉시 반환 (프론트 블록 없음)
|
|
||||||
→ 백그라운드: 3초마다 Python GET /status/:job_id 폴링
|
|
||||||
→ DONE 시 PRED_EXEC UPDATE (결과 JSONB 저장)
|
|
||||||
|
|
||||||
[Python FastAPI :5003]
|
|
||||||
→ 동시 처리 초과 시 503 즉시 반환
|
|
||||||
→ 여유 시 job_id 반환 + 백그라운드 OpenDrift 시뮬레이션 실행
|
|
||||||
→ NC 결과 → JSON 변환 → 상태 DONE
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. DB 스키마 (PRED_EXEC)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
PRED_EXEC_SN SERIAL PRIMARY KEY
|
|
||||||
ACDNT_SN INTEGER NOT NULL -- 사고 FK
|
|
||||||
SPIL_DATA_SN INTEGER -- 유출정보 FK (NULL 허용)
|
|
||||||
EXEC_NM VARCHAR(100) UNIQUE -- EXPC_{timestamp} 형식
|
|
||||||
ALGO_CD VARCHAR(20) NOT NULL -- 'OPENDRIFT'
|
|
||||||
EXEC_STTS_CD VARCHAR(20) DEFAULT 'PENDING'
|
|
||||||
-- PENDING | RUNNING | COMPLETED | FAILED
|
|
||||||
BGNG_DTM TIMESTAMPTZ
|
|
||||||
CMPL_DTM TIMESTAMPTZ
|
|
||||||
REQD_SEC INTEGER
|
|
||||||
RSLT_DATA JSONB -- 시뮬레이션 결과 전체
|
|
||||||
ERR_MSG TEXT
|
|
||||||
```
|
|
||||||
|
|
||||||
인덱스: `IDX_PRED_STTS` (EXEC_STTS_CD), `uix_pred_exec_nm` (EXEC_NM, partial)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Python FastAPI 엔드포인트 (포트 5003)
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
|
||||||
|--------|------|------|
|
|
||||||
| GET | `/get-received-date` | 최신 예보 수신 가능 날짜 |
|
|
||||||
| GET | `/get-uv/{datetime}/{category}` | 바람/해류 U/V 벡터 (`wind`\|`hydr`) |
|
|
||||||
| POST | `/check-nc` | NetCDF 파일 존재 여부 확인 |
|
|
||||||
| POST | `/run-model` | 시뮬레이션 제출 → 즉시 `job_id` 반환 |
|
|
||||||
| GET | `/status/{job_id}` | 시뮬레이션 진행 상태 조회 |
|
|
||||||
|
|
||||||
### POST /run-model 입력 파라미터
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"startTime": "2025-01-15 12:00:00", // KST (내부 UTC 변환)
|
|
||||||
"runTime": 72, // 예측 시간 (시간)
|
|
||||||
"matTy": "CRUDE OIL", // OpenDrift 유류명
|
|
||||||
"matVol": 100.0, // 시간당 유출량 (m³/hr)
|
|
||||||
"lon": 126.1,
|
|
||||||
"lat": 36.6,
|
|
||||||
"spillTime": 12, // 유출 지속 시간 (0=순간)
|
|
||||||
"name": "EXPC_1710000000000"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 유류 코드 매핑 (DB → OpenDrift)
|
|
||||||
|
|
||||||
| DB SPIL_MAT_CD | OpenDrift 이름 |
|
|
||||||
|---------------|---------------|
|
|
||||||
| CRUD | CRUDE OIL |
|
|
||||||
| DSEL | DIESEL |
|
|
||||||
| BNKR | BUNKER |
|
|
||||||
| HEFO | IFO 180 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Express 백엔드 주요 엔드포인트
|
|
||||||
|
|
||||||
파일: [backend/src/routes/simulation.ts](../backend/src/routes/simulation.ts)
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
|
||||||
|--------|------|------|
|
|
||||||
| POST | `/api/simulation/run` | 시뮬레이션 제출 → `execSn` 즉시 반환 |
|
|
||||||
| GET | `/api/simulation/status/:execSn` | 프론트 폴링용 상태 조회 |
|
|
||||||
|
|
||||||
파일: [backend/src/prediction/predictionService.ts](../backend/src/prediction/predictionService.ts)
|
|
||||||
|
|
||||||
- `fetchPredictionList()` — PRED_EXEC 목록 조회
|
|
||||||
- `fetchTrajectoryResult()` — 저장된 결과 조회 (`RSLT_DATA` JSONB 파싱)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 프론트엔드 주요 파일
|
|
||||||
|
|
||||||
| 파일 | 역할 |
|
|
||||||
|------|------|
|
|
||||||
| [frontend/src/tabs/prediction/components/OilSpillView.tsx](../frontend/src/tabs/prediction/components/OilSpillView.tsx) | 예측 탭 메인 뷰, 시뮬레이션 실행·폴링 상태 관리 |
|
|
||||||
| [frontend/src/tabs/prediction/hooks/](../frontend/src/tabs/prediction/hooks/) | `useSimulationStatus` 폴링 훅 |
|
|
||||||
| [frontend/src/tabs/prediction/services/predictionApi.ts](../frontend/src/tabs/prediction/services/predictionApi.ts) | API 요청 함수 + 타입 정의 |
|
|
||||||
| [frontend/src/tabs/prediction/components/RightPanel.tsx](../frontend/src/tabs/prediction/components/RightPanel.tsx) | 풍화량·잔류량·오염면적 표시 (마지막 스텝 실제 값) |
|
|
||||||
| [frontend/src/common/components/map/HydrParticleOverlay.tsx](../frontend/src/common/components/map/HydrParticleOverlay.tsx) | 해류 파티클 Canvas 오버레이 |
|
|
||||||
|
|
||||||
### 핵심 타입 (predictionApi.ts)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface HydrGrid {
|
|
||||||
lonInterval: number[];
|
|
||||||
latInterval: number[];
|
|
||||||
boundLonLat: { top: number; bottom: number; left: number; right: number };
|
|
||||||
rows: number; cols: number;
|
|
||||||
}
|
|
||||||
interface HydrDataStep {
|
|
||||||
value: [number[][], number[][]]; // [u_2d, v_2d]
|
|
||||||
grid: HydrGrid;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 폴링 훅 패턴
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
useQuery({
|
|
||||||
queryKey: ['simulationStatus', execSn],
|
|
||||||
queryFn: () => api.get(`/api/simulation/status/${execSn}`),
|
|
||||||
enabled: execSn !== null,
|
|
||||||
refetchInterval: (data) =>
|
|
||||||
data?.status === 'DONE' || data?.status === 'ERROR' ? false : 3000,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Python 코드 위치 (prediction/)
|
|
||||||
|
|
||||||
```
|
|
||||||
prediction/opendrift/
|
|
||||||
├── api.py FastAPI 진입점 (수정 필요: 폴링 지원 + CORS)
|
|
||||||
├── config.py 경로 설정 (수정 필요: 환경변수화)
|
|
||||||
├── createJsonResult.py NC → JSON 변환 (핵심 후처리)
|
|
||||||
├── coastline/ TN_SHORLINE.shp (한국 해안선)
|
|
||||||
├── startup.sh / shutdown.sh
|
|
||||||
├── .env.example 환경변수 샘플
|
|
||||||
└── environment-opendrift.yml conda 환경 재현용
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 환경변수
|
|
||||||
|
|
||||||
### backend/.env
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PYTHON_API_URL=http://localhost:5003
|
|
||||||
```
|
|
||||||
|
|
||||||
### prediction/opendrift/.env
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MPR_STORAGE_ROOT=/data/storage # NetCDF 기상·해양 데이터 루트
|
|
||||||
MPR_RESULT_ROOT=./result # 시뮬레이션 결과 저장 경로
|
|
||||||
MAX_CONCURRENT_JOBS=4 # 동시 처리 최대 수
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 위험 요소
|
|
||||||
|
|
||||||
| 위험 | 내용 |
|
|
||||||
|------|------|
|
|
||||||
| NetCDF 파일 부재 | `MPR_STORAGE_ROOT` 경로에 KMA GDAPS·MOHID NC 파일 필요. 없으면 시뮬레이션 불가 |
|
|
||||||
| conda 환경 | `opendrift` conda 환경 설치 필요 (`environment-opendrift.yml`) |
|
|
||||||
| Workers 포화 | 동시 4개 초과 시 503 반환 → `MAX_CONCURRENT_JOBS` 조정 |
|
|
||||||
| 결과 용량 | 12시간 결과 ≈ 1500KB/건. 90일 주기 `RSLT_DATA = NULL` 정리 권장 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 관련 문서
|
|
||||||
|
|
||||||
- [CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md) — Express API 개발 패턴
|
|
||||||
- [COMMON-GUIDE.md](./COMMON-GUIDE.md) — 인증·상태관리 공통 로직
|
|
||||||
@ -4,15 +4,38 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [2026-03-20.2]
|
## [2026-03-24]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- Stitch MCP 기반 디자인 시스템 카탈로그 페이지 (/design)
|
||||||
|
- react-router-dom 도입, BrowserRouter 래핑
|
||||||
|
- SVG 아이콘 에셋 19종 추가
|
||||||
|
- @/ path alias 추가
|
||||||
|
- 레이어: 레이어 데이터 테이블 매핑 구현 + 어장 팝업 수정
|
||||||
|
- 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장
|
||||||
|
- DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (025 마이그레이션)
|
||||||
|
- DB: 민감자원 데이터 마이그레이션 (026_sensitive_resources)
|
||||||
|
- DB: 민감자원 평가 마이그레이션 추가 (027_sensitivity_evaluation)
|
||||||
|
- 보고서: 유류유출 보고서 템플릿 전면 개선 (OilSpillReportTemplate)
|
||||||
|
- 관리자: 실시간 기상·해상 모니터링 패널 추가 (MonitorRealtimePanel)
|
||||||
|
- 관리자: 방제선 보유자재 현황 패널 추가 (VesselMaterialsPanel)
|
||||||
|
- 관리자: 방제장비 현황 패널에 장비 타입 필터 및 조건부 컬럼 강조 스타일 추가
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
- prediction/scat 파이프라인 제거 + SCAT/사고관리 UI 수정
|
- SCAT 지도 하드코딩 제주 해안선 제거, 인접 구간 기반 동적 방향 계산으로 전환
|
||||||
|
- 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거)
|
||||||
|
- 예측: 예측 API 확장 (predictionRouter/Service, LeftPanel/RightPanel 연동)
|
||||||
|
- 보고서: 유류유출 보고서 민감자원 지도 섹션 개선 (GeoJSON 자동 필터링, 6개 테이블 자동 채우기, 지도 캡처 기능)
|
||||||
|
|
||||||
|
### 문서
|
||||||
|
- Foundation 탭 디자인 토큰 상세 문서화 (DESIGN-SYSTEM.md)
|
||||||
|
|
||||||
## [2026-03-20]
|
## [2026-03-20]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
- 관리자: 지도 베이스 관리 패널, 레이어 패널 추가 및 보고서 기능 개선
|
- 관리자: 지도 베이스 관리 패널, 레이어 패널 추가 및 보고서 기능 개선
|
||||||
|
- 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가)
|
||||||
|
- 관리자: 유처리제 제한구역 패널, 민감자원 레이어 패널 추가
|
||||||
- 항공 방제: WingAI (AI 탐지/분석) 서브탭 추가
|
- 항공 방제: WingAI (AI 탐지/분석) 서브탭 추가
|
||||||
- 항공 방제: UP42 위성 패스 조회 + 궤도 지도 표시
|
- 항공 방제: UP42 위성 패스 조회 + 궤도 지도 표시
|
||||||
- 항공 방제: 위성 요청 취소 기능 추가
|
- 항공 방제: 위성 요청 취소 기능 추가
|
||||||
@ -20,6 +43,8 @@
|
|||||||
- 항공 방제: 위성 히스토리 지도에 캘린더 + 날짜별 촬영 리스트 + 영상 오버레이
|
- 항공 방제: 위성 히스토리 지도에 캘린더 + 날짜별 촬영 리스트 + 영상 오버레이
|
||||||
- 항공 방제: 완료 촬영 클릭 시 VWorld 위성 영상 오버레이 표시
|
- 항공 방제: 완료 촬영 클릭 시 VWorld 위성 영상 오버레이 표시
|
||||||
- 항공 방제: 위성 요청 목록 더보기 → 페이징 처리로 변경
|
- 항공 방제: 위성 요청 목록 더보기 → 페이징 처리로 변경
|
||||||
|
- 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선)
|
||||||
|
- 기상: 날씨 스냅샷 스토어, 유틸리티 모듈 추가
|
||||||
- 사고관리: UI 개선 + 오염물 배출규정 기능 추가
|
- 사고관리: UI 개선 + 오염물 배출규정 기능 추가
|
||||||
- Pre-SCAT 해안조사 UI 개선
|
- Pre-SCAT 해안조사 UI 개선
|
||||||
- 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화)
|
- 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화)
|
||||||
@ -30,10 +55,14 @@
|
|||||||
- 항공 방제: 촬영 히스토리 지도 리스트 위치 좌하단으로 이동
|
- 항공 방제: 촬영 히스토리 지도 리스트 위치 좌하단으로 이동
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
|
- prediction/scat 파이프라인 제거 + SCAT/사고관리 UI 수정
|
||||||
- 기상: 지역별 기상정보 패널 글자 사이즈 조정 + 시각화 개선
|
- 기상: 지역별 기상정보 패널 글자 사이즈 조정 + 시각화 개선
|
||||||
- SCAT 사진을 로컬에서 서버 프록시로 전환 (scat-photos 1,127개 삭제)
|
- SCAT 사진을 로컬에서 서버 프록시로 전환 (scat-photos 1,127개 삭제)
|
||||||
- WeatherRightPanel 중복 코드 정리
|
- WeatherRightPanel 중복 코드 정리
|
||||||
|
|
||||||
|
### 문서
|
||||||
|
- PREDICTION-GUIDE.md 삭제
|
||||||
|
|
||||||
## [2026-03-18]
|
## [2026-03-18]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
@ -67,8 +96,6 @@
|
|||||||
- KOSPS/앙상블 준비중 팝업 + 기본 모델 POSEIDON 변경
|
- KOSPS/앙상블 준비중 팝업 + 기본 모델 POSEIDON 변경
|
||||||
- 오염분석 원 분석 기능 — 중심점/반경 입력으로 원형 오염 면적 계산
|
- 오염분석 원 분석 기능 — 중심점/반경 입력으로 원형 오염 면적 계산
|
||||||
- 오일펜스 배치 가이드 UI 개선
|
- 오일펜스 배치 가이드 UI 개선
|
||||||
- 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화)
|
|
||||||
- Pre-SCAT 관할서 필터링 + 해안조사 데이터 파이프라인 구축
|
|
||||||
- 다각형/원 오염분석 + 범례 최소화 + Convex Hull 면적 계산
|
- 다각형/원 오염분석 + 범례 최소화 + Convex Hull 면적 계산
|
||||||
|
|
||||||
### 수정
|
### 수정
|
||||||
@ -79,7 +106,6 @@
|
|||||||
- 오염분석 UI 개선 — HTML 디자인 참고 반영
|
- 오염분석 UI 개선 — HTML 디자인 참고 반영
|
||||||
- 범례 UI 개선 — HTML 참고 디자인 반영
|
- 범례 UI 개선 — HTML 참고 디자인 반영
|
||||||
- 드론 아이콘 쿼드콥터 + 함정 MarineTraffic 삼각형 스타일
|
- 드론 아이콘 쿼드콥터 + 함정 MarineTraffic 삼각형 스타일
|
||||||
- SCAT 사진을 로컬에서 서버 프록시로 전환 (scat-photos 1,127개 삭제)
|
|
||||||
|
|
||||||
### 기타
|
### 기타
|
||||||
- 프론트엔드 포트 변경(5174) + CORS 허용
|
- 프론트엔드 포트 변경(5174) + CORS 허용
|
||||||
|
|||||||
58
frontend/package-lock.json
generated
@ -32,6 +32,7 @@
|
|||||||
"maplibre-gl": "^5.19.0",
|
"maplibre-gl": "^5.19.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
"react-window": "^2.2.7",
|
"react-window": "^2.2.7",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
@ -3366,6 +3367,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/core-assert": {
|
"node_modules/core-assert": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz",
|
||||||
@ -5451,6 +5465,44 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||||
|
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||||
|
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.13.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-window": {
|
"node_modules/react-window": {
|
||||||
"version": "2.2.7",
|
"version": "2.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.7.tgz",
|
||||||
@ -5660,6 +5712,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/set-value": {
|
"node_modules/set-value": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
|
||||||
|
|||||||
@ -34,6 +34,7 @@
|
|||||||
"maplibre-gl": "^5.19.0",
|
"maplibre-gl": "^5.19.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
"react-window": "^2.2.7",
|
"react-window": "^2.2.7",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
|
|||||||
199501
frontend/public/dispersant-consider.geojson
Normal file
665667
frontend/public/dispersant-restrict.geojson
Normal file
@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
import { GoogleOAuthProvider } from '@react-oauth/google'
|
import { GoogleOAuthProvider } from '@react-oauth/google'
|
||||||
import type { MainTab } from '@common/types/navigation'
|
import type { MainTab } from '@common/types/navigation'
|
||||||
import { MainLayout } from '@common/components/layout/MainLayout'
|
import { MainLayout } from '@common/components/layout/MainLayout'
|
||||||
@ -19,6 +20,7 @@ import { IncidentsView } from '@tabs/incidents'
|
|||||||
import { AdminView } from '@tabs/admin'
|
import { AdminView } from '@tabs/admin'
|
||||||
import { ScatView } from '@tabs/scat'
|
import { ScatView } from '@tabs/scat'
|
||||||
import { RescueView } from '@tabs/rescue'
|
import { RescueView } from '@tabs/rescue'
|
||||||
|
import { DesignPage } from '@/pages/design/DesignPage'
|
||||||
|
|
||||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''
|
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''
|
||||||
|
|
||||||
@ -108,9 +110,14 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/design" element={<DesignPage />} />
|
||||||
|
<Route path="*" element={
|
||||||
<MainLayout activeMainTab={activeMainTab} onMainTabChange={setActiveMainTab}>
|
<MainLayout activeMainTab={activeMainTab} onMainTabChange={setActiveMainTab}>
|
||||||
{renderView()}
|
{renderView()}
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
3
frontend/src/assets/icons/wing-alert-triangle.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="17" height="15" viewBox="0 0 17 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 14.25L8.25 0L16.5 14.25H0V14.25M2.5875 12.75H13.9125L8.25 3L2.5875 12.75V12.75M8.25 12C8.4625 12 8.64063 11.9281 8.78438 11.7844C8.92813 11.6406 9 11.4625 9 11.25C9 11.0375 8.92813 10.8594 8.78438 10.7156C8.64063 10.5719 8.4625 10.5 8.25 10.5C8.0375 10.5 7.85937 10.5719 7.71562 10.7156C7.57187 10.8594 7.5 11.0375 7.5 11.25C7.5 11.4625 7.57187 11.6406 7.71562 11.7844C7.85937 11.9281 8.0375 12 8.25 12V12M7.5 9.75H9V6H7.5V9.75V9.75M8.25 7.875V7.875V7.875V7.875V7.875" fill="#FFB4AB"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 601 B |
3
frontend/src/assets/icons/wing-anchor.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="14" height="15" viewBox="0 0 14 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6.75 15C5.9875 15 5.20625 14.8625 4.40625 14.5875C3.60625 14.3125 2.88125 13.9375 2.23125 13.4625C1.58125 12.9875 1.04688 12.4312 0.628125 11.7937C0.209375 11.1562 0 10.475 0 9.75V7.5L3 9.75L1.8375 10.9125C2.2 11.55 2.775 12.1 3.5625 12.5625C4.35 13.025 5.1625 13.3188 6 13.4438V6.75H3.75V5.25H6V4.36875C5.5625 4.20625 5.20312 3.93437 4.92188 3.55312C4.64062 3.17187 4.5 2.7375 4.5 2.25C4.5 1.625 4.71875 1.09375 5.15625 0.65625C5.59375 0.21875 6.125 0 6.75 0C7.375 0 7.90625 0.21875 8.34375 0.65625C8.78125 1.09375 9 1.625 9 2.25C9 2.7375 8.85938 3.17187 8.57812 3.55312C8.29688 3.93437 7.9375 4.20625 7.5 4.36875V5.25H9.75V6.75H7.5V13.4438C8.3375 13.3188 9.15 13.025 9.9375 12.5625C10.725 12.1 11.3 11.55 11.6625 10.9125L10.5 9.75L13.5 7.5V9.75C13.5 10.475 13.2906 11.1562 12.8719 11.7937C12.4531 12.4312 11.9188 12.9875 11.2688 13.4625C10.6188 13.9375 9.89375 14.3125 9.09375 14.5875C8.29375 14.8625 7.5125 15 6.75 15V15M6.75 3C6.9625 3 7.14063 2.92812 7.28438 2.78437C7.42813 2.64062 7.5 2.4625 7.5 2.25C7.5 2.0375 7.42813 1.85938 7.28438 1.71563C7.14063 1.57188 6.9625 1.5 6.75 1.5C6.5375 1.5 6.35937 1.57188 6.21562 1.71563C6.07187 1.85938 6 2.0375 6 2.25C6 2.4625 6.07187 2.64062 6.21562 2.78437C6.35937 2.92812 6.5375 3 6.75 3V3" fill="#22D3EE"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 1.3 KiB |
3
frontend/src/assets/icons/wing-cargo.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2.25 15C1.8375 15 1.48437 14.8531 1.19062 14.5594C0.896875 14.2656 0.75 13.9125 0.75 13.5V5.04375C0.525 4.90625 0.34375 4.72813 0.20625 4.50938C0.06875 4.29063 0 4.0375 0 3.75V1.5C0 1.0875 0.146875 0.734375 0.440625 0.440625C0.734375 0.146875 1.0875 0 1.5 0H13.5C13.9125 0 14.2656 0.146875 14.5594 0.440625C14.8531 0.734375 15 1.0875 15 1.5V3.75C15 4.0375 14.9312 4.29063 14.7937 4.50938C14.6562 4.72813 14.475 4.90625 14.25 5.04375V13.5C14.25 13.9125 14.1031 14.2656 13.8094 14.5594C13.5156 14.8531 13.1625 15 12.75 15H2.25V15M2.25 5.25V13.5V13.5V13.5H12.75V13.5V13.5V5.25H2.25V5.25M1.5 3.75H13.5V3.75V3.75V1.5V1.5V1.5H1.5V1.5V1.5V3.75V3.75V3.75V3.75M5.25 9H9.75V7.5H5.25V9V9M7.5 9.375V9.375V9.375V9.375V9.375V9.375V9.375V9.375V9.375V9.375" fill="#FFB873"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 872 B |
3
frontend/src/assets/icons/wing-chart-bar.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 13.5V12L1.5 10.5V13.5H0V13.5M3 13.5V9L4.5 7.5V7.5V13.5H3V13.5M6 13.5V7.5L7.5 9.01875V13.5H6V13.5M9 13.5V9.01875L10.5 7.51875V13.5H9V13.5M12 13.5V6L13.5 4.5V13.5H12V13.5M0 9.61875V7.5L5.25 2.25L8.25 5.25L13.5 0V2.11875L8.25 7.36875L5.25 4.36875L0 9.61875V9.61875" fill="#06B6D4" fill-opacity="0.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 414 B |
3
frontend/src/assets/icons/wing-color-palette.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 20C8.63333 20 7.34167 19.7375 6.125 19.2125C4.90833 18.6875 3.84583 17.9708 2.9375 17.0625C2.02917 16.1542 1.3125 15.0917 0.7875 13.875C0.2625 12.6583 0 11.3667 0 10C0 8.61667 0.270833 7.31667 0.8125 6.1C1.35417 4.88333 2.0875 3.825 3.0125 2.925C3.9375 2.025 5.01667 1.3125 6.25 0.7875C7.48333 0.2625 8.8 0 10.2 0C11.5333 0 12.7917 0.229167 13.975 0.6875C15.1583 1.14583 16.1958 1.77917 17.0875 2.5875C17.9792 3.39583 18.6875 4.35417 19.2125 5.4625C19.7375 6.57083 20 7.76667 20 9.05C20 10.9667 19.4167 12.4375 18.25 13.4625C17.0833 14.4875 15.6667 15 14 15H12.15C12 15 11.8958 15.0417 11.8375 15.125C11.7792 15.2083 11.75 15.3 11.75 15.4C11.75 15.6 11.875 15.8875 12.125 16.2625C12.375 16.6375 12.5 17.0667 12.5 17.55C12.5 18.3833 12.2708 19 11.8125 19.4C11.3542 19.8 10.75 20 10 20V20M10 10V10V10V10V10V10V10V10V10V10V10V10V10V10V10V10V10M4.5 11C4.93333 11 5.29167 10.8583 5.575 10.575C5.85833 10.2917 6 9.93333 6 9.5C6 9.06667 5.85833 8.70833 5.575 8.425C5.29167 8.14167 4.93333 8 4.5 8C4.06667 8 3.70833 8.14167 3.425 8.425C3.14167 8.70833 3 9.06667 3 9.5C3 9.93333 3.14167 10.2917 3.425 10.575C3.70833 10.8583 4.06667 11 4.5 11V11M7.5 7C7.93333 7 8.29167 6.85833 8.575 6.575C8.85833 6.29167 9 5.93333 9 5.5C9 5.06667 8.85833 4.70833 8.575 4.425C8.29167 4.14167 7.93333 4 7.5 4C7.06667 4 6.70833 4.14167 6.425 4.425C6.14167 4.70833 6 5.06667 6 5.5C6 5.93333 6.14167 6.29167 6.425 6.575C6.70833 6.85833 7.06667 7 7.5 7V7M12.5 7C12.9333 7 13.2917 6.85833 13.575 6.575C13.8583 6.29167 14 5.93333 14 5.5C14 5.06667 13.8583 4.70833 13.575 4.425C13.2917 4.14167 12.9333 4 12.5 4C12.0667 4 11.7083 4.14167 11.425 4.425C11.1417 4.70833 11 5.06667 11 5.5C11 5.93333 11.1417 6.29167 11.425 6.575C11.7083 6.85833 12.0667 7 12.5 7V7M15.5 11C15.9333 11 16.2917 10.8583 16.575 10.575C16.8583 10.2917 17 9.93333 17 9.5C17 9.06667 16.8583 8.70833 16.575 8.425C16.2917 8.14167 15.9333 8 15.5 8C15.0667 8 14.7083 8.14167 14.425 8.425C14.1417 8.70833 14 9.06667 14 9.5C14 9.93333 14.1417 10.2917 14.425 10.575C14.7083 10.8583 15.0667 11 15.5 11V11M10 18C10.15 18 10.2708 17.9583 10.3625 17.875C10.4542 17.7917 10.5 17.6833 10.5 17.55C10.5 17.3167 10.375 17.0417 10.125 16.725C9.875 16.4083 9.75 15.9333 9.75 15.3C9.75 14.6 9.99167 14.0417 10.475 13.625C10.9583 13.2083 11.55 13 12.25 13H14C15.1 13 16.0417 12.6792 16.825 12.0375C17.6083 11.3958 18 10.4 18 9.05C18 7.03333 17.2292 5.35417 15.6875 4.0125C14.1458 2.67083 12.3167 2 10.2 2C7.93333 2 6 2.775 4.4 4.325C2.8 5.875 2 7.76667 2 10C2 12.2167 2.77917 14.1042 4.3375 15.6625C5.89583 17.2208 7.78333 18 10 18V18" fill="#94A3B8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 2.6 KiB |
3
frontend/src/assets/icons/wing-comp-close.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1.05 10.5L0 9.45L4.2 5.25L0 1.05L1.05 0L5.25 4.2L9.45 0L10.5 1.05L6.3 5.25L10.5 9.45L9.45 10.5L5.25 6.3L1.05 10.5V10.5" fill="#94A3B8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 250 B |
3
frontend/src/assets/icons/wing-comp-gear.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="16" height="15" viewBox="0 0 16 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5.475 15L5.175 12.6C5.0125 12.5375 4.85937 12.4625 4.71562 12.375C4.57187 12.2875 4.43125 12.1938 4.29375 12.0938L2.0625 13.0312L0 9.46875L1.93125 8.00625C1.91875 7.91875 1.9125 7.83438 1.9125 7.75313C1.9125 7.67188 1.9125 7.5875 1.9125 7.5C1.9125 7.4125 1.9125 7.32812 1.9125 7.24687C1.9125 7.16562 1.91875 7.08125 1.93125 6.99375L0 5.53125L2.0625 1.96875L4.29375 2.90625C4.43125 2.80625 4.575 2.7125 4.725 2.625C4.875 2.5375 5.025 2.4625 5.175 2.4L5.475 0H9.6L9.9 2.4C10.0625 2.4625 10.2156 2.5375 10.3594 2.625C10.5031 2.7125 10.6437 2.80625 10.7812 2.90625L13.0125 1.96875L15.075 5.53125L13.1438 6.99375C13.1563 7.08125 13.1625 7.16562 13.1625 7.24687C13.1625 7.32812 13.1625 7.4125 13.1625 7.5C13.1625 7.5875 13.1625 7.67188 13.1625 7.75313C13.1625 7.83438 13.15 7.91875 13.125 8.00625L15.0562 9.46875L12.9937 13.0312L10.7812 12.0938C10.6437 12.1938 10.5 12.2875 10.35 12.375C10.2 12.4625 10.05 12.5375 9.9 12.6L9.6 15H5.475V15M6.7875 13.5H8.26875L8.53125 11.5125C8.91875 11.4125 9.27812 11.2656 9.60938 11.0719C9.94063 10.8781 10.2438 10.6437 10.5188 10.3687L12.375 11.1375L13.1062 9.8625L11.4937 8.64375C11.5562 8.46875 11.6 8.28437 11.625 8.09062C11.65 7.89687 11.6625 7.7 11.6625 7.5C11.6625 7.3 11.65 7.10313 11.625 6.90938C11.6 6.71563 11.5562 6.53125 11.4937 6.35625L13.1062 5.1375L12.375 3.8625L10.5188 4.65C10.2438 4.3625 9.94063 4.12187 9.60938 3.92812C9.27812 3.73437 8.91875 3.5875 8.53125 3.4875L8.2875 1.5H6.80625L6.54375 3.4875C6.15625 3.5875 5.79687 3.73437 5.46562 3.92812C5.13437 4.12187 4.83125 4.35625 4.55625 4.63125L2.7 3.8625L1.96875 5.1375L3.58125 6.3375C3.51875 6.525 3.475 6.7125 3.45 6.9C3.425 7.0875 3.4125 7.2875 3.4125 7.5C3.4125 7.7 3.425 7.89375 3.45 8.08125C3.475 8.26875 3.51875 8.45625 3.58125 8.64375L1.96875 9.8625L2.7 11.1375L4.55625 10.35C4.83125 10.6375 5.13437 10.8781 5.46562 11.0719C5.79687 11.2656 6.15625 11.4125 6.54375 11.5125L6.7875 13.5V13.5M7.575 10.125C8.3 10.125 8.91875 9.86875 9.43125 9.35625C9.94375 8.84375 10.2 8.225 10.2 7.5C10.2 6.775 9.94375 6.15625 9.43125 5.64375C8.91875 5.13125 8.3 4.875 7.575 4.875C6.8375 4.875 6.21562 5.13125 5.70937 5.64375C5.20312 6.15625 4.95 6.775 4.95 7.5C4.95 8.225 5.20312 8.84375 5.70937 9.35625C6.21562 9.86875 6.8375 10.125 7.575 10.125V10.125M7.5375 7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5V7.5" fill="#94A3B8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 2.5 KiB |
3
frontend/src/assets/icons/wing-comp-menu.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="14" height="9" viewBox="0 0 14 9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 9V7.5H13.5V9H0V9M0 5.25V3.75H13.5V5.25H0V5.25M0 1.5V0H13.5V1.5H0V1.5" fill="#94A3B8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 200 B |
3
frontend/src/assets/icons/wing-comp-search.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12.45 13.5L7.725 8.775C7.35 9.075 6.91875 9.3125 6.43125 9.4875C5.94375 9.6625 5.425 9.75 4.875 9.75C3.5125 9.75 2.35938 9.27813 1.41562 8.33438C0.471875 7.39063 0 6.2375 0 4.875C0 3.5125 0.471875 2.35938 1.41562 1.41562C2.35938 0.471875 3.5125 0 4.875 0C6.2375 0 7.39063 0.471875 8.33438 1.41562C9.27813 2.35938 9.75 3.5125 9.75 4.875C9.75 5.425 9.6625 5.94375 9.4875 6.43125C9.3125 6.91875 9.075 7.35 8.775 7.725L13.5 12.45L12.45 13.5V13.5M4.875 8.25C5.8125 8.25 6.60938 7.92188 7.26562 7.26562C7.92188 6.60938 8.25 5.8125 8.25 4.875C8.25 3.9375 7.92188 3.14062 7.26562 2.48438C6.60938 1.82812 5.8125 1.5 4.875 1.5C3.9375 1.5 3.14062 1.82812 2.48438 2.48438C1.82812 3.14062 1.5 3.9375 1.5 4.875C1.5 5.8125 1.82812 6.60938 2.48438 7.26562C3.14062 7.92188 3.9375 8.25 4.875 8.25V8.25" fill="#94A3B8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 915 B |
3
frontend/src/assets/icons/wing-documentation.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9.95 16C10.3 16 10.5958 15.8792 10.8375 15.6375C11.0792 15.3958 11.2 15.1 11.2 14.75C11.2 14.4 11.0792 14.1042 10.8375 13.8625C10.5958 13.6208 10.3 13.5 9.95 13.5C9.6 13.5 9.30417 13.6208 9.0625 13.8625C8.82083 14.1042 8.7 14.4 8.7 14.75C8.7 15.1 8.82083 15.3958 9.0625 15.6375C9.30417 15.8792 9.6 16 9.95 16V16M9.05 12.15H10.9C10.9 11.6 10.9625 11.1667 11.0875 10.85C11.2125 10.5333 11.5667 10.1 12.15 9.55C12.5833 9.11667 12.925 8.70417 13.175 8.3125C13.425 7.92083 13.55 7.45 13.55 6.9C13.55 5.96667 13.2083 5.25 12.525 4.75C11.8417 4.25 11.0333 4 10.1 4C9.15 4 8.37917 4.25 7.7875 4.75C7.19583 5.25 6.78333 5.85 6.55 6.55L8.2 7.2C8.28333 6.9 8.47083 6.575 8.7625 6.225C9.05417 5.875 9.5 5.7 10.1 5.7C10.6333 5.7 11.0333 5.84583 11.3 6.1375C11.5667 6.42917 11.7 6.75 11.7 7.1C11.7 7.43333 11.6 7.74583 11.4 8.0375C11.2 8.32917 10.95 8.6 10.65 8.85C9.91667 9.5 9.46667 9.99167 9.3 10.325C9.13333 10.6583 9.05 11.2667 9.05 12.15V12.15M10 20C8.61667 20 7.31667 19.7375 6.1 19.2125C4.88333 18.6875 3.825 17.975 2.925 17.075C2.025 16.175 1.3125 15.1167 0.7875 13.9C0.2625 12.6833 0 11.3833 0 10C0 8.61667 0.2625 7.31667 0.7875 6.1C1.3125 4.88333 2.025 3.825 2.925 2.925C3.825 2.025 4.88333 1.3125 6.1 0.7875C7.31667 0.2625 8.61667 0 10 0C11.3833 0 12.6833 0.2625 13.9 0.7875C15.1167 1.3125 16.175 2.025 17.075 2.925C17.975 3.825 18.6875 4.88333 19.2125 6.1C19.7375 7.31667 20 8.61667 20 10C20 11.3833 19.7375 12.6833 19.2125 13.9C18.6875 15.1167 17.975 16.175 17.075 17.075C16.175 17.975 15.1167 18.6875 13.9 19.2125C12.6833 19.7375 11.3833 20 10 20V20M10 18C12.2333 18 14.125 17.225 15.675 15.675C17.225 14.125 18 12.2333 18 10C18 7.76667 17.225 5.875 15.675 4.325C14.125 2.775 12.2333 2 10 2C7.76667 2 5.875 2.775 4.325 4.325C2.775 5.875 2 7.76667 2 10C2 12.2333 2.775 14.125 4.325 15.675C5.875 17.225 7.76667 18 10 18V18M10 10V10V10V10V10V10V10V10V10V10" fill="#94A3B8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 1.9 KiB |
3
frontend/src/assets/icons/wing-elevation.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 19.05L0 12.05L1.65 10.8L9 16.5L16.35 10.8L18 12.05L9 19.05V19.05M9 14L0 7L9 0L18 7L9 14V14M9 7V7V7V7V7V7M9 11.45L14.75 7L9 2.55L3.25 7L9 11.45V11.45" fill="#94A3B8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 282 B |
3
frontend/src/assets/icons/wing-foundations.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 6V0H18V6H10V6M0 10V0H8V10H0V10M10 18V8H18V18H10V18M0 18V12H8V18H0V18M2 8H6V2H2V8V8M12 16H16V10H12V16V16M12 4H16V2H12V4V4M2 16H6V14H2V16V16M6 8V8V8V8V8V8M12 4V4V4V4V4V4M12 10V10V10V10V10V10M6 14V14V14V14V14V14" fill="#4CD7F6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 343 B |
3
frontend/src/assets/icons/wing-layout-grid.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 8V0H8V8H0V8M0 18V10H8V18H0V18M10 8V0H18V8H10V8M10 18V10H18V18H10V18M2 6H6V2H2V6V6M12 6H16V2H12V6V6M12 16H16V12H12V16V16M2 16H6V12H2V16V16M12 6V6V6V6V6V6M12 12V12V12V12V12V12M6 12V12V12V12V12V12M6 6V6V6V6V6V6" fill="#94A3B8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 341 B |
3
frontend/src/assets/icons/wing-notification.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="31" height="35" viewBox="0 0 31 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M7.59998 23.7501V21.8501H9.49998V15.2001C9.49998 13.8859 9.89581 12.7182 10.6875 11.697C11.4791 10.6757 12.5083 10.0068 13.775 9.6901V9.0251C13.775 8.62926 13.9135 8.29281 14.1906 8.01572C14.4677 7.73864 14.8041 7.6001 15.2 7.6001C15.5958 7.6001 15.9323 7.73864 16.2094 8.01572C16.4864 8.29281 16.625 8.62926 16.625 9.0251V9.6901C17.8916 10.0068 18.9208 10.6757 19.7125 11.697C20.5041 12.7182 20.9 13.8859 20.9 15.2001V21.8501H22.8V23.7501H7.59998V23.7501M15.2 16.6251V16.6251V16.6251V16.6251V16.6251V16.6251V16.6251V16.6251V16.6251M15.2 26.6001C14.6775 26.6001 14.2302 26.4141 13.8581 26.042C13.486 25.6699 13.3 25.2226 13.3 24.7001H17.1C17.1 25.2226 16.9139 25.6699 16.5419 26.042C16.1698 26.4141 15.7225 26.6001 15.2 26.6001V26.6001M11.4 21.8501H19V15.2001C19 14.1551 18.6279 13.2605 17.8837 12.5163C17.1396 11.7722 16.245 11.4001 15.2 11.4001C14.155 11.4001 13.2604 11.7722 12.5162 12.5163C11.7721 13.2605 11.4 14.1551 11.4 15.2001V21.8501V21.8501" fill="#94A3B8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 1.1 KiB |
3
frontend/src/assets/icons/wing-pdf-file-disabled.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4.08333 6.125H4.66667V4.95833H5.25C5.41528 4.95833 5.55382 4.90243 5.66563 4.79063C5.77743 4.67882 5.83333 4.54028 5.83333 4.375V3.79167C5.83333 3.62639 5.77743 3.48785 5.66563 3.37604C5.55382 3.26424 5.41528 3.20833 5.25 3.20833H4.08333V6.125V6.125M4.66667 4.375V3.79167H5.25V4.375H4.66667V4.375M6.41667 6.125H7.58333C7.74861 6.125 7.88715 6.0691 7.99896 5.95729C8.11076 5.84549 8.16667 5.70694 8.16667 5.54167V3.79167C8.16667 3.62639 8.11076 3.48785 7.99896 3.37604C7.88715 3.26424 7.74861 3.20833 7.58333 3.20833H6.41667V6.125V6.125M7 5.54167V3.79167H7.58333V5.54167H7V5.54167M8.75 6.125H9.33333V4.95833H9.91667V4.375H9.33333V3.79167H9.91667V3.20833H8.75V6.125V6.125M3.5 9.33333C3.17917 9.33333 2.90451 9.2191 2.67604 8.99063C2.44757 8.76215 2.33333 8.4875 2.33333 8.16667V1.16667C2.33333 0.845833 2.44757 0.571181 2.67604 0.342708C2.90451 0.114236 3.17917 0 3.5 0H10.5C10.8208 0 11.0955 0.114236 11.324 0.342708C11.5524 0.571181 11.6667 0.845833 11.6667 1.16667V8.16667C11.6667 8.4875 11.5524 8.76215 11.324 8.99063C11.0955 9.2191 10.8208 9.33333 10.5 9.33333H3.5V9.33333M3.5 8.16667H10.5V8.16667V8.16667V1.16667V1.16667V1.16667H3.5V1.16667V1.16667V8.16667V8.16667V8.16667V8.16667M1.16667 11.6667C0.845833 11.6667 0.571181 11.5524 0.342708 11.324C0.114236 11.0955 0 10.8208 0 10.5V2.33333H1.16667V10.5V10.5V10.5H9.33333V11.6667H1.16667V11.6667M3.5 1.16667V1.16667V1.16667V1.16667V8.16667V8.16667V8.16667V8.16667V8.16667V8.16667V1.16667V1.16667V1.16667V1.16667" fill="#3B82F6" fill-opacity="0.4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 1.6 KiB |
3
frontend/src/assets/icons/wing-pdf-file.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4.08333 6.125H4.66667V4.95833H5.25C5.41528 4.95833 5.55382 4.90243 5.66563 4.79063C5.77743 4.67882 5.83333 4.54028 5.83333 4.375V3.79167C5.83333 3.62639 5.77743 3.48785 5.66563 3.37604C5.55382 3.26424 5.41528 3.20833 5.25 3.20833H4.08333V6.125V6.125M4.66667 4.375V3.79167H5.25V4.375H4.66667V4.375M6.41667 6.125H7.58333C7.74861 6.125 7.88715 6.0691 7.99896 5.95729C8.11076 5.84549 8.16667 5.70694 8.16667 5.54167V3.79167C8.16667 3.62639 8.11076 3.48785 7.99896 3.37604C7.88715 3.26424 7.74861 3.20833 7.58333 3.20833H6.41667V6.125V6.125M7 5.54167V3.79167H7.58333V5.54167H7V5.54167M8.75 6.125H9.33333V4.95833H9.91667V4.375H9.33333V3.79167H9.91667V3.20833H8.75V6.125V6.125M3.5 9.33333C3.17917 9.33333 2.90451 9.2191 2.67604 8.99063C2.44757 8.76215 2.33333 8.4875 2.33333 8.16667V1.16667C2.33333 0.845833 2.44757 0.571181 2.67604 0.342708C2.90451 0.114236 3.17917 0 3.5 0H10.5C10.8208 0 11.0955 0.114236 11.324 0.342708C11.5524 0.571181 11.6667 0.845833 11.6667 1.16667V8.16667C11.6667 8.4875 11.5524 8.76215 11.324 8.99063C11.0955 9.2191 10.8208 9.33333 10.5 9.33333H3.5V9.33333M3.5 8.16667H10.5V8.16667V8.16667V1.16667V1.16667V1.16667H3.5V1.16667V1.16667V8.16667V8.16667V8.16667V8.16667M1.16667 11.6667C0.845833 11.6667 0.571181 11.5524 0.342708 11.324C0.114236 11.0955 0 10.8208 0 10.5V2.33333H1.16667V10.5V10.5V10.5H9.33333V11.6667H1.16667V11.6667M3.5 1.16667V1.16667V1.16667V1.16667V8.16667V8.16667V8.16667V8.16667V8.16667V8.16667V1.16667V1.16667V1.16667V1.16667" fill="#3B82F6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 1.6 KiB |
3
frontend/src/assets/icons/wing-settings.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.535 26.6001L14.155 23.5601C13.9491 23.4809 13.7552 23.3859 13.5731 23.2751C13.391 23.1643 13.2129 23.0455 13.0387 22.9188L10.2125 24.1063L7.59998 19.5938L10.0462 17.7413C10.0304 17.6305 10.0225 17.5236 10.0225 17.4207C10.0225 17.3178 10.0225 17.2109 10.0225 17.1001C10.0225 16.9893 10.0225 16.8824 10.0225 16.7795C10.0225 16.6766 10.0304 16.5697 10.0462 16.4588L7.59998 14.6063L10.2125 10.0938L13.0387 11.2813C13.2129 11.1547 13.395 11.0359 13.585 10.9251C13.775 10.8143 13.965 10.7193 14.155 10.6401L14.535 7.6001H19.76L20.14 10.6401C20.3458 10.7193 20.5398 10.8143 20.7219 10.9251C20.9039 11.0359 21.0821 11.1547 21.2562 11.2813L24.0825 10.0938L26.695 14.6063L24.2487 16.4588C24.2646 16.5697 24.2725 16.6766 24.2725 16.7795C24.2725 16.8824 24.2725 16.9893 24.2725 17.1001C24.2725 17.2109 24.2725 17.3178 24.2725 17.4207C24.2725 17.5236 24.2566 17.6305 24.225 17.7413L26.6712 19.5938L24.0587 24.1063L21.2562 22.9188C21.0821 23.0455 20.9 23.1643 20.71 23.2751C20.52 23.3859 20.33 23.4809 20.14 23.5601L19.76 26.6001H14.535V26.6001M16.1975 24.7001H18.0737L18.4062 22.1826C18.8971 22.0559 19.3523 21.8699 19.7719 21.6245C20.1914 21.3791 20.5754 21.0822 20.9237 20.7338L23.275 21.7076L24.2012 20.0926L22.1587 18.5488C22.2379 18.3272 22.2933 18.0936 22.325 17.8482C22.3566 17.6028 22.3725 17.3534 22.3725 17.1001C22.3725 16.8468 22.3566 16.5974 22.325 16.352C22.2933 16.1066 22.2379 15.873 22.1587 15.6513L24.2012 14.1076L23.275 12.4926L20.9237 13.4901C20.5754 13.1259 20.1914 12.8211 19.7719 12.5757C19.3523 12.3303 18.8971 12.1443 18.4062 12.0176L18.0975 9.5001H16.2212L15.8887 12.0176C15.3979 12.1443 14.9427 12.3303 14.5231 12.5757C14.1035 12.8211 13.7196 13.118 13.3712 13.4663L11.02 12.4926L10.0937 14.1076L12.1362 15.6276C12.0571 15.8651 12.0016 16.1026 11.97 16.3401C11.9383 16.5776 11.9225 16.8309 11.9225 17.1001C11.9225 17.3534 11.9383 17.5988 11.97 17.8363C12.0016 18.0738 12.0571 18.3113 12.1362 18.5488L10.0937 20.0926L11.02 21.7076L13.3712 20.7101C13.7196 21.0743 14.1035 21.3791 14.5231 21.6245C14.9427 21.8699 15.3979 22.0559 15.8887 22.1826L16.1975 24.7001V24.7001M17.195 20.4251C18.1133 20.4251 18.8971 20.1005 19.5462 19.4513C20.1954 18.8022 20.52 18.0184 20.52 17.1001C20.52 16.1818 20.1954 15.398 19.5462 14.7488C18.8971 14.0997 18.1133 13.7751 17.195 13.7751C16.2608 13.7751 15.4731 14.0997 14.8319 14.7488C14.1906 15.398 13.87 16.1818 13.87 17.1001C13.87 18.0184 14.1906 18.8022 14.8319 19.4513C15.4731 20.1005 16.2608 20.4251 17.195 20.4251V20.4251M17.1475 17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001V17.1001" fill="#94A3B8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 2.8 KiB |
3
frontend/src/assets/icons/wing-typography.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="16" viewBox="0 0 20 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5 16V3H0V0H13V3H8V16H5V16M14 16V8H11V5H20V8H17V16H14V16" fill="#94A3B8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 187 B |
10
frontend/src/assets/icons/wing-wave-graph.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg width="630" height="96" viewBox="0 0 630 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_2_537)">
|
||||||
|
<path d="M123 76.7998C155 38.3998 187 31.9998 219 57.5998C251 83.1998 283 76.7998 315 38.3998C347 -0.000192642 379 12.7998 411 76.7998C443 140.8 475 121.6 507 19.1998" stroke="#4CD7F6" stroke-width="1.92"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_2_537">
|
||||||
|
<rect width="630" height="96" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | 크기: 451 B |
@ -1,14 +1,14 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer } from '@deck.gl/layers'
|
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer, GeoJsonLayer } from '@deck.gl/layers'
|
||||||
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
|
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
|
||||||
import type { StyleSpecification } from 'maplibre-gl'
|
import type { StyleSpecification } from 'maplibre-gl'
|
||||||
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { layerDatabase } from '@common/services/layerService'
|
import { layerDatabase } from '@common/services/layerService'
|
||||||
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'
|
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'
|
||||||
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'
|
import type { HydrDataStep, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi'
|
||||||
import HydrParticleOverlay from './HydrParticleOverlay'
|
import HydrParticleOverlay from './HydrParticleOverlay'
|
||||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
||||||
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||||
@ -289,6 +289,24 @@ const PRIORITY_LABELS: Record<string, string> = {
|
|||||||
'MEDIUM': '보통',
|
'MEDIUM': '보통',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||||
|
const a = s * Math.min(l, 1 - l);
|
||||||
|
const f = (n: number) => {
|
||||||
|
const k = (n + h * 12) % 12;
|
||||||
|
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
|
||||||
|
};
|
||||||
|
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryToRgb(category: string): [number, number, number] {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < category.length; i++) {
|
||||||
|
hash = (hash * 31 + category.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
const hue = (hash * 137) % 360;
|
||||||
|
return hslToRgb(hue / 360, 0.65, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
const SENSITIVE_COLORS: Record<string, string> = {
|
const SENSITIVE_COLORS: Record<string, string> = {
|
||||||
'aquaculture': '#22c55e',
|
'aquaculture': '#22c55e',
|
||||||
'beach': '#0ea5e9',
|
'beach': '#0ea5e9',
|
||||||
@ -342,6 +360,7 @@ interface MapViewProps {
|
|||||||
incidentCoord: { lat: number; lon: number }
|
incidentCoord: { lat: number; lon: number }
|
||||||
}
|
}
|
||||||
sensitiveResources?: SensitiveResource[]
|
sensitiveResources?: SensitiveResource[]
|
||||||
|
sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null
|
||||||
flyToTarget?: { lng: number; lat: number; zoom?: number } | null
|
flyToTarget?: { lng: number; lat: number; zoom?: number } | null
|
||||||
fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null
|
fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null
|
||||||
centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }>
|
centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }>
|
||||||
@ -528,6 +547,7 @@ export function MapView({
|
|||||||
layerBrightness = 50,
|
layerBrightness = 50,
|
||||||
backtrackReplay,
|
backtrackReplay,
|
||||||
sensitiveResources = [],
|
sensitiveResources = [],
|
||||||
|
sensitiveResourceGeojson,
|
||||||
flyToTarget,
|
flyToTarget,
|
||||||
fitBoundsTarget,
|
fitBoundsTarget,
|
||||||
centerPoints = [],
|
centerPoints = [],
|
||||||
@ -559,6 +579,12 @@ export function MapView({
|
|||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const [playbackSpeed, setPlaybackSpeed] = useState(1)
|
const [playbackSpeed, setPlaybackSpeed] = useState(1)
|
||||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null)
|
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null)
|
||||||
|
// deck.gl 레이어 클릭 시 MapLibre 맵 클릭 핸들러 차단용 플래그 (민감자원 등)
|
||||||
|
const deckClickHandledRef = useRef(false)
|
||||||
|
// 클릭으로 열린 팝업(닫기 전까지 유지) 추적 — 호버 핸들러가 닫지 않도록 방지
|
||||||
|
const persistentPopupRef = useRef(false)
|
||||||
|
// 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용)
|
||||||
|
const hoveredSensitiveRef = useRef<Record<string, unknown> | null>(null)
|
||||||
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime
|
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime
|
||||||
|
|
||||||
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
|
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
|
||||||
@ -569,6 +595,44 @@ export function MapView({
|
|||||||
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
|
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
|
||||||
const { lng, lat } = e.lngLat
|
const { lng, lat } = e.lngLat
|
||||||
setCurrentPosition([lat, lng])
|
setCurrentPosition([lat, lng])
|
||||||
|
// deck.gl 다른 레이어 onClick이 처리한 클릭 — 팝업 유지
|
||||||
|
if (deckClickHandledRef.current) {
|
||||||
|
deckClickHandledRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 민감자원 hover 중이면 팝업 표시
|
||||||
|
if (hoveredSensitiveRef.current) {
|
||||||
|
const props = hoveredSensitiveRef.current
|
||||||
|
const { category, ...rest } = props
|
||||||
|
const entries = Object.entries(rest).filter(([k, v]) => k !== 'srId' && v !== null && v !== undefined && v !== '')
|
||||||
|
persistentPopupRef.current = true
|
||||||
|
setPopupInfo({
|
||||||
|
longitude: lng,
|
||||||
|
latitude: lat,
|
||||||
|
content: (
|
||||||
|
<div className="text-xs font-korean" style={{ minWidth: '180px', maxWidth: '260px' }}>
|
||||||
|
<div className="font-semibold mb-1.5 pb-1 border-b border-[rgba(0,0,0,0.12)]">
|
||||||
|
{String(category ?? '민감자원')}
|
||||||
|
</div>
|
||||||
|
{entries.length > 0 ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{entries.map(([key, val]) => (
|
||||||
|
<div key={key} className="flex gap-2 justify-between">
|
||||||
|
<span className="text-[10px] text-[#888] shrink-0">{key}</span>
|
||||||
|
<span className="text-[10px] text-[#333] font-medium text-right break-all">
|
||||||
|
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[10px] text-[#999]">상세 정보 없음</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (measureMode !== null) {
|
if (measureMode !== null) {
|
||||||
handleMeasureClick(lng, lat)
|
handleMeasureClick(lng, lat)
|
||||||
return
|
return
|
||||||
@ -716,7 +780,7 @@ export function MapView({
|
|||||||
getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]),
|
getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]),
|
||||||
getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230),
|
getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230),
|
||||||
getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2,
|
getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2,
|
||||||
getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : null,
|
getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : [0, 0],
|
||||||
dashJustified: true,
|
dashJustified: true,
|
||||||
widthMinPixels: 2,
|
widthMinPixels: 2,
|
||||||
widthMaxPixels: 6,
|
widthMaxPixels: 6,
|
||||||
@ -1018,8 +1082,11 @@ export function MapView({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
} else if (!info.object) {
|
} else if (!info.object) {
|
||||||
|
// 클릭으로 열린 팝업(어장 등)이 있으면 호버로 닫지 않음
|
||||||
|
if (!persistentPopupRef.current) {
|
||||||
setPopupInfo(null);
|
setPopupInfo(null);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@ -1111,6 +1178,41 @@ export function MapView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 민감자원 GeoJSON 레이어 ---
|
||||||
|
if (sensitiveResourceGeojson && sensitiveResourceGeojson.features.length > 0) {
|
||||||
|
result.push(
|
||||||
|
new GeoJsonLayer({
|
||||||
|
id: 'sensitive-resource-geojson',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
data: sensitiveResourceGeojson as any,
|
||||||
|
pickable: true,
|
||||||
|
stroked: true,
|
||||||
|
filled: true,
|
||||||
|
pointRadiusMinPixels: 10,
|
||||||
|
pointRadiusMaxPixels: 20,
|
||||||
|
lineWidthMinPixels: 1,
|
||||||
|
getLineWidth: 1.5,
|
||||||
|
getFillColor: (f: { properties: { category?: string } | null }) => {
|
||||||
|
const cat = f.properties?.category ?? '';
|
||||||
|
const [r, g, b] = categoryToRgb(cat);
|
||||||
|
return [r, g, b, 80] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
getLineColor: (f: { properties: { category?: string } | null }) => {
|
||||||
|
const cat = f.properties?.category ?? '';
|
||||||
|
const [r, g, b] = categoryToRgb(cat);
|
||||||
|
return [r, g, b, 210] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
onHover: (info: PickingInfo) => {
|
||||||
|
if (info.object) {
|
||||||
|
hoveredSensitiveRef.current = (info.object as { properties: Record<string, unknown> | null }).properties ?? {}
|
||||||
|
} else {
|
||||||
|
hoveredSensitiveRef.current = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}) as unknown as DeckLayer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) ---
|
// --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) ---
|
||||||
const visibleCenters = centerPoints.filter(p => p.time <= currentTime)
|
const visibleCenters = centerPoints.filter(p => p.time <= currentTime)
|
||||||
if (visibleCenters.length > 0) {
|
if (visibleCenters.length > 0) {
|
||||||
@ -1225,12 +1327,12 @@ export function MapView({
|
|||||||
// 거리/면적 측정 레이어
|
// 거리/면적 측정 레이어
|
||||||
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements))
|
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements))
|
||||||
|
|
||||||
return result
|
return result.filter(Boolean)
|
||||||
}, [
|
}, [
|
||||||
oilTrajectory, currentTime, selectedModels,
|
oilTrajectory, currentTime, selectedModels,
|
||||||
boomLines, isDrawingBoom, drawingPoints,
|
boomLines, isDrawingBoom, drawingPoints,
|
||||||
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
||||||
sensitiveResources, centerPoints, windData,
|
sensitiveResources, sensitiveResourceGeojson, centerPoints, windData,
|
||||||
showWind, showBeached, showTimeLabel, simulationStartTime,
|
showWind, showBeached, showTimeLabel, simulationStartTime,
|
||||||
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
|
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
|
||||||
])
|
])
|
||||||
@ -1318,7 +1420,10 @@ export function MapView({
|
|||||||
longitude={popupInfo.longitude}
|
longitude={popupInfo.longitude}
|
||||||
latitude={popupInfo.latitude}
|
latitude={popupInfo.latitude}
|
||||||
anchor="bottom"
|
anchor="bottom"
|
||||||
onClose={() => setPopupInfo(null)}
|
onClose={() => {
|
||||||
|
persistentPopupRef.current = false
|
||||||
|
setPopupInfo(null)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-[#333]">{popupInfo.content}</div>
|
<div className="text-[#333]">{popupInfo.content}</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export interface LayerNode {
|
|||||||
name: string
|
name: string
|
||||||
level: number
|
level: number
|
||||||
layerName: string | null
|
layerName: string | null
|
||||||
|
dataTblNm?: string | null
|
||||||
icon?: string
|
icon?: string
|
||||||
count?: number
|
count?: number
|
||||||
defaultOn?: boolean
|
defaultOn?: boolean
|
||||||
|
|||||||
@ -13,11 +13,11 @@ export function useLayers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 계층 구조 레이어 트리 조회 훅
|
// 계층 구조 레이어 트리 조회 훅
|
||||||
|
// staleTime 없음 → 마운트 시 항상 최신 데이터 요청 (관리자 설정 즉시 반영)
|
||||||
export function useLayerTree() {
|
export function useLayerTree() {
|
||||||
return useQuery<Layer[], Error>({
|
return useQuery<Layer[], Error>({
|
||||||
queryKey: ['layers', 'tree'],
|
queryKey: ['layers', 'tree'],
|
||||||
queryFn: fetchLayerTree,
|
queryFn: fetchLayerTree,
|
||||||
staleTime: 1000 * 60 * 5,
|
|
||||||
retry: 3,
|
retry: 3,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -203,6 +203,13 @@ export interface OilReportPayload {
|
|||||||
windSpeed: string;
|
windSpeed: string;
|
||||||
waveHeight: string;
|
waveHeight: string;
|
||||||
temp: string;
|
temp: string;
|
||||||
|
pressure?: string;
|
||||||
|
visibility?: string;
|
||||||
|
salinity?: string;
|
||||||
|
waveMaxHeight?: string;
|
||||||
|
wavePeriod?: string;
|
||||||
|
currentDir?: string;
|
||||||
|
currentSpeed?: string;
|
||||||
} | null;
|
} | null;
|
||||||
spread: {
|
spread: {
|
||||||
kosps: string;
|
kosps: string;
|
||||||
@ -228,6 +235,12 @@ export interface OilReportPayload {
|
|||||||
centerPoints: { lat: number; lon: number; time: number }[];
|
centerPoints: { lat: number; lon: number; time: number }[];
|
||||||
simulationStartTime: string;
|
simulationStartTime: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
sensitiveResources?: Array<{
|
||||||
|
category: string;
|
||||||
|
count: number;
|
||||||
|
totalArea: number | null;
|
||||||
|
}>;
|
||||||
|
acdntSn?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _oilReportPayload: OilReportPayload | null = null;
|
let _oilReportPayload: OilReportPayload | null = null;
|
||||||
|
|||||||
@ -46,6 +46,7 @@ export interface LayerDTO {
|
|||||||
cmn_cd_nm: string
|
cmn_cd_nm: string
|
||||||
cmn_cd_level: number
|
cmn_cd_level: number
|
||||||
clnm: string | null
|
clnm: string | null
|
||||||
|
data_tbl_nm?: string | null
|
||||||
icon?: string
|
icon?: string
|
||||||
count?: number
|
count?: number
|
||||||
children?: LayerDTO[]
|
children?: LayerDTO[]
|
||||||
@ -58,6 +59,7 @@ export interface Layer {
|
|||||||
fullName: string
|
fullName: string
|
||||||
level: number
|
level: number
|
||||||
wmsLayer: string | null
|
wmsLayer: string | null
|
||||||
|
dataTblNm?: string | null
|
||||||
icon?: string
|
icon?: string
|
||||||
count?: number
|
count?: number
|
||||||
children?: Layer[]
|
children?: Layer[]
|
||||||
@ -72,6 +74,7 @@ function convertToLayer(dto: LayerDTO): Layer {
|
|||||||
fullName: dto.cmn_cd_full_nm,
|
fullName: dto.cmn_cd_full_nm,
|
||||||
level: dto.cmn_cd_level,
|
level: dto.cmn_cd_level,
|
||||||
wmsLayer: dto.clnm,
|
wmsLayer: dto.clnm,
|
||||||
|
dataTblNm: dto.data_tbl_nm,
|
||||||
icon: dto.icon,
|
icon: dto.icon,
|
||||||
count: dto.count,
|
count: dto.count,
|
||||||
children: dto.children ? dto.children.map(convertToLayer) : undefined,
|
children: dto.children ? dto.children.map(convertToLayer) : undefined,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export interface Layer {
|
|||||||
fullName: string
|
fullName: string
|
||||||
level: number
|
level: number
|
||||||
wmsLayer: string | null
|
wmsLayer: string | null
|
||||||
|
dataTblNm?: string | null
|
||||||
icon?: string
|
icon?: string
|
||||||
count?: number
|
count?: number
|
||||||
children?: Layer[]
|
children?: Layer[]
|
||||||
|
|||||||
53
frontend/src/common/store/weatherSnapshotStore.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export interface WeatherSnapshot {
|
||||||
|
stationName: string;
|
||||||
|
capturedAt: string;
|
||||||
|
wind: {
|
||||||
|
speed: number;
|
||||||
|
direction: number;
|
||||||
|
directionLabel: string;
|
||||||
|
speed_1k: number;
|
||||||
|
speed_3k: number;
|
||||||
|
};
|
||||||
|
wave: {
|
||||||
|
height: number;
|
||||||
|
maxHeight: number;
|
||||||
|
period: number;
|
||||||
|
direction: string;
|
||||||
|
};
|
||||||
|
temperature: {
|
||||||
|
current: number;
|
||||||
|
feelsLike: number;
|
||||||
|
};
|
||||||
|
pressure: number;
|
||||||
|
visibility: number;
|
||||||
|
salinity: number;
|
||||||
|
astronomy?: {
|
||||||
|
sunrise: string;
|
||||||
|
sunset: string;
|
||||||
|
moonrise: string;
|
||||||
|
moonset: string;
|
||||||
|
moonPhase: string;
|
||||||
|
tidalRange: number;
|
||||||
|
};
|
||||||
|
alert?: string;
|
||||||
|
forecast?: Array<{
|
||||||
|
time: string;
|
||||||
|
icon: string;
|
||||||
|
temperature: number;
|
||||||
|
windSpeed: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeatherSnapshotStore {
|
||||||
|
snapshot: WeatherSnapshot | null;
|
||||||
|
setSnapshot: (data: WeatherSnapshot) => void;
|
||||||
|
clearSnapshot: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWeatherSnapshotStore = create<WeatherSnapshotStore>((set) => ({
|
||||||
|
snapshot: null,
|
||||||
|
setSnapshot: (data) => set({ snapshot: data }),
|
||||||
|
clearSnapshot: () => set({ snapshot: null }),
|
||||||
|
}));
|
||||||
@ -1,3 +1,9 @@
|
|||||||
|
/* 바람 입자 캔버스(z-index: 450) 위에 팝업이 표시되도록 z-index 설정
|
||||||
|
@layer 밖에 위치해야 non-layered CSS인 MapLibre 스타일보다 우선순위를 가짐 */
|
||||||
|
.maplibregl-popup {
|
||||||
|
z-index: 500;
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
/* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */
|
/* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */
|
||||||
.cctv-dark-popup .maplibregl-popup-content {
|
.cctv-dark-popup .maplibregl-popup-content {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
@ -17,8 +18,10 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<App />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
491
frontend/src/pages/design/ColorPaletteContent.tsx
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
// ColorPaletteContent.tsx — WING-OPS Color Palette 콘텐츠 (다크/라이트 테마 지원)
|
||||||
|
|
||||||
|
import type { DesignTheme } from './designTheme';
|
||||||
|
|
||||||
|
// ---------- 데이터 타입 ----------
|
||||||
|
|
||||||
|
interface PrimitiveColorStep {
|
||||||
|
label: string;
|
||||||
|
hex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrimitiveColorGroup {
|
||||||
|
name: string;
|
||||||
|
steps: PrimitiveColorStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SemanticToken {
|
||||||
|
token: string;
|
||||||
|
dark: string;
|
||||||
|
light: string;
|
||||||
|
usage: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SemanticCategory {
|
||||||
|
name: string;
|
||||||
|
tokens: SemanticToken[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Primitive Colors 데이터 ----------
|
||||||
|
|
||||||
|
const PRIMITIVE_COLORS: PrimitiveColorGroup[] = [
|
||||||
|
{
|
||||||
|
name: 'Navy',
|
||||||
|
steps: [
|
||||||
|
{ label: '0', hex: '#0a0e1a' },
|
||||||
|
{ label: '1', hex: '#0f1524' },
|
||||||
|
{ label: '2', hex: '#121929' },
|
||||||
|
{ label: '3', hex: '#1a2236' },
|
||||||
|
{ label: 'hover', hex: '#1e2844' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cyan',
|
||||||
|
steps: [
|
||||||
|
{ label: '00', hex: '#ecfeff' }, { label: '10', hex: '#cffafe' },
|
||||||
|
{ label: '20', hex: '#a5f3fc' }, { label: '30', hex: '#67e8f9' },
|
||||||
|
{ label: '40', hex: '#22d3ee' }, { label: '50', hex: '#06b6d4' },
|
||||||
|
{ label: '60', hex: '#0891b2' }, { label: '70', hex: '#0e7490' },
|
||||||
|
{ label: '80', hex: '#155e75' }, { label: '90', hex: '#164e63' },
|
||||||
|
{ label: '100', hex: '#083344' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Blue',
|
||||||
|
steps: [
|
||||||
|
{ label: '00', hex: '#eff6ff' }, { label: '10', hex: '#dbeafe' },
|
||||||
|
{ label: '20', hex: '#bfdbfe' }, { label: '30', hex: '#93c5fd' },
|
||||||
|
{ label: '40', hex: '#60a5fa' }, { label: '50', hex: '#3b82f6' },
|
||||||
|
{ label: '60', hex: '#2563eb' }, { label: '70', hex: '#1d4ed8' },
|
||||||
|
{ label: '80', hex: '#1e40af' }, { label: '90', hex: '#1e3a8a' },
|
||||||
|
{ label: '100', hex: '#172554' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Red',
|
||||||
|
steps: [
|
||||||
|
{ label: '00', hex: '#fef2f2' }, { label: '10', hex: '#fee2e2' },
|
||||||
|
{ label: '20', hex: '#fecaca' }, { label: '30', hex: '#fca5a5' },
|
||||||
|
{ label: '40', hex: '#f87171' }, { label: '50', hex: '#ef4444' },
|
||||||
|
{ label: '60', hex: '#dc2626' }, { label: '70', hex: '#b91c1c' },
|
||||||
|
{ label: '80', hex: '#991b1b' }, { label: '90', hex: '#7f1d1d' },
|
||||||
|
{ label: '100', hex: '#450a0a' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Green',
|
||||||
|
steps: [
|
||||||
|
{ label: '00', hex: '#f0fdf4' }, { label: '10', hex: '#dcfce7' },
|
||||||
|
{ label: '20', hex: '#bbf7d0' }, { label: '30', hex: '#86efac' },
|
||||||
|
{ label: '40', hex: '#4ade80' }, { label: '50', hex: '#22c55e' },
|
||||||
|
{ label: '60', hex: '#16a34a' }, { label: '70', hex: '#15803d' },
|
||||||
|
{ label: '80', hex: '#166534' }, { label: '90', hex: '#14532d' },
|
||||||
|
{ label: '100', hex: '#052e16' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Orange',
|
||||||
|
steps: [
|
||||||
|
{ label: '00', hex: '#fff7ed' }, { label: '10', hex: '#ffedd5' },
|
||||||
|
{ label: '20', hex: '#fed7aa' }, { label: '30', hex: '#fdba74' },
|
||||||
|
{ label: '40', hex: '#fb923c' }, { label: '50', hex: '#f97316' },
|
||||||
|
{ label: '60', hex: '#ea580c' }, { label: '70', hex: '#c2410c' },
|
||||||
|
{ label: '80', hex: '#9a3412' }, { label: '90', hex: '#7c2d12' },
|
||||||
|
{ label: '100', hex: '#431407' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Yellow',
|
||||||
|
steps: [
|
||||||
|
{ label: '00', hex: '#fefce8' }, { label: '10', hex: '#fef9c3' },
|
||||||
|
{ label: '20', hex: '#fef08a' }, { label: '30', hex: '#fde047' },
|
||||||
|
{ label: '40', hex: '#facc15' }, { label: '50', hex: '#eab308' },
|
||||||
|
{ label: '60', hex: '#ca8a04' }, { label: '70', hex: '#a16207' },
|
||||||
|
{ label: '80', hex: '#854d0e' }, { label: '90', hex: '#713f12' },
|
||||||
|
{ label: '100', hex: '#422006' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Semantic Colors 데이터 ----------
|
||||||
|
|
||||||
|
const SEMANTIC_CATEGORIES: SemanticCategory[] = [
|
||||||
|
{
|
||||||
|
name: 'Text',
|
||||||
|
tokens: [
|
||||||
|
{ token: 'text-1', dark: '#edf0f7', light: '#0f172a', usage: ['기본 텍스트 색상', '아이콘 기본 색상'] },
|
||||||
|
{ token: 'text-2', dark: '#b0b8cc', light: '#475569', usage: ['보조 텍스트 색상'] },
|
||||||
|
{ token: 'text-3', dark: '#8690a6', light: '#94a3b8', usage: ['비활성 텍스트', '플레이스홀더'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Background',
|
||||||
|
tokens: [
|
||||||
|
{ token: 'bg-0', dark: '#0a0e1a', light: '#f8fafc', usage: ['페이지 배경'] },
|
||||||
|
{ token: 'bg-1', dark: '#0f1524', light: '#ffffff', usage: ['사이드바', '패널'] },
|
||||||
|
{ token: 'bg-2', dark: '#121929', light: '#f1f5f9', usage: ['테이블 헤더'] },
|
||||||
|
{ token: 'bg-3', dark: '#1a2236', light: '#e2e8f0', usage: ['카드 배경'] },
|
||||||
|
{ token: 'bg-hover', dark: '#1e2844', light: '#cbd5e1', usage: ['호버 상태'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Border',
|
||||||
|
tokens: [
|
||||||
|
{ token: 'border', dark: '#1e2a42', light: '#cbd5e1', usage: ['기본 구분선'] },
|
||||||
|
{ token: 'border-light', dark: '#2a3a5c', light: '#e2e8f0', usage: ['연한 구분선'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Accent',
|
||||||
|
tokens: [
|
||||||
|
{ token: 'primary-cyan', dark: '#06b6d4', light: '#06b6d4', usage: ['주요 강조', '활성 상태'] },
|
||||||
|
{ token: 'primary-blue', dark: '#3b82f6', light: '#0891b2', usage: ['보조 강조'] },
|
||||||
|
{ token: 'primary-purple', dark: '#a855f7', light: '#6366f1', usage: ['3차 강조'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Status',
|
||||||
|
tokens: [
|
||||||
|
{ token: 'status-red', dark: '#ef4444', light: '#dc2626', usage: ['위험', '삭제'] },
|
||||||
|
{ token: 'status-orange', dark: '#f97316', light: '#c2410c', usage: ['주의'] },
|
||||||
|
{ token: 'status-yellow', dark: '#eab308', light: '#b45309', usage: ['경고'] },
|
||||||
|
{ token: 'status-green', dark: '#22c55e', light: '#047857', usage: ['정상', '성공'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Props ----------
|
||||||
|
|
||||||
|
interface ColorPaletteContentProps {
|
||||||
|
theme: DesignTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 컴포넌트 ----------
|
||||||
|
|
||||||
|
export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
||||||
|
const t = theme;
|
||||||
|
const isDark = t.mode === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-24 px-8 pb-16 flex flex-col gap-16 items-start justify-start max-w-[1440px]">
|
||||||
|
|
||||||
|
{/* ── 섹션 1: 헤더 ── */}
|
||||||
|
<div
|
||||||
|
className="w-full border-b border-solid pb-8 flex flex-col gap-6"
|
||||||
|
style={{ borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0' }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1
|
||||||
|
className="font-sans text-3xl leading-9 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
Color
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="font-korean text-sm leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
WING-OPS 인터페이스에서 사용되는 색상 체계입니다. Primitive Token(원시 팔레트)과 Semantic Token(의미 기반 토큰) 두 계층으로 구성됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 토큰 네이밍 다이어그램 카드 */}
|
||||||
|
<div
|
||||||
|
className="inline-flex flex-row items-center gap-3 rounded-md border border-solid px-5 py-4"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.cardBg,
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
boxShadow: t.cardShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono text-sm font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
bg
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-mono text-sm"
|
||||||
|
style={{ color: isDark ? 'rgba(66,71,84,0.60)' : '#cbd5e1' }}
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-mono text-sm font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
0
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-row gap-4 ml-4">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.textAccent }}
|
||||||
|
>
|
||||||
|
Category
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-korean text-[11px]"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
색상 범주 (bg, text, border, status…)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-px self-stretch"
|
||||||
|
style={{ backgroundColor: isDark ? 'rgba(66,71,84,0.30)' : '#e2e8f0' }}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.textAccent }}
|
||||||
|
>
|
||||||
|
Value
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-korean text-[11px]"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
단계 또는 역할 (0–3, hover, red, cyan…)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 섹션 2: Primitive Tokens ── */}
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2
|
||||||
|
className="font-sans text-2xl leading-8 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
Primitive Tokens
|
||||||
|
</h2>
|
||||||
|
<ul
|
||||||
|
className="flex flex-col gap-1 list-disc list-inside font-korean text-sm"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
<li>Tailwind CSS 기반 색상 스케일로 구성된 원시 팔레트입니다.</li>
|
||||||
|
<li>각 색조(Hue)는 00(가장 밝음)에서 100(가장 어두움)까지 11단계로 표현됩니다.</li>
|
||||||
|
<li>Navy는 UI 배경 계층에만 사용되는 특수 팔레트입니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{PRIMITIVE_COLORS.map((group) => (
|
||||||
|
<div key={group.name} className="flex flex-col gap-2">
|
||||||
|
{/* 그룹명 */}
|
||||||
|
<span
|
||||||
|
className="font-mono text-xs font-bold uppercase"
|
||||||
|
style={{ letterSpacing: '1.2px', color: t.textMuted }}
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 가로 컬러 바 */}
|
||||||
|
<div className="flex flex-row w-full">
|
||||||
|
{group.steps.map((step) => (
|
||||||
|
<div key={step.label} className="flex flex-col" style={{ flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: step.hex,
|
||||||
|
height: '48px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-0.5 pt-1"
|
||||||
|
style={{ borderTop: `1px solid ${isDark ? 'rgba(66,71,84,0.15)' : '#e2e8f0'}` }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono"
|
||||||
|
style={{ fontSize: '9px', lineHeight: '13px', color: t.textMuted }}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-mono"
|
||||||
|
style={{ fontSize: '9px', lineHeight: '13px', color: t.textMuted, opacity: 0.7 }}
|
||||||
|
>
|
||||||
|
{step.hex}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 섹션 3: Semantic Colors ── */}
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2
|
||||||
|
className="font-sans text-2xl leading-8 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
Semantic Colors
|
||||||
|
</h2>
|
||||||
|
<ul
|
||||||
|
className="flex flex-col gap-1 list-disc list-inside font-korean text-sm"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
<li>Primitive Token을 역할 기반으로 추상화한 의미 토큰입니다.</li>
|
||||||
|
<li>다크/라이트 모드에 따라 실제 색상 값이 달라집니다.</li>
|
||||||
|
<li>코드에서는 항상 Semantic Token을 사용하고, Primitive Token을 직접 참조하지 않습니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
{SEMANTIC_CATEGORIES.map((category) => (
|
||||||
|
<div key={category.name} className="flex flex-col gap-3">
|
||||||
|
{/* 카테고리 제목 (좌측 2px cyan accent bar) */}
|
||||||
|
<div className="flex flex-row items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-0.5 self-stretch rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: t.textAccent, minHeight: '20px' }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="font-sans text-sm font-bold uppercase"
|
||||||
|
style={{ letterSpacing: '0.8px', color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-solid overflow-hidden w-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.tableContainerBg,
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
boxShadow: t.cardShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div
|
||||||
|
className="grid"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '200px 1fr 1fr 1fr',
|
||||||
|
backgroundColor: t.tableHeaderBg,
|
||||||
|
borderBottom: `1px solid ${t.tableRowBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(['Token', 'Dark Mode', 'Light Mode', 'Usage'] as const).map((col) => (
|
||||||
|
<div key={col} className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] font-medium uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행 */}
|
||||||
|
{category.tokens.map((token, rowIdx) => (
|
||||||
|
<div
|
||||||
|
key={token.token}
|
||||||
|
className="grid"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '200px 1fr 1fr 1fr',
|
||||||
|
borderTop: rowIdx === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Token 컬럼 */}
|
||||||
|
<div className="py-3 px-4 flex items-center">
|
||||||
|
<span
|
||||||
|
className="font-mono rounded border border-solid px-2 py-0.5"
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
lineHeight: '17px',
|
||||||
|
color: t.textAccent,
|
||||||
|
backgroundColor: t.cardBg,
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{token.token}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dark Mode 컬럼 */}
|
||||||
|
<div className="py-3 px-4 flex items-center">
|
||||||
|
<div
|
||||||
|
className="inline-flex flex-row items-center gap-2 rounded-md px-3 py-2"
|
||||||
|
style={{ backgroundColor: '#171b28' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-sm shrink-0"
|
||||||
|
style={{
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
backgroundColor: token.dark,
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="font-mono text-[11px]"
|
||||||
|
style={{ color: 'rgba(237,240,247,0.70)' }}
|
||||||
|
>
|
||||||
|
{token.dark}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Light Mode 컬럼 */}
|
||||||
|
<div className="py-3 px-4 flex items-center">
|
||||||
|
<div
|
||||||
|
className="inline-flex flex-row items-center gap-2 rounded-md px-3 py-2"
|
||||||
|
style={{ backgroundColor: '#f8fafc' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-sm shrink-0"
|
||||||
|
style={{
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
backgroundColor: token.light,
|
||||||
|
border: '1px solid rgba(0,0,0,0.08)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="font-mono text-[11px]"
|
||||||
|
style={{ color: 'rgba(15,23,42,0.70)' }}
|
||||||
|
>
|
||||||
|
{token.light}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage 컬럼 */}
|
||||||
|
<div className="py-3 px-4 flex items-center">
|
||||||
|
<ul className="flex flex-col gap-0.5 list-none">
|
||||||
|
{token.usage.map((u) => (
|
||||||
|
<li key={u} className="flex flex-row items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="w-1 h-1 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: t.textMuted }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="font-korean text-xs"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
{u}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ColorPaletteContent;
|
||||||
48
frontend/src/pages/design/ComponentsContent.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { ButtonCatalogSection } from './components/ButtonCatalogSection';
|
||||||
|
import { IconBadgeSection } from './components/IconBadgeSection';
|
||||||
|
import { CardSection } from './components/CardSection';
|
||||||
|
|
||||||
|
export const ComponentsContent = () => {
|
||||||
|
return (
|
||||||
|
<div className="pt-20 px-8 pb-16 flex flex-col gap-[121.5px] items-start justify-start max-w-[1440px]">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex flex-col gap-2 items-start justify-start self-stretch">
|
||||||
|
<h1
|
||||||
|
className="text-[#dfe2f3] font-korean text-3xl leading-9 font-medium self-stretch"
|
||||||
|
style={{ letterSpacing: '-0.75px' }}
|
||||||
|
>
|
||||||
|
시스템 컴포넌트 카탈로그
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#bcc9cd] font-korean text-sm leading-5 font-medium max-w-2xl">
|
||||||
|
WING-OPS 해상 물류를 위한 시각적 아이덴티티 시스템입니다. 정밀도와 미션 크리티컬한 운영을 위해 설계된 고밀도 산업용 인터페이스입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 섹션 */}
|
||||||
|
<ButtonCatalogSection />
|
||||||
|
<IconBadgeSection />
|
||||||
|
<CardSection />
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div
|
||||||
|
className="border-t border-solid border-[rgba(22,78,99,0.10)] p-8 flex flex-row items-center justify-between self-stretch"
|
||||||
|
style={{ opacity: 0.4 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-[#64748b] font-sans text-[10px] leading-[15px] font-bold uppercase"
|
||||||
|
style={{ letterSpacing: '1px' }}
|
||||||
|
>
|
||||||
|
© 2024 WING-OPS 해상 시스템
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-[#22d3ee] font-korean text-[10px] leading-[15px] font-medium uppercase"
|
||||||
|
style={{ letterSpacing: '1px' }}
|
||||||
|
>
|
||||||
|
전술 네비게이터 쉘 v2.4
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComponentsContent;
|
||||||
489
frontend/src/pages/design/DesignContent.tsx
Normal file
@ -0,0 +1,489 @@
|
|||||||
|
// DesignContent.tsx — 디자인 토큰 탭 콘텐츠 (다크/라이트 테마 지원)
|
||||||
|
|
||||||
|
import type { DesignTheme } from './designTheme';
|
||||||
|
|
||||||
|
// ---------- 06 타이포그래피 스케일 데이터 ----------
|
||||||
|
interface TypoRow {
|
||||||
|
size: string;
|
||||||
|
sampleNode: (theme: DesignTheme) => React.ReactNode;
|
||||||
|
properties: string;
|
||||||
|
isData?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 공통 섹션 타이틀 ----------
|
||||||
|
interface SectionTitleProps {
|
||||||
|
num: string;
|
||||||
|
title: string;
|
||||||
|
sub?: string;
|
||||||
|
rightNode?: React.ReactNode;
|
||||||
|
theme: DesignTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SectionTitle = ({ num, title, sub, rightNode, theme }: SectionTitleProps) => (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex flex-row items-center justify-between">
|
||||||
|
<p
|
||||||
|
className="font-sans text-lg leading-7 font-bold"
|
||||||
|
style={{ letterSpacing: '0.45px', color: theme.sectionTitle }}
|
||||||
|
>
|
||||||
|
{num} {title}
|
||||||
|
</p>
|
||||||
|
{rightNode}
|
||||||
|
</div>
|
||||||
|
{sub && (
|
||||||
|
<p
|
||||||
|
className="font-mono text-[10px] leading-[15px] uppercase"
|
||||||
|
style={{ letterSpacing: theme.sectionSubSpacing, color: theme.sectionSub }}
|
||||||
|
>
|
||||||
|
{sub}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- 타이포그래피 행 ----------
|
||||||
|
const TYPO_ROWS: TypoRow[] = [
|
||||||
|
{
|
||||||
|
size: '9px / Meta',
|
||||||
|
sampleNode: (t) => (
|
||||||
|
<span className="font-korean text-[9px]" style={{ color: t.typoSampleText }}>메타정보 Meta info</span>
|
||||||
|
),
|
||||||
|
properties: 'Regular / 400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
size: '10px / Table',
|
||||||
|
sampleNode: (t) => (
|
||||||
|
<span className="font-korean text-[10px]" style={{ color: t.typoSampleText }}>테이블 데이터 Table data</span>
|
||||||
|
),
|
||||||
|
properties: 'Medium / 500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
size: '11px / Action',
|
||||||
|
sampleNode: (t) => (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-md border border-solid py-2 px-4"
|
||||||
|
style={{ backgroundColor: t.typoActionBg, borderColor: t.typoActionBorder }}
|
||||||
|
>
|
||||||
|
<span className="font-korean text-[11px] font-medium" style={{ color: t.typoActionText }}>
|
||||||
|
입력/버튼 Input/Button text
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
properties: 'Medium / 500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
size: '13px / Header',
|
||||||
|
sampleNode: (t) => (
|
||||||
|
<span className="font-korean text-[13px] font-bold" style={{ color: t.textPrimary }}>
|
||||||
|
섹션 헤더 Section Header
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
properties: 'Bold / 700',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
size: '15px / Title',
|
||||||
|
sampleNode: (t) => (
|
||||||
|
<span className="font-korean text-[15px] font-bold" style={{ color: t.textPrimary }}>
|
||||||
|
패널 타이틀 Panel Title
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
properties: 'ExtraBold / 800',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
size: 'Data / Mono',
|
||||||
|
sampleNode: (t) => (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-mono text-[11px]" style={{ color: t.typoDataText }}>1,234.56 km²</span>
|
||||||
|
<span className="font-mono text-[11px]" style={{ color: t.typoCoordText }}>35° 06' 12" N</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
properties: 'JetBrains Mono / 11px',
|
||||||
|
isData: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- 컴포넌트 ----------
|
||||||
|
export interface DesignContentProps {
|
||||||
|
theme: DesignTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||||
|
const t = theme;
|
||||||
|
const isDark = t.mode === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-24 px-8 pb-16 flex flex-col gap-12 items-start justify-start max-w-[1440px]">
|
||||||
|
{/* ── 헤더 섹션 ── */}
|
||||||
|
<div
|
||||||
|
className="w-full border-b border-solid pb-8 flex flex-row items-end justify-between"
|
||||||
|
style={{ borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0' }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1
|
||||||
|
className="font-sans text-4xl leading-10 font-bold"
|
||||||
|
style={{ letterSpacing: '-0.9px', color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
디자인 토큰
|
||||||
|
</h1>
|
||||||
|
<p className="font-korean text-base leading-6 font-light" style={{ color: t.textSecondary }}>
|
||||||
|
Comprehensive design token reference for the WING-OPS operational interface.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* 상태 뱃지 */}
|
||||||
|
<div
|
||||||
|
className="rounded border border-solid p-2 flex flex-row gap-3 items-center shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.systemActiveBg,
|
||||||
|
borderColor: t.systemActiveBorder,
|
||||||
|
boxShadow: isDark ? 'none' : t.systemActiveShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="rounded-xl w-3 h-3 shrink-0"
|
||||||
|
style={{ backgroundColor: t.textAccent, boxShadow: t.systemActiveShadow }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="font-mono text-xs leading-4 uppercase"
|
||||||
|
style={{ letterSpacing: '1.2px', color: t.textAccent }}
|
||||||
|
>
|
||||||
|
System Active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 2컬럼 그리드 ── */}
|
||||||
|
<div
|
||||||
|
className="w-full grid gap-10"
|
||||||
|
style={{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }}
|
||||||
|
>
|
||||||
|
{/* ── 행 1 좌측: 01 배경색 ── */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle num="01" title="배경색" sub="Surface Hierarchy" theme={t} />
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{t.bgTokens.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.token}
|
||||||
|
className="rounded-[10px] border border-solid p-6 flex flex-row gap-4 items-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? item.bg : t.cardBg,
|
||||||
|
borderColor: item.isHover ? t.cardBorderHover : t.cardBorder,
|
||||||
|
boxShadow: t.cardShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 색상 스와치 */}
|
||||||
|
<div
|
||||||
|
className="w-24 h-24 rounded-sm border border-solid shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.bg,
|
||||||
|
borderColor: item.isHover ? t.swatchBorderHover : t.swatchBorder,
|
||||||
|
boxShadow: 'inset 0px 2px 4px 1px rgba(0, 0, 0, 0.05)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-mono text-xs leading-4" style={{ color: t.textAccent }}>{item.token}</span>
|
||||||
|
<span className="font-korean text-sm leading-5 font-bold" style={{ color: t.textPrimary }}>
|
||||||
|
{item.hex}
|
||||||
|
</span>
|
||||||
|
<span className="font-korean text-[11px] leading-[16.5px]" style={{ color: t.textSecondary }}>
|
||||||
|
{item.desc}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 행 1 우측: 02 테두리 색상 + 03 텍스트 색상 ── */}
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
{/* 02 테두리 색상 */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle num="02" title="테두리 색상" theme={t} />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{t.borderTokens.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.token}
|
||||||
|
className="rounded-md border border-solid p-4 flex flex-col gap-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.borderCardBg,
|
||||||
|
borderColor: item.border,
|
||||||
|
boxShadow: t.borderCardShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[10px]" style={{ color: t.textAccent }}>{item.token}</span>
|
||||||
|
<span className="font-korean text-sm font-bold pb-2" style={{ color: t.textPrimary }}>
|
||||||
|
{item.hex}
|
||||||
|
</span>
|
||||||
|
<div className="h-1 self-stretch rounded-sm" style={{ backgroundColor: item.barBg }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 03 텍스트 색상 */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle num="03" title="텍스트 색상" theme={t} />
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-solid p-8 flex flex-col gap-6"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.textSectionBg,
|
||||||
|
borderColor: t.textSectionBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.textTokens.map((item) => (
|
||||||
|
<div key={item.token} className="flex flex-col gap-[3px]">
|
||||||
|
<span className="font-mono text-[10px]" style={{ color: t.textAccent }}>{item.token}</span>
|
||||||
|
<span className={item.sampleClass}>{item.sampleText}</span>
|
||||||
|
<span className="font-korean text-xs" style={{ color: item.descColor }}>
|
||||||
|
{item.desc}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 행 2 좌측: 04 강조 색상 ── */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle num="04" title="강조 색상" theme={t} />
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{t.accentTokens.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.token}
|
||||||
|
className="rounded-lg border border-solid p-4 flex flex-row items-center justify-between"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.cardBg,
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
boxShadow: t.cardShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 좌측: 색상 원 + 이름/토큰 */}
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
<div
|
||||||
|
className={`w-12 h-12 ${t.badgeRadius} shrink-0`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
boxShadow: item.glow,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-korean text-sm font-bold" style={{ color: t.textPrimary }}>{item.name}</span>
|
||||||
|
<span className="font-mono text-[10px]" style={{ color: t.textMuted }}>
|
||||||
|
{item.token} / {item.color}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 우측: 뱃지 */}
|
||||||
|
<div
|
||||||
|
className={`${t.badgeRadius} border border-solid py-1 px-3`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.badgeBg,
|
||||||
|
borderColor: item.badgeBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-korean text-[11px] font-medium"
|
||||||
|
style={{ color: item.badgeText }}
|
||||||
|
>
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 행 2 우측: 05 상태 표시기 ── */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle num="05" title="상태 표시기" theme={t} />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{t.statusTokens.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.hex}
|
||||||
|
className={`${t.badgeRadius} border border-solid p-4 flex flex-row gap-3 items-center`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.bg,
|
||||||
|
borderColor: item.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-2.5 h-2.5 ${t.badgeRadius} shrink-0`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
...(item.glow ? { boxShadow: item.glow } : {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="font-korean text-[13px] font-bold flex-1"
|
||||||
|
style={{ color: item.color }}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] opacity-40" style={{ color: item.color }}>
|
||||||
|
{item.hex}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 행 3: 06 타이포그래피 스케일 (전체 열 span) ── */}
|
||||||
|
<div className="col-span-2 flex flex-col gap-4">
|
||||||
|
<SectionTitle
|
||||||
|
num="06"
|
||||||
|
title="타이포그래피 스케일"
|
||||||
|
theme={t}
|
||||||
|
rightNode={
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<span
|
||||||
|
className="rounded-sm py-0.5 px-2 font-korean text-[10px] font-bold"
|
||||||
|
style={{ backgroundColor: t.fontBadgePrimaryBg, color: t.fontBadgePrimaryText }}
|
||||||
|
>
|
||||||
|
Noto Sans KR
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="rounded-sm border border-solid py-0.5 px-2 font-korean text-[10px] font-bold"
|
||||||
|
style={{ borderColor: t.fontBadgeSecondaryBorder, color: t.fontBadgeSecondaryText }}
|
||||||
|
>
|
||||||
|
JetBrains Mono
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-solid overflow-hidden w-full"
|
||||||
|
style={{ backgroundColor: t.tableContainerBg, borderColor: t.cardBorder }}
|
||||||
|
>
|
||||||
|
{/* 헤더 행 */}
|
||||||
|
<div className="flex flex-row items-start" style={{ backgroundColor: t.tableHeaderBg }}>
|
||||||
|
{(['Size / Role', 'Sample String', 'Properties'] as const).map((col, i) => (
|
||||||
|
<div
|
||||||
|
key={col}
|
||||||
|
className="flex-1 py-4 px-8 border-b border-solid"
|
||||||
|
style={{ textAlign: i === 2 ? 'right' : 'left', borderColor: t.tableRowBorder }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] font-medium uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행 */}
|
||||||
|
{TYPO_ROWS.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.size}
|
||||||
|
className="flex flex-row items-center border-t border-solid"
|
||||||
|
style={{
|
||||||
|
borderColor: t.tableRowBorder,
|
||||||
|
backgroundColor: row.isData ? t.tableDataRowBg : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Size */}
|
||||||
|
<div className="flex-1 py-4 px-8">
|
||||||
|
<span className="font-mono text-[10px]" style={{ color: t.typoSizeText }}>{row.size}</span>
|
||||||
|
</div>
|
||||||
|
{/* Sample */}
|
||||||
|
<div className="flex-1 py-4 px-8">{row.sampleNode(t)}</div>
|
||||||
|
{/* Properties */}
|
||||||
|
<div className="flex-1 py-4 px-8 text-right" style={{ opacity: 0.5 }}>
|
||||||
|
<span className="font-mono text-[10px]" style={{ color: t.typoPropertiesText }}>{row.properties}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 행 4: 07 테두리 곡률 (전체 열 span) ── */}
|
||||||
|
<div className="col-span-2 flex flex-col gap-4">
|
||||||
|
<SectionTitle num="07" title="테두리 곡률" theme={t} />
|
||||||
|
<div className="grid grid-cols-2 gap-8">
|
||||||
|
{/* radius-sm */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="font-mono text-xs leading-4" style={{ color: t.textMuted }}>{t.radiusSmLabel}</span>
|
||||||
|
<div
|
||||||
|
className="rounded-md border border-solid p-6 h-32 flex flex-col justify-end"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.radiusCardBg,
|
||||||
|
borderColor: t.radiusCardBorder,
|
||||||
|
boxShadow: t.radiusCardShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-korean text-[10px] font-bold uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.textAccent }}
|
||||||
|
>
|
||||||
|
Small Elements
|
||||||
|
</span>
|
||||||
|
<p className="font-korean text-xs leading-[19.5px] mt-1" style={{ color: t.radiusDescText }}>
|
||||||
|
Applied to tactical buttons, search inputs, and micro-cards for a precise, sharp industrial feel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* radius-md */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="font-mono text-xs leading-4" style={{ color: t.textMuted }}>{t.radiusMdLabel}</span>
|
||||||
|
<div
|
||||||
|
className="rounded-[10px] border border-solid p-6 h-32 flex flex-col justify-end"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.radiusCardBg,
|
||||||
|
borderColor: t.radiusCardBorder,
|
||||||
|
boxShadow: t.radiusCardShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-korean text-[10px] font-bold uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.textAccent }}
|
||||||
|
>
|
||||||
|
Structural Panels
|
||||||
|
</span>
|
||||||
|
<p className="font-korean text-xs leading-[19.5px] mt-1" style={{ color: t.radiusDescText }}>
|
||||||
|
Applied to telemetry cards, floating modals, and primary operational panels to soften high-density data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 푸터 ── */}
|
||||||
|
<div
|
||||||
|
className="w-full border-t border-solid pt-12 flex flex-row items-center justify-between"
|
||||||
|
style={{ borderColor: t.footerBorder }}
|
||||||
|
>
|
||||||
|
{/* 좌측 */}
|
||||||
|
<div className="flex flex-row gap-8">
|
||||||
|
{['Precision Engineering', 'Safety Compliant', 'Optimized v8.42'].map((label) => (
|
||||||
|
<span
|
||||||
|
key={label}
|
||||||
|
className="font-mono text-[10px] uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.footerText }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 우측 */}
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.footerText }}
|
||||||
|
>
|
||||||
|
Generated for Terminal:
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] font-medium uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.footerAccent }}
|
||||||
|
>
|
||||||
|
1440x900_PR_MKT
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DesignContent;
|
||||||
96
frontend/src/pages/design/DesignHeader.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import type { DesignTheme } from './designTheme';
|
||||||
|
|
||||||
|
export type DesignTab = 'foundations' | 'components';
|
||||||
|
|
||||||
|
interface DesignHeaderProps {
|
||||||
|
activeTab: DesignTab;
|
||||||
|
onTabChange: (tab: DesignTab) => void;
|
||||||
|
theme: DesignTheme;
|
||||||
|
onThemeToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: { label: string; id: DesignTab }[] = [
|
||||||
|
{ label: 'Foundations', id: 'foundations' },
|
||||||
|
{ label: 'Components', id: 'components' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DesignHeader = ({ activeTab, onTabChange, theme, onThemeToggle }: DesignHeaderProps) => {
|
||||||
|
const isDark = theme.mode === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className="h-16 px-8 flex flex-row items-center justify-between shrink-0 border-b border-solid"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.headerBg,
|
||||||
|
borderColor: theme.headerBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 좌측: 로고 + 버전 뱃지 */}
|
||||||
|
<div className="flex flex-row items-center gap-3">
|
||||||
|
<span
|
||||||
|
className="font-sans text-2xl leading-8 font-bold"
|
||||||
|
style={{ letterSpacing: '2.4px', color: theme.textAccent }}
|
||||||
|
>
|
||||||
|
WING-OPS
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="rounded-sm border border-solid py-1 px-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)',
|
||||||
|
borderColor: isDark ? 'rgba(255,255,255,0.10)' : '#e2e8f0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-sans text-[10px] leading-[15px] uppercase"
|
||||||
|
style={{ letterSpacing: '2px', color: theme.textMuted }}
|
||||||
|
>
|
||||||
|
Design System v1.0
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중앙: 탭 네비게이션 */}
|
||||||
|
<nav className="flex flex-row gap-8">
|
||||||
|
{TABS.map(({ label, id }) => {
|
||||||
|
const isActive = activeTab === id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onTabChange(id)}
|
||||||
|
className={`font-sans text-base leading-6 bg-transparent cursor-pointer ${
|
||||||
|
isActive ? 'font-bold border-b-2 pb-1' : 'font-medium border-none'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
letterSpacing: '-0.4px',
|
||||||
|
color: isActive ? theme.textAccent : theme.textMuted,
|
||||||
|
borderColor: isActive ? theme.textAccent : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 우측: 테마 토글 */}
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onThemeToggle}
|
||||||
|
className="w-10 h-10 rounded-md border border-solid flex items-center justify-center cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)',
|
||||||
|
borderColor: isDark ? 'rgba(255,255,255,0.10)' : '#e2e8f0',
|
||||||
|
}}
|
||||||
|
title={isDark ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{isDark ? '☀️' : '🌙'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DesignHeader;
|
||||||
66
frontend/src/pages/design/DesignPage.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { DesignHeader } from './DesignHeader';
|
||||||
|
import type { DesignTab } from './DesignHeader';
|
||||||
|
import { DesignSidebar } from './DesignSidebar';
|
||||||
|
import type { MenuItemId } from './DesignSidebar';
|
||||||
|
|
||||||
|
import { ComponentsContent } from './ComponentsContent';
|
||||||
|
import { ColorPaletteContent } from './ColorPaletteContent';
|
||||||
|
import { TypographyContent } from './TypographyContent';
|
||||||
|
import { RadiusContent } from './RadiusContent';
|
||||||
|
import { LayoutContent } from './LayoutContent';
|
||||||
|
import { getTheme } from './designTheme';
|
||||||
|
import type { ThemeMode } from './designTheme';
|
||||||
|
|
||||||
|
const FIRST_ITEM: Record<DesignTab, MenuItemId> = {
|
||||||
|
foundations: 'color',
|
||||||
|
components: 'buttons',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DesignPage = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<DesignTab>('foundations');
|
||||||
|
const [themeMode, setThemeMode] = useState<ThemeMode>('dark');
|
||||||
|
const [sidebarItem, setSidebarItem] = useState<MenuItemId>('color');
|
||||||
|
|
||||||
|
const theme = getTheme(themeMode);
|
||||||
|
|
||||||
|
const handleTabChange = (tab: DesignTab) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
setSidebarItem(FIRST_ITEM[tab]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (activeTab === 'foundations') {
|
||||||
|
switch (sidebarItem) {
|
||||||
|
case 'color':
|
||||||
|
return <ColorPaletteContent theme={theme} />;
|
||||||
|
case 'typography':
|
||||||
|
return <TypographyContent theme={theme} />;
|
||||||
|
case 'radius':
|
||||||
|
return <RadiusContent theme={theme} />;
|
||||||
|
case 'layout':
|
||||||
|
return <LayoutContent theme={theme} />;
|
||||||
|
default:
|
||||||
|
return <ColorPaletteContent theme={theme} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <ComponentsContent />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-screen w-screen overflow-hidden flex flex-col"
|
||||||
|
style={{ backgroundColor: theme.pageBg }}
|
||||||
|
>
|
||||||
|
<DesignHeader activeTab={activeTab} onTabChange={handleTabChange} theme={theme} onThemeToggle={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')} />
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<DesignSidebar theme={theme} activeTab={activeTab} activeItem={sidebarItem} onItemChange={setSidebarItem} />
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
{renderContent()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DesignPage;
|
||||||
107
frontend/src/pages/design/DesignSidebar.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import type { DesignTheme } from './designTheme';
|
||||||
|
import type { DesignTab } from './DesignHeader';
|
||||||
|
|
||||||
|
import wingColorPaletteIcon from '../../assets/icons/wing-color-palette.svg';
|
||||||
|
import wingElevationIcon from '../../assets/icons/wing-elevation.svg';
|
||||||
|
import wingFoundationsIcon from '../../assets/icons/wing-foundations.svg';
|
||||||
|
import wingLayoutGridIcon from '../../assets/icons/wing-layout-grid.svg';
|
||||||
|
import wingTypographyIcon from '../../assets/icons/wing-typography.svg';
|
||||||
|
|
||||||
|
export type FoundationsMenuItemId = 'color' | 'typography' | 'radius' | 'layout';
|
||||||
|
export type ComponentsMenuItemId = 'buttons' | 'text-inputs' | 'controls' | 'badge' | 'dialog' | 'tabs' | 'popup' | 'navigation';
|
||||||
|
export type MenuItemId = FoundationsMenuItemId | ComponentsMenuItemId;
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
id: MenuItemId;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOUNDATIONS_MENU: MenuItem[] = [
|
||||||
|
{ id: 'color', label: 'Color', icon: wingColorPaletteIcon },
|
||||||
|
{ id: 'typography', label: 'Typography', icon: wingTypographyIcon },
|
||||||
|
{ id: 'radius', label: 'Radius', icon: wingElevationIcon },
|
||||||
|
{ id: 'layout', label: 'Layout', icon: wingLayoutGridIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMPONENTS_MENU: MenuItem[] = [
|
||||||
|
{ id: 'buttons', label: 'Buttons', icon: wingFoundationsIcon },
|
||||||
|
{ id: 'text-inputs', label: 'Text Inputs', icon: wingFoundationsIcon },
|
||||||
|
{ id: 'controls', label: 'Controls', icon: wingFoundationsIcon },
|
||||||
|
{ id: 'badge', label: 'Badge', icon: wingColorPaletteIcon },
|
||||||
|
{ id: 'dialog', label: 'Dialog', icon: wingLayoutGridIcon },
|
||||||
|
{ id: 'tabs', label: 'Tabs', icon: wingLayoutGridIcon },
|
||||||
|
{ id: 'popup', label: 'Popup', icon: wingElevationIcon },
|
||||||
|
{ id: 'navigation', label: 'Navigation', icon: wingTypographyIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SIDEBAR_CONFIG: Record<DesignTab, { title: string; subtitle: string; menu: MenuItem[] }> = {
|
||||||
|
foundations: { title: 'FOUNDATIONS', subtitle: 'Design Token System', menu: FOUNDATIONS_MENU },
|
||||||
|
components: { title: 'COMPONENTS', subtitle: 'UI Component Catalog', menu: COMPONENTS_MENU },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DesignSidebarProps {
|
||||||
|
theme: DesignTheme;
|
||||||
|
activeTab: DesignTab;
|
||||||
|
activeItem: MenuItemId;
|
||||||
|
onItemChange: (id: MenuItemId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesignSidebar({ theme, activeTab, activeItem, onItemChange }: DesignSidebarProps) {
|
||||||
|
const isDark = theme.mode === 'dark';
|
||||||
|
const { menu } = SIDEBAR_CONFIG[activeTab];
|
||||||
|
|
||||||
|
const renderMenuItem = (item: MenuItem) => {
|
||||||
|
const isActive = activeItem === item.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onItemChange(item.id)}
|
||||||
|
className="py-3 px-6 flex flex-row gap-3 items-center w-full text-left transition-colors duration-150 border-l-4"
|
||||||
|
style={{
|
||||||
|
borderColor: isActive ? theme.textAccent : 'transparent',
|
||||||
|
color: isActive ? theme.textAccent : theme.textMuted,
|
||||||
|
background: isActive
|
||||||
|
? `linear-gradient(90deg, ${isDark ? 'rgba(76,215,246,0.1)' : 'rgba(6,182,212,0.1)'} 0%, transparent 100%)`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={item.icon} alt={item.label} className="w-5 h-5 shrink-0" />
|
||||||
|
<span className="font-sans text-base leading-6">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="w-64 h-full border-r border-solid pt-20 flex flex-col"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.sidebarBg,
|
||||||
|
borderColor: theme.sidebarBorder,
|
||||||
|
boxShadow: `0px 25px 50px -12px ${theme.sidebarShadow}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 타이틀 영역 */}
|
||||||
|
{/* <div className="px-6 pb-8">
|
||||||
|
<p
|
||||||
|
className="font-sans text-xl leading-7 font-bold"
|
||||||
|
style={{ letterSpacing: '-1px', color: theme.textPrimary }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="font-sans text-[10px] leading-[15px] font-normal uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: theme.textAccent }}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* 메뉴 네비게이션 */}
|
||||||
|
<nav className="flex-1 flex flex-col">{menu.map(renderMenuItem)}</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DesignSidebar;
|
||||||
567
frontend/src/pages/design/LayoutContent.tsx
Normal file
@ -0,0 +1,567 @@
|
|||||||
|
// LayoutContent.tsx — WING-OPS Layout 콘텐츠 (다크/라이트 테마 지원)
|
||||||
|
|
||||||
|
import type { DesignTheme } from './designTheme';
|
||||||
|
|
||||||
|
// ---------- 데이터 타입 ----------
|
||||||
|
|
||||||
|
interface Breakpoint {
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
minWidth: string;
|
||||||
|
inUse: boolean;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceSpec {
|
||||||
|
device: string;
|
||||||
|
width: string;
|
||||||
|
columns: string;
|
||||||
|
gutter: string;
|
||||||
|
margin: string;
|
||||||
|
supported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpacingToken {
|
||||||
|
className: string;
|
||||||
|
rem: string;
|
||||||
|
px: string;
|
||||||
|
usage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZLayer {
|
||||||
|
name: string;
|
||||||
|
zIndex: number;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShellClass {
|
||||||
|
className: string;
|
||||||
|
role: string;
|
||||||
|
styles: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Breakpoints 데이터 ----------
|
||||||
|
|
||||||
|
const BREAKPOINTS: Breakpoint[] = [
|
||||||
|
{ name: 'sm', prefix: 'sm:', minWidth: '640px', inUse: false },
|
||||||
|
{ name: 'md', prefix: 'md:', minWidth: '768px', inUse: false },
|
||||||
|
{ name: 'lg', prefix: 'lg:', minWidth: '1024px', inUse: false },
|
||||||
|
{ name: 'xl', prefix: 'xl:', minWidth: '1280px', inUse: true, note: 'TopBar 탭 레이블/아이콘 토글' },
|
||||||
|
{ name: '2xl', prefix: '2xl:', minWidth: '1536px', inUse: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Device Specs ----------
|
||||||
|
|
||||||
|
const DEVICE_SPECS: DeviceSpec[] = [
|
||||||
|
{ device: 'Desktop', width: '≥ 1280px', columns: 'flex 기반 가변', gutter: 'gap-2 ~ gap-6', margin: 'px-5 ~ px-8', supported: true },
|
||||||
|
{ device: 'Tablet', width: '768px ~ 1279px', columns: '-', gutter: '-', margin: '-', supported: false },
|
||||||
|
{ device: 'Mobile', width: '< 768px', columns: '-', gutter: '-', margin: '-', supported: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Spacing Scale ----------
|
||||||
|
|
||||||
|
const SPACING_TOKENS: SpacingToken[] = [
|
||||||
|
{ className: '0.5', rem: '0.125rem', px: '2px', usage: '미세 간격' },
|
||||||
|
{ className: '1', rem: '0.25rem', px: '4px', usage: '최소 간격 (gap-1)' },
|
||||||
|
{ className: '1.5', rem: '0.375rem', px: '6px', usage: '컴팩트 간격 (gap-1.5)' },
|
||||||
|
{ className: '2', rem: '0.5rem', px: '8px', usage: '기본 간격 (gap-2, p-2)' },
|
||||||
|
{ className: '2.5', rem: '0.625rem', px: '10px', usage: '중간 간격' },
|
||||||
|
{ className: '3', rem: '0.75rem', px: '12px', usage: '표준 간격 (gap-3, p-3)' },
|
||||||
|
{ className: '4', rem: '1rem', px: '16px', usage: '넓은 간격 (p-4, gap-4)' },
|
||||||
|
{ className: '5', rem: '1.25rem', px: '20px', usage: '패널 패딩 (px-5, py-5)' },
|
||||||
|
{ className: '6', rem: '1.5rem', px: '24px', usage: '섹션 간격 (gap-6, p-6)' },
|
||||||
|
{ className: '8', rem: '2rem', px: '32px', usage: '큰 간격 (px-8, gap-8)' },
|
||||||
|
{ className: '16', rem: '4rem', px: '64px', usage: '최대 간격' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Z-Index Layers ----------
|
||||||
|
|
||||||
|
const Z_LAYERS: ZLayer[] = [
|
||||||
|
{ name: 'Tooltip', zIndex: 60, description: '툴팁, 드롭다운 메뉴', color: '#a855f7' },
|
||||||
|
{ name: 'Popup', zIndex: 50, description: '팝업, 지도 오버레이', color: '#f97316' },
|
||||||
|
{ name: 'Modal', zIndex: 40, description: '모달 다이얼로그, 백드롭', color: '#ef4444' },
|
||||||
|
{ name: 'TopBar', zIndex: 30, description: '상단 네비게이션 바', color: '#3b82f6' },
|
||||||
|
{ name: 'Sidebar', zIndex: 20, description: '사이드바, 패널', color: '#06b6d4' },
|
||||||
|
{ name: 'Content', zIndex: 10, description: '메인 콘텐츠 영역', color: '#22c55e' },
|
||||||
|
{ name: 'Base', zIndex: 0, description: '기본 레이어, 배경', color: '#8690a6' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- App Shell Classes ----------
|
||||||
|
|
||||||
|
const SHELL_CLASSES: ShellClass[] = [
|
||||||
|
{ className: '.wing-panel', role: '탭 콘텐츠 패널', styles: 'flex flex-col h-full overflow-hidden' },
|
||||||
|
{ className: '.wing-panel-scroll', role: '패널 내 스크롤 영역', styles: 'flex-1 overflow-y-auto' },
|
||||||
|
{ className: '.wing-header-bar', role: '패널 헤더', styles: 'flex items-center justify-between shrink-0 px-5 border-b' },
|
||||||
|
{ className: '.wing-sidebar', role: '사이드바', styles: 'flex flex-col border-r border-border' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Props ----------
|
||||||
|
|
||||||
|
interface LayoutContentProps {
|
||||||
|
theme: DesignTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 컴포넌트 ----------
|
||||||
|
|
||||||
|
export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
||||||
|
const t = theme;
|
||||||
|
const isDark = t.mode === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-24 px-8 pb-16 flex flex-col gap-16 items-start justify-start max-w-[1440px]">
|
||||||
|
|
||||||
|
{/* ── 섹션 1: 헤더 + 개요 ── */}
|
||||||
|
<div
|
||||||
|
className="w-full border-b border-solid pb-8 flex flex-col gap-4"
|
||||||
|
style={{ borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0' }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1
|
||||||
|
className="font-sans text-3xl leading-9 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
Layout
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="font-korean text-sm leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
WING-OPS는 데스크톱 전용 고정 뷰포트 애플리케이션입니다. 화면 전체를 채우는 고정 레이아웃(100vh)으로, flex 기반의 패널 구조를 사용합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
className="flex flex-col gap-1 list-disc list-inside font-korean text-sm"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
<li>뷰포트 고정: <code style={{ color: t.textAccent, fontSize: '12px' }}>body {'{'} height: 100vh; overflow: hidden {'}'}</code></li>
|
||||||
|
<li>레이아웃 수단: flex (2,243회) > grid (~120회) — flex가 주요 레이아웃 수단</li>
|
||||||
|
<li>Tailwind CSS 기본 breakpoints/spacing을 사용하며, 커스텀 오버라이드 없음</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 섹션 2: Breakpoints ── */}
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2
|
||||||
|
className="font-sans text-2xl leading-8 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
Breakpoints
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="font-korean text-sm leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
Tailwind CSS 기본 breakpoints를 사용합니다. 현재 프로젝트는 데스크톱 전용으로, <code style={{ color: t.textAccent, fontSize: '12px' }}>xl:</code> 접두사만 제한적으로 사용합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-solid overflow-hidden w-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.tableContainerBg,
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
boxShadow: t.cardShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="grid"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '100px 100px 120px 100px 1fr',
|
||||||
|
backgroundColor: t.tableHeaderBg,
|
||||||
|
borderBottom: `1px solid ${t.tableRowBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(['Name', 'Prefix', 'Min Width', 'Status', 'Note'] as const).map((col) => (
|
||||||
|
<div key={col} className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] font-medium uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{BREAKPOINTS.map((bp, idx) => (
|
||||||
|
<div
|
||||||
|
key={bp.name}
|
||||||
|
className="grid items-center"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '100px 100px 120px 100px 1fr',
|
||||||
|
borderTop: idx === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span className="font-mono text-[11px]" style={{ color: t.textPrimary }}>{bp.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className="font-mono rounded border border-solid px-2 py-0.5"
|
||||||
|
style={{ fontSize: '11px', color: t.textAccent, backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||||
|
>
|
||||||
|
{bp.prefix}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span className="font-mono text-[11px]" style={{ color: t.textPrimary }}>{bp.minWidth}</span>
|
||||||
|
</div>
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[9px] rounded px-1.5 py-0.5"
|
||||||
|
style={{
|
||||||
|
color: bp.inUse ? (isDark ? '#22c55e' : '#047857') : (isDark ? '#8690a6' : '#94a3b8'),
|
||||||
|
backgroundColor: bp.inUse
|
||||||
|
? (isDark ? 'rgba(34,197,94,0.10)' : 'rgba(34,197,94,0.08)')
|
||||||
|
: (isDark ? 'rgba(134,144,166,0.10)' : 'rgba(148,163,184,0.08)'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bp.inUse ? '사용 중' : '미사용'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
|
||||||
|
{bp.note || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 섹션 3: Device Grid & Spacing ── */}
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2
|
||||||
|
className="font-sans text-2xl leading-8 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
Device Grid & Spacing
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="font-korean text-sm leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
디바이스별 그리드 구성과 여백 패턴입니다. 현재 Desktop만 지원합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{DEVICE_SPECS.map((spec) => (
|
||||||
|
<div
|
||||||
|
key={spec.device}
|
||||||
|
className="rounded-lg border border-solid px-5 py-5 flex flex-col gap-4"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.cardBg,
|
||||||
|
borderColor: spec.supported ? t.cardBorder : (isDark ? 'rgba(66,71,84,0.10)' : '#f1f5f9'),
|
||||||
|
boxShadow: t.cardShadow,
|
||||||
|
opacity: spec.supported ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-center justify-between">
|
||||||
|
<span
|
||||||
|
className="font-sans text-lg font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
{spec.device}
|
||||||
|
</span>
|
||||||
|
{!spec.supported && (
|
||||||
|
<span
|
||||||
|
className="font-mono text-[9px] rounded px-1.5 py-0.5"
|
||||||
|
style={{
|
||||||
|
color: isDark ? '#f97316' : '#c2410c',
|
||||||
|
backgroundColor: isDark ? 'rgba(249,115,22,0.10)' : 'rgba(249,115,22,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
미지원
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{[
|
||||||
|
{ label: 'Width', value: spec.width },
|
||||||
|
{ label: 'Columns', value: spec.columns },
|
||||||
|
{ label: 'Gutter', value: spec.gutter },
|
||||||
|
{ label: 'Margin', value: spec.margin },
|
||||||
|
].map((row) => (
|
||||||
|
<div key={row.label} className="flex flex-row justify-between items-center">
|
||||||
|
<span className="font-mono text-[10px] uppercase" style={{ letterSpacing: '0.5px', color: t.textMuted }}>
|
||||||
|
{row.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[11px]" style={{ color: t.textPrimary }}>
|
||||||
|
{row.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 섹션 4: Spacing Scale ── */}
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2
|
||||||
|
className="font-sans text-2xl leading-8 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
Spacing Scale
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="font-korean text-sm leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
Tailwind CSS 기본 spacing 스케일입니다. gap, padding, margin에 동일하게 적용됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-solid overflow-hidden w-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.tableContainerBg,
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
boxShadow: t.cardShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="grid"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '100px 120px 80px 1fr 200px',
|
||||||
|
backgroundColor: t.tableHeaderBg,
|
||||||
|
borderBottom: `1px solid ${t.tableRowBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(['Scale', 'REM', 'PX', 'Preview', '용도'] as const).map((col) => (
|
||||||
|
<div key={col} className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] font-medium uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{SPACING_TOKENS.map((token, idx) => (
|
||||||
|
<div
|
||||||
|
key={token.className}
|
||||||
|
className="grid items-center"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '100px 120px 80px 1fr 200px',
|
||||||
|
borderTop: idx === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className="font-mono rounded border border-solid px-2 py-0.5"
|
||||||
|
style={{ fontSize: '11px', color: t.textAccent, backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||||
|
>
|
||||||
|
{token.className}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span className="font-mono text-[11px]" style={{ color: t.textSecondary }}>{token.rem}</span>
|
||||||
|
</div>
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span className="font-mono text-[11px]" style={{ color: t.textPrimary }}>{token.px}</span>
|
||||||
|
</div>
|
||||||
|
<div className="py-3 px-4 flex items-center">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: token.px,
|
||||||
|
height: '12px',
|
||||||
|
backgroundColor: isDark ? 'rgba(6,182,212,0.30)' : 'rgba(6,182,212,0.25)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
minWidth: '2px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>{token.usage}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 섹션 5: Z-Index Layers ── */}
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2
|
||||||
|
className="font-sans text-2xl leading-8 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
Z-Index Layers
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="font-korean text-sm leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
UI 요소의 레이어 스택 순서입니다. 높은 z-index가 위에 표시됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0" style={{ maxWidth: '600px' }}>
|
||||||
|
{Z_LAYERS.map((layer, idx) => (
|
||||||
|
<div
|
||||||
|
key={layer.name}
|
||||||
|
className="flex flex-row items-stretch"
|
||||||
|
style={{ marginLeft: `${idx * 16}px` }}
|
||||||
|
>
|
||||||
|
{/* z-index 라벨 */}
|
||||||
|
<div className="w-12 shrink-0 flex items-center justify-end pr-3">
|
||||||
|
<span className="font-mono text-[10px]" style={{ color: t.textMuted }}>
|
||||||
|
{layer.zIndex}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 레이어 바 */}
|
||||||
|
<div
|
||||||
|
className="flex-1 flex flex-row items-center gap-3 px-4 py-3 border border-solid"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? `${layer.color}10` : `${layer.color}08`,
|
||||||
|
borderColor: isDark ? `${layer.color}30` : `${layer.color}25`,
|
||||||
|
borderRadius: '6px',
|
||||||
|
marginBottom: '-1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: layer.color }}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-xs font-bold" style={{ color: layer.color }}>
|
||||||
|
{layer.name}
|
||||||
|
</span>
|
||||||
|
<span className="font-korean text-[11px]" style={{ color: t.textSecondary }}>
|
||||||
|
{layer.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 섹션 6: App Shell 구조 ── */}
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2
|
||||||
|
className="font-sans text-2xl leading-8 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
App Shell 구조
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="font-korean text-sm leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
WING-OPS 애플리케이션의 기본 레이아웃 구조입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 레이아웃 다이어그램 */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-solid overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? '#0a0e1a' : '#f8fafc',
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
boxShadow: t.cardShadow,
|
||||||
|
maxWidth: '700px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* TopBar */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 border-b border-solid"
|
||||||
|
style={{
|
||||||
|
height: '36px',
|
||||||
|
backgroundColor: isDark ? 'rgba(6,182,212,0.08)' : 'rgba(6,182,212,0.06)',
|
||||||
|
borderColor: isDark ? 'rgba(6,182,212,0.20)' : 'rgba(6,182,212,0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[10px] font-bold" style={{ color: '#06b6d4' }}>TopBar</span>
|
||||||
|
<span className="font-mono text-[9px]" style={{ color: t.textMuted }}>h-[52px] / shrink-0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SubMenuBar */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 border-b border-solid"
|
||||||
|
style={{
|
||||||
|
height: '24px',
|
||||||
|
backgroundColor: isDark ? 'rgba(59,130,246,0.06)' : 'rgba(59,130,246,0.04)',
|
||||||
|
borderColor: isDark ? 'rgba(59,130,246,0.15)' : 'rgba(59,130,246,0.10)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[10px] font-bold" style={{ color: '#3b82f6' }}>SubMenuBar</span>
|
||||||
|
<span className="font-mono text-[9px]" style={{ color: t.textMuted }}>shrink-0 / 조건부 렌더</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="flex flex-row" style={{ height: '200px' }}>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center border-r border-solid"
|
||||||
|
style={{
|
||||||
|
width: '120px',
|
||||||
|
backgroundColor: isDark ? 'rgba(168,85,247,0.06)' : 'rgba(168,85,247,0.04)',
|
||||||
|
borderColor: isDark ? 'rgba(168,85,247,0.15)' : 'rgba(168,85,247,0.10)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[10px] font-bold" style={{ color: '#a855f7' }}>Sidebar</span>
|
||||||
|
<span className="font-mono text-[9px] mt-1" style={{ color: t.textMuted }}>가변 너비</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div
|
||||||
|
className="flex-1 flex flex-col items-center justify-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? 'rgba(34,197,94,0.04)' : 'rgba(34,197,94,0.03)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[10px] font-bold" style={{ color: '#22c55e' }}>Content</span>
|
||||||
|
<span className="font-mono text-[9px] mt-1" style={{ color: t.textMuted }}>flex-1 / overflow-y-auto</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* wing.css 레이아웃 클래스 */}
|
||||||
|
<div className="flex flex-col gap-3" style={{ maxWidth: '700px' }}>
|
||||||
|
<h3
|
||||||
|
className="font-korean text-sm font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
레이아웃 클래스
|
||||||
|
</h3>
|
||||||
|
{SHELL_CLASSES.map((cls) => (
|
||||||
|
<div
|
||||||
|
key={cls.className}
|
||||||
|
className="rounded-lg border border-solid px-4 py-3 flex flex-row items-center gap-4"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.cardBg,
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono rounded border border-solid px-2 py-0.5 shrink-0"
|
||||||
|
style={{ fontSize: '11px', color: t.textAccent, backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||||
|
>
|
||||||
|
{cls.className}
|
||||||
|
</span>
|
||||||
|
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
|
||||||
|
{cls.role}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] ml-auto" style={{ color: t.textMuted }}>
|
||||||
|
{cls.styles}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayoutContent;
|
||||||
279
frontend/src/pages/design/RadiusContent.tsx
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
// RadiusContent.tsx — WING-OPS Radius 콘텐츠 (다크/라이트 테마 지원)
|
||||||
|
|
||||||
|
import type { DesignTheme } from './designTheme';
|
||||||
|
|
||||||
|
// ---------- 데이터 타입 ----------
|
||||||
|
|
||||||
|
interface RadiusToken {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
px: number;
|
||||||
|
isCustom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComponentRadius {
|
||||||
|
className: string;
|
||||||
|
radius: string;
|
||||||
|
components: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Radius Token 데이터 ----------
|
||||||
|
|
||||||
|
const RADIUS_TOKENS: RadiusToken[] = [
|
||||||
|
{ name: 'rounded-sm', value: '6px', px: 6, isCustom: true },
|
||||||
|
{ name: 'rounded', value: '4px (0.25rem)', px: 4 },
|
||||||
|
{ name: 'rounded-md', value: '10px', px: 10, isCustom: true },
|
||||||
|
{ name: 'rounded-lg', value: '8px (0.5rem)', px: 8 },
|
||||||
|
{ name: 'rounded-xl', value: '12px (0.75rem)', px: 12 },
|
||||||
|
{ name: 'rounded-2xl', value: '16px (1rem)', px: 16 },
|
||||||
|
{ name: 'rounded-full', value: '9999px', px: 9999 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- 컴포넌트 매핑 데이터 ----------
|
||||||
|
|
||||||
|
const COMPONENT_RADIUS: ComponentRadius[] = [
|
||||||
|
{ className: 'rounded-sm (6px)', radius: '6px', components: ['.wing-btn', '.wing-input', '.wing-card-sm'] },
|
||||||
|
{ className: 'rounded (4px)', radius: '4px', components: ['.wing-badge'] },
|
||||||
|
{ className: 'rounded-md (10px)', radius: '10px', components: ['.wing-card', '.wing-section', '.wing-tab'] },
|
||||||
|
{ className: 'rounded-lg (8px)', radius: '8px', components: ['.wing-tab-bar'] },
|
||||||
|
{ className: 'rounded-xl (12px)', radius: '12px', components: ['.wing-modal'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Props ----------
|
||||||
|
|
||||||
|
interface RadiusContentProps {
|
||||||
|
theme: DesignTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 컴포넌트 ----------
|
||||||
|
|
||||||
|
export const RadiusContent = ({ theme }: RadiusContentProps) => {
|
||||||
|
const t = theme;
|
||||||
|
const isDark = t.mode === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-24 px-8 pb-16 flex flex-col gap-16 items-start justify-start max-w-[1440px]">
|
||||||
|
|
||||||
|
{/* ── 섹션 1: 헤더 ── */}
|
||||||
|
<div
|
||||||
|
className="w-full border-b border-solid pb-8 flex flex-col gap-4"
|
||||||
|
style={{ borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0' }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1
|
||||||
|
className="font-sans text-3xl leading-9 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
Radius
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="font-korean text-sm leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
Radius는 컴포넌트 혹은 콘텐츠 모서리의 둥글기를 표현합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="font-korean text-sm leading-6"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
Radius는 UI 구성 요소의 모서리를 둥글게 처리하여 부드럽고 현대적인 느낌을 제공합니다. 일관된 Radius 값은 브랜드 아이덴티티를 강화하고, 사용자 경험을 향상시키며, 다양한 화면과 컨텍스트에서 시각적 일관성을 유지하는 데 중요한 역할을 합니다.
|
||||||
|
</p>
|
||||||
|
<ul
|
||||||
|
className="flex flex-col gap-1 list-disc list-inside font-korean text-sm"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<code style={{ color: t.textAccent, fontSize: '12px' }}>rounded-sm</code>(6px)과{' '}
|
||||||
|
<code style={{ color: t.textAccent, fontSize: '12px' }}>rounded-md</code>(10px)는 Tailwind 기본값을 오버라이드한 프로젝트 커스텀 값입니다.
|
||||||
|
</li>
|
||||||
|
<li>나머지 토큰은 Tailwind CSS 기본 border-radius 스케일을 따릅니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 섹션 2: Radius Tokens 테이블 ── */}
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<h2
|
||||||
|
className="font-sans text-2xl leading-8 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
Radius Tokens
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-solid overflow-hidden w-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.tableContainerBg,
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
boxShadow: t.cardShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div
|
||||||
|
className="grid"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '200px 200px 1fr',
|
||||||
|
backgroundColor: t.tableHeaderBg,
|
||||||
|
borderBottom: `1px solid ${t.tableRowBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(['이름', '값', 'Preview'] as const).map((col) => (
|
||||||
|
<div key={col} className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] font-medium uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행 */}
|
||||||
|
{RADIUS_TOKENS.map((token, rowIdx) => (
|
||||||
|
<div
|
||||||
|
key={token.name}
|
||||||
|
className="grid items-center"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '200px 200px 1fr',
|
||||||
|
borderTop: rowIdx === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 이름 */}
|
||||||
|
<div className="py-4 px-4 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="font-mono rounded border border-solid px-2 py-0.5"
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
lineHeight: '17px',
|
||||||
|
color: t.textAccent,
|
||||||
|
backgroundColor: t.cardBg,
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{token.name}
|
||||||
|
</span>
|
||||||
|
{token.isCustom && (
|
||||||
|
<span
|
||||||
|
className="font-mono text-[9px] rounded px-1.5 py-0.5"
|
||||||
|
style={{
|
||||||
|
color: isDark ? '#f97316' : '#c2410c',
|
||||||
|
backgroundColor: isDark ? 'rgba(249,115,22,0.10)' : 'rgba(249,115,22,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
custom
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 값 */}
|
||||||
|
<div className="py-4 px-4">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[11px]"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
{token.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="py-4 px-4 flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '80px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: token.px >= 9999 ? '9999px' : `${token.px}px`,
|
||||||
|
backgroundColor: isDark ? 'rgba(6,182,212,0.15)' : 'rgba(6,182,212,0.12)',
|
||||||
|
border: `1.5px solid ${isDark ? 'rgba(6,182,212,0.40)' : 'rgba(6,182,212,0.50)'}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: token.px >= 9999 ? '9999px' : `${token.px}px`,
|
||||||
|
backgroundColor: isDark ? 'rgba(6,182,212,0.15)' : 'rgba(6,182,212,0.12)',
|
||||||
|
border: `1.5px solid ${isDark ? 'rgba(6,182,212,0.40)' : 'rgba(6,182,212,0.50)'}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 섹션 3: 컴포넌트 매핑 ── */}
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2
|
||||||
|
className="font-sans text-2xl leading-8 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
컴포넌트 매핑
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="font-korean text-sm leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
wing.css 컴포넌트 클래스에 적용된 Radius 토큰입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4" style={{ maxWidth: '800px' }}>
|
||||||
|
{COMPONENT_RADIUS.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.className}
|
||||||
|
className="rounded-lg border border-solid px-5 py-4 flex flex-row items-center gap-6"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.cardBg,
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
boxShadow: t.cardShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 미리보기 박스 */}
|
||||||
|
<div
|
||||||
|
className="shrink-0"
|
||||||
|
style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: item.radius,
|
||||||
|
backgroundColor: isDark ? 'rgba(6,182,212,0.12)' : 'rgba(6,182,212,0.10)',
|
||||||
|
border: `1.5px solid ${isDark ? 'rgba(6,182,212,0.30)' : 'rgba(6,182,212,0.40)'}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="flex flex-col gap-1.5 flex-1">
|
||||||
|
<span
|
||||||
|
className="font-mono text-xs font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
{item.className}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-row flex-wrap gap-2">
|
||||||
|
{item.components.map((comp) => (
|
||||||
|
<span
|
||||||
|
key={comp}
|
||||||
|
className="font-mono rounded border border-solid px-2 py-0.5"
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
lineHeight: '15px',
|
||||||
|
color: t.textAccent,
|
||||||
|
backgroundColor: t.cardBg,
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{comp}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RadiusContent;
|
||||||
462
frontend/src/pages/design/TypographyContent.tsx
Normal file
@ -0,0 +1,462 @@
|
|||||||
|
// TypographyContent.tsx — WING-OPS Typography 콘텐츠 (다크/라이트 테마 지원)
|
||||||
|
|
||||||
|
import type { DesignTheme } from './designTheme';
|
||||||
|
|
||||||
|
// ---------- 데이터 타입 ----------
|
||||||
|
|
||||||
|
interface FontFamily {
|
||||||
|
name: string;
|
||||||
|
className: string;
|
||||||
|
stack: string;
|
||||||
|
usage: string;
|
||||||
|
sampleText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypographyToken {
|
||||||
|
className: string;
|
||||||
|
size: string;
|
||||||
|
font: string;
|
||||||
|
weight: string;
|
||||||
|
usage: string;
|
||||||
|
sampleText: string;
|
||||||
|
sampleStyle: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Font Family 데이터 ----------
|
||||||
|
|
||||||
|
const FONT_FAMILIES: FontFamily[] = [
|
||||||
|
{
|
||||||
|
name: 'Noto Sans KR',
|
||||||
|
className: 'font-korean',
|
||||||
|
stack: "'Noto Sans KR', sans-serif",
|
||||||
|
usage: '기본 UI 텍스트, 레이블, 설명 등 한국어 콘텐츠 전반에 사용됩니다. 프로젝트에서 가장 많이 사용되는 폰트입니다.',
|
||||||
|
sampleText: '해양 방제 운영 지원 시스템 WING-OPS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'JetBrains Mono',
|
||||||
|
className: 'font-mono',
|
||||||
|
stack: "'JetBrains Mono', monospace",
|
||||||
|
usage: '좌표, 수치 데이터, 코드, 토큰 이름 등 고정폭이 필요한 콘텐츠에 사용됩니다.',
|
||||||
|
sampleText: '126.978° E, 37.566° N — #0a0e1a',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Outfit',
|
||||||
|
className: 'font-sans',
|
||||||
|
stack: "'Outfit', 'Noto Sans KR', sans-serif",
|
||||||
|
usage: '영문 헤딩과 브랜드 타이틀에 사용됩니다. body 기본 폰트 스택에 포함되어 있습니다.',
|
||||||
|
sampleText: 'WING-OPS Design System v1.0',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Typography Token 데이터 ----------
|
||||||
|
|
||||||
|
const TYPOGRAPHY_TOKENS: TypographyToken[] = [
|
||||||
|
{
|
||||||
|
className: '.wing-title',
|
||||||
|
size: '15px',
|
||||||
|
font: 'font-korean',
|
||||||
|
weight: 'Bold (700)',
|
||||||
|
usage: '패널 제목',
|
||||||
|
sampleText: '확산 예측 시뮬레이션',
|
||||||
|
sampleStyle: { fontSize: '15px', fontWeight: 700, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: '.wing-section-header',
|
||||||
|
size: '13px',
|
||||||
|
font: 'font-korean',
|
||||||
|
weight: 'Bold (700)',
|
||||||
|
usage: '섹션 헤더',
|
||||||
|
sampleText: '기본 정보 입력',
|
||||||
|
sampleStyle: { fontSize: '13px', fontWeight: 700, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: '.wing-label',
|
||||||
|
size: '11px',
|
||||||
|
font: 'font-korean',
|
||||||
|
weight: 'Semibold (600)',
|
||||||
|
usage: '필드 레이블',
|
||||||
|
sampleText: '유출량 (kL)',
|
||||||
|
sampleStyle: { fontSize: '11px', fontWeight: 600, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: '.wing-btn',
|
||||||
|
size: '11px',
|
||||||
|
font: 'font-korean',
|
||||||
|
weight: 'Semibold (600)',
|
||||||
|
usage: '버튼 텍스트',
|
||||||
|
sampleText: '시뮬레이션 실행',
|
||||||
|
sampleStyle: { fontSize: '11px', fontWeight: 600, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: '.wing-value',
|
||||||
|
size: '11px',
|
||||||
|
font: 'font-mono',
|
||||||
|
weight: 'Semibold (600)',
|
||||||
|
usage: '수치 / 데이터 값',
|
||||||
|
sampleText: '35.1284° N, 129.0598° E',
|
||||||
|
sampleStyle: { fontSize: '11px', fontWeight: 600, fontFamily: "'JetBrains Mono', monospace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: '.wing-input',
|
||||||
|
size: '11px',
|
||||||
|
font: 'font-korean',
|
||||||
|
weight: 'Normal (400)',
|
||||||
|
usage: '입력 필드',
|
||||||
|
sampleText: '서해 대산항 인근 해역',
|
||||||
|
sampleStyle: { fontSize: '11px', fontWeight: 400, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: '.wing-section-desc',
|
||||||
|
size: '10px',
|
||||||
|
font: 'font-korean',
|
||||||
|
weight: 'Normal (400)',
|
||||||
|
usage: '섹션 설명',
|
||||||
|
sampleText: '예측 결과는 기상 조건에 따라 달라질 수 있습니다.',
|
||||||
|
sampleStyle: { fontSize: '10px', fontWeight: 400, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: '.wing-subtitle',
|
||||||
|
size: '10px',
|
||||||
|
font: 'font-korean',
|
||||||
|
weight: 'Normal (400)',
|
||||||
|
usage: '보조 설명',
|
||||||
|
sampleText: '최근 업데이트: 2026-03-24 09:00 KST',
|
||||||
|
sampleStyle: { fontSize: '10px', fontWeight: 400, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: '.wing-meta',
|
||||||
|
size: '9px',
|
||||||
|
font: 'font-korean',
|
||||||
|
weight: 'Normal (400)',
|
||||||
|
usage: '메타 정보',
|
||||||
|
sampleText: 'v2.1 | 해양환경공단',
|
||||||
|
sampleStyle: { fontSize: '9px', fontWeight: 400, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: '.wing-badge',
|
||||||
|
size: '9px',
|
||||||
|
font: 'font-korean',
|
||||||
|
weight: 'Bold (700)',
|
||||||
|
usage: '뱃지 / 태그',
|
||||||
|
sampleText: '진행중',
|
||||||
|
sampleStyle: { fontSize: '9px', fontWeight: 700, fontFamily: "'Noto Sans KR', sans-serif" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Props ----------
|
||||||
|
|
||||||
|
interface TypographyContentProps {
|
||||||
|
theme: DesignTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 컴포넌트 ----------
|
||||||
|
|
||||||
|
export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
||||||
|
const t = theme;
|
||||||
|
const isDark = t.mode === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-24 px-8 pb-16 flex flex-col gap-16 items-start justify-start max-w-[1440px]">
|
||||||
|
|
||||||
|
{/* ── 섹션 1: 헤더 + 개요 ── */}
|
||||||
|
<div
|
||||||
|
className="w-full border-b border-solid pb-8 flex flex-col gap-6"
|
||||||
|
style={{ borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0' }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1
|
||||||
|
className="font-sans text-3xl leading-9 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
Typography
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="font-korean text-sm leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
WING-OPS 인터페이스에서 사용되는 타이포그래피 체계입니다. 폰트 패밀리, 크기, 두께를 토큰과 컴포넌트 클래스로 정의하여 시각적 계층 구조와 일관성을 유지합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3
|
||||||
|
className="font-korean text-sm font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
개요
|
||||||
|
</h3>
|
||||||
|
<ul
|
||||||
|
className="flex flex-col gap-1 list-disc list-inside font-korean text-sm"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
<li>폰트 크기, 폰트 두께, 폰트 패밀리를 각각 토큰으로 정의합니다.</li>
|
||||||
|
<li>컴포넌트 클래스(<code style={{ color: t.textAccent, fontSize: '12px' }}>.wing-*</code>)로 조합하여 일관된 텍스트 스타일을 적용합니다.</li>
|
||||||
|
<li>시스템 폰트를 기반으로 다양한 환경에서 일관된 사용자 경험을 보장합니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 섹션 2: 글꼴 (Font Family) ── */}
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2
|
||||||
|
className="font-sans text-2xl leading-8 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
글꼴
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="font-korean text-sm leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
사용자의 디바이스 환경을 고려하여, 시스템 폰트와 웹 폰트를 조합하여 사용합니다. 한국어 UI에 최적화된 폰트 스택으로 다양한 기기에서 일관된 가독성을 보장합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* body 기본 폰트 스택 코드 블록 */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-solid px-5 py-4 overflow-x-auto"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? '#0f1524' : '#f1f5f9',
|
||||||
|
borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
className="font-mono text-sm leading-6"
|
||||||
|
style={{ color: isDark ? '#b0b8cc' : '#475569' }}
|
||||||
|
>
|
||||||
|
<span style={{ color: t.textAccent }}>font-family</span>
|
||||||
|
{`: 'Outfit', 'Noto Sans KR', sans-serif;`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폰트 카드 3종 */}
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{FONT_FAMILIES.map((font) => (
|
||||||
|
<div
|
||||||
|
key={font.name}
|
||||||
|
className="rounded-lg border border-solid overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.cardBg,
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
boxShadow: t.cardShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 카드 헤더 */}
|
||||||
|
<div
|
||||||
|
className="flex flex-row items-center gap-4 px-5 py-4 border-b border-solid"
|
||||||
|
style={{ borderColor: isDark ? 'rgba(66,71,84,0.15)' : '#e2e8f0' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-sans text-lg font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
{font.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-mono text-[11px] rounded border border-solid px-2 py-0.5"
|
||||||
|
style={{
|
||||||
|
color: t.textAccent,
|
||||||
|
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
|
||||||
|
borderColor: isDark ? 'rgba(6,182,212,0.20)' : 'rgba(6,182,212,0.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{font.className}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 본문 */}
|
||||||
|
<div className="px-5 py-5 flex flex-col gap-4">
|
||||||
|
{/* 폰트 스택 */}
|
||||||
|
<div
|
||||||
|
className="font-mono text-xs leading-5 rounded px-3 py-2"
|
||||||
|
style={{
|
||||||
|
color: isDark ? '#8690a6' : '#64748b',
|
||||||
|
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.02)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{font.stack}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 용도 설명 */}
|
||||||
|
<p
|
||||||
|
className="font-korean text-xs leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
{font.usage}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 샘플 렌더 */}
|
||||||
|
<div className="flex flex-col gap-3 pt-2">
|
||||||
|
{/* Regular */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[9px] uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||||
|
>
|
||||||
|
Regular
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`${font.className} text-xl leading-7`}
|
||||||
|
style={{ color: t.textPrimary, fontWeight: 400 }}
|
||||||
|
>
|
||||||
|
{font.sampleText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Bold */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[9px] uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||||
|
>
|
||||||
|
Bold
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`${font.className} text-xl leading-7 font-bold`}
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
{font.sampleText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 섹션 3: 타이포그래피 토큰 ── */}
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2
|
||||||
|
className="font-sans text-2xl leading-8 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
타이포그래피 토큰
|
||||||
|
</h2>
|
||||||
|
<ul
|
||||||
|
className="flex flex-col gap-1 list-disc list-inside font-korean text-sm"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
<li>Tailwind @apply 기반 컴포넌트 클래스로 정의됩니다 (<code style={{ color: t.textAccent, fontSize: '12px' }}>wing.css</code>).</li>
|
||||||
|
<li>크기는 접근성을 위해 px 단위로 명시적으로 지정합니다.</li>
|
||||||
|
<li>실제 UI에서는 클래스명을 직접 사용하거나, 동일한 속성 조합으로 적용합니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 토큰 테이블 */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-solid overflow-hidden w-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.tableContainerBg,
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
boxShadow: t.cardShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div
|
||||||
|
className="grid"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '160px 70px 110px 130px 120px 1fr',
|
||||||
|
backgroundColor: t.tableHeaderBg,
|
||||||
|
borderBottom: `1px solid ${t.tableRowBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(['Class', 'Size', 'Font', 'Weight', '용도', 'Sample'] as const).map((col) => (
|
||||||
|
<div key={col} className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] font-medium uppercase"
|
||||||
|
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행 */}
|
||||||
|
{TYPOGRAPHY_TOKENS.map((token, rowIdx) => (
|
||||||
|
<div
|
||||||
|
key={token.className}
|
||||||
|
className="grid items-center"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '160px 70px 110px 130px 120px 1fr',
|
||||||
|
borderTop: rowIdx === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Class */}
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className="font-mono rounded border border-solid px-2 py-0.5"
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
lineHeight: '17px',
|
||||||
|
color: t.textAccent,
|
||||||
|
backgroundColor: t.cardBg,
|
||||||
|
borderColor: t.cardBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{token.className}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Size */}
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[11px]"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
|
{token.size}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Font */}
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[11px]"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
{token.font}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weight */}
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className="font-mono text-[11px]"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
{token.weight}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 용도 */}
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className="font-korean text-xs"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
|
{token.usage}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sample */}
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
...token.sampleStyle,
|
||||||
|
color: t.textPrimary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{token.sampleText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TypographyContent;
|
||||||
242
frontend/src/pages/design/components/ButtonCatalogSection.tsx
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import pdfFileIcon from '../../../assets/icons/wing-pdf-file.svg';
|
||||||
|
import pdfFileDisabledIcon from '../../../assets/icons/wing-pdf-file-disabled.svg';
|
||||||
|
|
||||||
|
interface ButtonRow {
|
||||||
|
label: string;
|
||||||
|
defaultBtn: React.ReactNode;
|
||||||
|
hoverBtn: React.ReactNode;
|
||||||
|
disabledBtn: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonRows: ButtonRow[] = [
|
||||||
|
{
|
||||||
|
label: '프라이머리 (그라디언트)',
|
||||||
|
defaultBtn: (
|
||||||
|
<div
|
||||||
|
className='rounded-md pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(120.41deg, rgba(6, 182, 212, 1) 0%, rgba(59, 130, 246, 1) 100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='text-white text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
실행
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
hoverBtn: (
|
||||||
|
<div
|
||||||
|
className='rounded-md pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(120.41deg, rgba(6, 182, 212, 1) 0%, rgba(59, 130, 246, 1) 100%)',
|
||||||
|
boxShadow: '0px 0px 12px 0px rgba(6, 182, 212, 0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='text-white text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
확인
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
disabledBtn: (
|
||||||
|
<div
|
||||||
|
className='bg-[#334155] rounded-md pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className='text-[#64748b] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
저장
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '세컨더리 (솔리드)',
|
||||||
|
defaultBtn: (
|
||||||
|
<div className='bg-[#1a2236] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
|
||||||
|
<div className='text-[#b0b8cc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
취소
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
hoverBtn: (
|
||||||
|
<div className='bg-[#1e2844] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
|
||||||
|
<div className='text-[#b0b8cc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
닫기
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
disabledBtn: (
|
||||||
|
<div className='bg-[rgba(26,34,54,0.50)] rounded-md border border-solid border-[rgba(30,42,66,0.30)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
|
||||||
|
<div className='text-[rgba(176,184,204,0.30)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
취소
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '아웃라인 (고스트)',
|
||||||
|
defaultBtn: (
|
||||||
|
<div className='rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
|
||||||
|
<div className='text-[#b0b8cc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
더보기
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
hoverBtn: (
|
||||||
|
<div className='bg-[#1e2844] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
|
||||||
|
<div className='text-[#b0b8cc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
필터
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
disabledBtn: (
|
||||||
|
<div className='rounded-md border border-solid border-[rgba(30,42,66,0.30)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
|
||||||
|
<div className='text-[rgba(176,184,204,0.30)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
더보기
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'PDF 액션',
|
||||||
|
defaultBtn: (
|
||||||
|
<div className='bg-[rgba(59,130,246,0.08)] rounded-md border border-solid border-[rgba(59,130,246,0.30)] pt-1.5 pr-3 pb-1.5 pl-3 flex flex-row gap-2 items-center justify-start shrink-0 relative'>
|
||||||
|
<img
|
||||||
|
className='shrink-0 relative overflow-visible'
|
||||||
|
src={pdfFileIcon}
|
||||||
|
alt='PDF 아이콘'
|
||||||
|
/>
|
||||||
|
<div className='text-[#3b82f6] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
PDF 다운로드
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
hoverBtn: (
|
||||||
|
<div
|
||||||
|
className='bg-[rgba(59,130,246,0.15)] rounded-md border border-solid border-[rgba(59,130,246,0.50)] pt-1.5 pr-3 pb-1.5 pl-3 flex flex-row gap-2 items-center justify-start shrink-0 relative'
|
||||||
|
style={{ boxShadow: '0px 0px 8px 0px rgba(59, 130, 246, 0.2)' }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className='shrink-0 relative overflow-visible'
|
||||||
|
src={pdfFileIcon}
|
||||||
|
alt='PDF 아이콘'
|
||||||
|
/>
|
||||||
|
<div className='text-[#3b82f6] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
PDF 다운로드
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
disabledBtn: (
|
||||||
|
<div className='bg-[rgba(59,130,246,0.04)] rounded-md border border-solid border-[rgba(59,130,246,0.10)] pt-1.5 pr-3 pb-1.5 pl-3 flex flex-row gap-2 items-center justify-start shrink-0 relative'>
|
||||||
|
<img
|
||||||
|
className='shrink-0 relative overflow-visible'
|
||||||
|
src={pdfFileDisabledIcon}
|
||||||
|
alt='PDF 아이콘 (비활성)'
|
||||||
|
/>
|
||||||
|
<div className='text-[rgba(59,130,246,0.40)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
PDF 다운로드
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '경고 (삭제)',
|
||||||
|
defaultBtn: (
|
||||||
|
<div className='bg-[rgba(239,68,68,0.10)] rounded-md border border-solid border-[rgba(239,68,68,0.30)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
|
||||||
|
<div className='text-[#ef4444] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
삭제
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
hoverBtn: (
|
||||||
|
<div
|
||||||
|
className='bg-[rgba(239,68,68,0.20)] rounded-md border border-solid border-[rgba(239,68,68,0.50)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'
|
||||||
|
style={{ boxShadow: '0px 0px 8px 0px rgba(239, 68, 68, 0.15)' }}
|
||||||
|
>
|
||||||
|
<div className='text-[#ef4444] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
삭제
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
disabledBtn: (
|
||||||
|
<div className='bg-[rgba(239,68,68,0.05)] rounded-md border border-solid border-[rgba(239,68,68,0.15)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative'>
|
||||||
|
<div className='text-[rgba(239,68,68,0.40)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center'>
|
||||||
|
초기화
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ButtonCatalogSection = () => {
|
||||||
|
return (
|
||||||
|
<div className='bg-[#1a2236] rounded-[10px] border border-solid border-[#1e2a42] flex flex-col gap-0 items-start justify-start overflow-hidden w-full'>
|
||||||
|
{/* 카드 헤더 */}
|
||||||
|
<div className='border-b border-solid border-[#1e2a42] pt-4 pr-6 pb-4 pl-6 flex flex-row gap-3 items-center justify-start self-stretch shrink-0 relative'>
|
||||||
|
<div className='bg-[#06b6d4] rounded-xl shrink-0 w-1 h-4 relative' />
|
||||||
|
<div
|
||||||
|
className='text-[#22d3ee] text-left font-korean text-xs leading-4 font-medium uppercase relative flex items-center justify-start'
|
||||||
|
style={{ letterSpacing: '1.2px' }}
|
||||||
|
>
|
||||||
|
제어 인터페이스: 버튼
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 본문 */}
|
||||||
|
<div className='p-6 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative overflow-hidden'>
|
||||||
|
<div className='flex flex-col items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
{/* 헤더 행 */}
|
||||||
|
<div className='border-b border-solid border-[#1e2a42] flex flex-row gap-0 items-start justify-center self-stretch shrink-0 relative'>
|
||||||
|
{['버튼 유형', '기본 상태', '호버 상태', '비활성 상태'].map((header) => (
|
||||||
|
<div
|
||||||
|
key={header}
|
||||||
|
className='pt-px pr-2 pb-[17.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='text-[#64748b] text-left font-korean text-xs font-medium uppercase relative flex items-center justify-start'
|
||||||
|
style={{ letterSpacing: '-0.55px' }}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 행 */}
|
||||||
|
<div
|
||||||
|
className='flex flex-col items-start justify-start self-stretch shrink-0 relative'
|
||||||
|
style={{ margin: '-1px 0 0 0' }}
|
||||||
|
>
|
||||||
|
{buttonRows.map((row, index) => (
|
||||||
|
<div
|
||||||
|
key={row.label}
|
||||||
|
className='border-t border-solid border-[rgba(30,42,66,0.50)] flex flex-row gap-0 items-start justify-center self-stretch shrink-0 relative'
|
||||||
|
style={index === 0 ? { borderTopColor: 'transparent' } : { margin: '-1px 0 0 0' }}
|
||||||
|
>
|
||||||
|
{/* 버튼 유형 레이블 */}
|
||||||
|
<div className='pt-[31.5px] pr-2 pb-[31.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative'>
|
||||||
|
<div className='text-[#bcc9cd] text-left font-korean text-xs font-medium relative flex items-center justify-start'>
|
||||||
|
{row.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본 상태 */}
|
||||||
|
<div className='pt-[24.5px] pr-2 pb-[24.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative'>
|
||||||
|
{row.defaultBtn}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 호버 상태 */}
|
||||||
|
<div className='pt-[24.5px] pr-2 pb-[24.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative'>
|
||||||
|
{row.hoverBtn}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비활성 상태 */}
|
||||||
|
<div className='pt-[24.5px] pr-2 pb-[24.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative'>
|
||||||
|
{row.disabledBtn}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
157
frontend/src/pages/design/components/CardSection.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import wingAnchorIcon from '../../../assets/icons/wing-anchor.svg';
|
||||||
|
import wingCargoIcon from '../../../assets/icons/wing-cargo.svg';
|
||||||
|
import wingAlertTriangleIcon from '../../../assets/icons/wing-alert-triangle.svg';
|
||||||
|
import wingChartBarIcon from '../../../assets/icons/wing-chart-bar.svg';
|
||||||
|
import wingWaveGraph from '../../../assets/icons/wing-wave-graph.svg';
|
||||||
|
|
||||||
|
interface LogisticsItem {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
progress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logisticsItems: LogisticsItem[] = [
|
||||||
|
{ icon: wingAnchorIcon, label: '화물 통관', progress: '진행률: 84%' },
|
||||||
|
{ icon: wingCargoIcon, label: '화물 통관', progress: '진행률: 84%' },
|
||||||
|
{ icon: wingAlertTriangleIcon, label: '화물 통관', progress: '진행률: 84%' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CardSection = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='grid gap-6 w-full'
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* col 3: 활성 물류 현황 카드 */}
|
||||||
|
<div
|
||||||
|
className='bg-[#1a2236] rounded-[10px] border border-[#1e2a42] p-6 flex flex-col gap-6 items-start justify-start relative'
|
||||||
|
style={{ gridColumn: '3 / span 1', gridRow: '1 / span 1' }}
|
||||||
|
>
|
||||||
|
{/* 카드 헤더 */}
|
||||||
|
<div className='border-l-2 border-[#06b6d4] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
<div
|
||||||
|
className='text-[#64748b] text-left font-korean text-[10px] leading-[15px] font-medium uppercase relative flex items-center justify-start'
|
||||||
|
style={{ letterSpacing: '1px' }}
|
||||||
|
>
|
||||||
|
활성 물류 현황
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 물류 아이템 목록 */}
|
||||||
|
<div className='flex flex-col gap-4 items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
{logisticsItems.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='flex flex-row gap-4 items-center justify-start self-stretch shrink-0 relative'
|
||||||
|
>
|
||||||
|
<div className='bg-[#1e293b] rounded-md flex flex-row gap-0 items-center justify-center shrink-0 w-10 h-10 relative'>
|
||||||
|
<img
|
||||||
|
className='shrink-0 relative overflow-visible'
|
||||||
|
src={item.icon}
|
||||||
|
alt={item.label}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative'>
|
||||||
|
<div className='flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
<div className='text-[#dfe2f3] text-left font-korean text-[11px] leading-[16.5px] font-medium relative flex items-center justify-start'>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
<div className='text-[#64748b] text-left font-sans text-[10px] leading-[15px] font-normal relative flex items-center justify-start'>
|
||||||
|
{item.progress}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대응팀 배치 버튼 */}
|
||||||
|
<div className='pt-2 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
<div
|
||||||
|
className='rounded-md pt-2 pb-2 flex flex-col gap-0 items-center justify-center self-stretch shrink-0 relative'
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(97.29deg, rgba(6, 182, 212, 1) 0%, rgba(59, 130, 246, 1) 100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='text-white text-center font-korean text-[11px] leading-[16.5px] font-medium relative flex items-center justify-center'>
|
||||||
|
대응팀 배치
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* col 1-2 span: 실시간 텔레메트리 카드 */}
|
||||||
|
<div
|
||||||
|
className='bg-[#1a2236] rounded-[10px] border border-[#1e2a42] p-6 flex flex-col items-start justify-between min-h-[240px] relative overflow-hidden'
|
||||||
|
style={{ gridColumn: '1 / span 2', gridRow: '1 / span 1' }}
|
||||||
|
>
|
||||||
|
{/* 배경 파형 (opacity 0.3) */}
|
||||||
|
<div
|
||||||
|
className='flex flex-col gap-0 items-start justify-center shrink-0 h-24 absolute right-px left-px bottom-[1.5px]'
|
||||||
|
style={{ opacity: 0.3 }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className='self-stretch shrink-0 h-24 relative overflow-visible'
|
||||||
|
src={wingWaveGraph}
|
||||||
|
alt='wave graph'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상단 콘텐츠 */}
|
||||||
|
<div className='flex flex-col gap-6 items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
{/* 제목 영역 */}
|
||||||
|
<div className='flex flex-row items-start justify-between self-stretch shrink-0 relative'>
|
||||||
|
<div className='flex flex-col gap-[4.5px] items-start justify-start shrink-0 relative'>
|
||||||
|
<div
|
||||||
|
className='text-[#22d3ee] text-left font-korean text-[10px] leading-[15px] font-medium uppercase relative flex items-center justify-start'
|
||||||
|
style={{ letterSpacing: '1px' }}
|
||||||
|
>
|
||||||
|
실시간 텔레메트리
|
||||||
|
</div>
|
||||||
|
<div className='text-[#dfe2f3] text-left font-korean text-2xl leading-8 font-medium relative flex items-center justify-start'>
|
||||||
|
선박 속도 오버레이
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
className='shrink-0 w-[13.5px] h-[13.5px] relative overflow-visible'
|
||||||
|
src={wingChartBarIcon}
|
||||||
|
alt='chart bar'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 속도 수치 */}
|
||||||
|
<div className='flex flex-row gap-2 justify-start self-stretch shrink-0 relative'>
|
||||||
|
<div className='text-white text-left font-sans font-bold text-4xl leading-10 relative flex items-center justify-start'>
|
||||||
|
24.8
|
||||||
|
</div>
|
||||||
|
<div className='text-[#64748b] text-left font-sans font-semibold text-sm leading-5 relative flex items-center justify-start'>
|
||||||
|
노트 (knots)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 뱃지 + 버튼 */}
|
||||||
|
<div className='flex flex-row items-center justify-between self-stretch shrink-0 relative'>
|
||||||
|
{/* 정상 가동중 뱃지 */}
|
||||||
|
<div className='bg-[rgba(34,197,94,0.10)] rounded-xl pt-0.5 pr-2 pb-0.5 pl-2 flex flex-col gap-0 items-start justify-start shrink-0 relative'>
|
||||||
|
<div className='text-[#22c55e] text-left font-korean text-[9px] leading-[13.5px] font-medium relative flex items-center justify-start'>
|
||||||
|
정상 가동중
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대응팀 배치 아웃라인 버튼 */}
|
||||||
|
<div className='rounded-md border border-[#1e2a42] pt-1 pr-3 pb-1 pl-3 flex flex-col gap-0 items-center justify-center shrink-0 relative'>
|
||||||
|
<div className='text-[#b0b8cc] text-center font-korean text-[10px] leading-[15px] font-medium relative flex items-center justify-center'>
|
||||||
|
대응팀 배치
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
193
frontend/src/pages/design/components/IconBadgeSection.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import wingCompGearIcon from '../../../assets/icons/wing-comp-gear.svg';
|
||||||
|
import wingCompSearchIcon from '../../../assets/icons/wing-comp-search.svg';
|
||||||
|
import wingCompCloseIcon from '../../../assets/icons/wing-comp-close.svg';
|
||||||
|
import wingCompMenuIcon from '../../../assets/icons/wing-comp-menu.svg';
|
||||||
|
|
||||||
|
interface IconButtonItem {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusBadge {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
bg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTag {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
dotColor: string;
|
||||||
|
bg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconButtons: IconButtonItem[] = [
|
||||||
|
{ icon: wingCompGearIcon, label: 'Settings' },
|
||||||
|
{ icon: wingCompSearchIcon, label: 'Search' },
|
||||||
|
{ icon: wingCompCloseIcon, label: 'Close' },
|
||||||
|
{ icon: wingCompMenuIcon, label: 'Menu' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusBadges: StatusBadge[] = [
|
||||||
|
{ label: '정상', color: '#22c55e', bg: 'rgba(34,197,94,0.10)' },
|
||||||
|
{ label: '주의', color: '#eab308', bg: 'rgba(234,179,8,0.10)' },
|
||||||
|
{ label: '위험', color: '#ef4444', bg: 'rgba(239,68,68,0.10)' },
|
||||||
|
{ label: '진행중', color: '#3b82f6', bg: 'rgba(59,130,246,0.10)' },
|
||||||
|
{ label: '완료', color: '#8690a6', bg: 'rgba(134,144,166,0.10)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const dataTags: DataTag[] = [
|
||||||
|
{ label: 'VESSEL_A', color: '#22c55e', dotColor: '#22c55e', bg: 'rgba(34,197,94,0.10)' },
|
||||||
|
{ label: 'PRIORITY_H', color: '#eab308', dotColor: '#eab308', bg: 'rgba(234,179,8,0.10)' },
|
||||||
|
{ label: 'CRITICAL_ERR', color: '#ef4444', dotColor: '#ef4444', bg: 'rgba(239,68,68,0.10)' },
|
||||||
|
{ label: 'ACTIVE_SYNC', color: '#3b82f6', dotColor: '#3b82f6', bg: 'rgba(59,130,246,0.10)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const IconBadgeSection = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='grid gap-8 w-full'
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 좌측 카드: 제어 인터페이스 — 아이콘 버튼 */}
|
||||||
|
<div
|
||||||
|
className='bg-[#1a2236] rounded-[10px] border border-[#1e2a42] flex flex-col gap-0 items-start justify-start relative overflow-hidden'
|
||||||
|
style={{ gridColumn: '1 / span 1', gridRow: '1 / span 1' }}
|
||||||
|
>
|
||||||
|
{/* 카드 헤더 */}
|
||||||
|
<div className='border-b border-[#1e2a42] pt-4 pr-6 pb-4 pl-6 flex flex-row gap-3 items-center justify-start self-stretch shrink-0 relative'>
|
||||||
|
<div className='bg-[#e89337] rounded-xl shrink-0 w-1 h-4 relative'></div>
|
||||||
|
<div className='flex flex-col gap-0 items-start justify-start shrink-0 relative'>
|
||||||
|
<div
|
||||||
|
className='text-[#22d3ee] text-left font-korean text-xs leading-4 font-medium uppercase relative flex items-center justify-start'
|
||||||
|
style={{ letterSpacing: '1.2px' }}
|
||||||
|
>
|
||||||
|
마이크로 컨트롤: 아이콘 버튼
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 아이콘 버튼 목록 */}
|
||||||
|
<div className='p-8 flex flex-row gap-6 items-start justify-evenly self-stretch shrink-0 relative'>
|
||||||
|
{iconButtons.map((btn) => (
|
||||||
|
<div
|
||||||
|
key={btn.label}
|
||||||
|
className='flex flex-col gap-3 items-center justify-start self-stretch shrink-0 relative'
|
||||||
|
>
|
||||||
|
<div className='bg-[#1a2236] rounded-md border border-[#1e2a42] flex flex-row gap-0 items-center justify-center shrink-0 w-9 h-9 relative'>
|
||||||
|
<img
|
||||||
|
className='shrink-0 relative overflow-visible'
|
||||||
|
src={btn.icon}
|
||||||
|
alt={btn.label}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-0 items-start justify-start shrink-0 relative'>
|
||||||
|
<div
|
||||||
|
className='text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start'
|
||||||
|
style={{ letterSpacing: '0.9px' }}
|
||||||
|
>
|
||||||
|
{btn.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 푸터 */}
|
||||||
|
<div className='bg-[rgba(15,23,42,0.30)] pt-4 pr-6 pb-4 pl-6 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
<div className='text-[#64748b] text-left font-sans text-[10px] leading-[15px] font-normal relative flex items-center justify-start'>
|
||||||
|
Standard dimensions: 36x36px with radius-md (6px)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 카드: 마이크로 컨트롤 — 뱃지 & 태그 */}
|
||||||
|
<div
|
||||||
|
className='bg-[#1a2236] rounded-[10px] border border-[#1e2a42] flex flex-col gap-0 items-start justify-start relative overflow-hidden'
|
||||||
|
style={{ gridColumn: '2 / span 1', gridRow: '1 / span 1' }}
|
||||||
|
>
|
||||||
|
{/* 카드 헤더 */}
|
||||||
|
<div className='border-b border-[#1e2a42] pt-4 pr-6 pb-4 pl-6 flex flex-row gap-3 items-center justify-start self-stretch shrink-0 relative'>
|
||||||
|
<div className='bg-[#93000a] rounded-xl shrink-0 w-1 h-4 relative'></div>
|
||||||
|
<div className='flex flex-col gap-0 items-start justify-start shrink-0 relative'>
|
||||||
|
<div
|
||||||
|
className='text-[#22d3ee] text-left font-korean text-xs leading-4 font-medium uppercase relative flex items-center justify-start'
|
||||||
|
style={{ letterSpacing: '1.2px' }}
|
||||||
|
>
|
||||||
|
마이크로 컨트롤: 아이콘 버튼
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 바디 */}
|
||||||
|
<div className='p-6 flex flex-col gap-8 items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
|
||||||
|
{/* Operational Status 섹션 */}
|
||||||
|
<div className='flex flex-col gap-4 items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
<div className='border-l-2 border-[#0e7490] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
<div
|
||||||
|
className='text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start'
|
||||||
|
style={{ letterSpacing: '1px' }}
|
||||||
|
>
|
||||||
|
Operational Status
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-row gap-3 items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
{statusBadges.map((badge) => (
|
||||||
|
<div
|
||||||
|
key={badge.label}
|
||||||
|
className='rounded-xl pt-0.5 pr-2 pb-0.5 pl-2 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'
|
||||||
|
style={{ backgroundColor: badge.bg }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='text-left font-korean text-[10px] leading-[15px] font-medium relative flex items-center justify-start'
|
||||||
|
style={{ color: badge.color }}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data Classification 섹션 */}
|
||||||
|
<div className='flex flex-col gap-4 items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
<div className='border-l-2 border-[#0e7490] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
<div
|
||||||
|
className='text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start'
|
||||||
|
style={{ letterSpacing: '1px' }}
|
||||||
|
>
|
||||||
|
Data Classification
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-row gap-4 items-start justify-start self-stretch shrink-0 relative'>
|
||||||
|
{dataTags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag.label}
|
||||||
|
className='rounded-xl pt-0.5 pr-2 pb-0.5 pl-2 flex flex-row gap-2 items-center justify-start self-stretch shrink-0 relative'
|
||||||
|
style={{ backgroundColor: tag.bg }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='rounded-xl shrink-0 w-1.5 h-1.5 relative'
|
||||||
|
style={{ backgroundColor: tag.dotColor }}
|
||||||
|
></div>
|
||||||
|
<div className='flex flex-col gap-0 items-start justify-start shrink-0 relative'>
|
||||||
|
<div
|
||||||
|
className='text-left font-sans font-bold text-[10px] leading-[15px] relative flex items-center justify-start'
|
||||||
|
style={{ color: tag.color }}
|
||||||
|
>
|
||||||
|
{tag.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
380
frontend/src/pages/design/designTheme.ts
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
// designTheme.ts — 디자인 시스템 페이지 다크/라이트 테마 정의
|
||||||
|
|
||||||
|
export type ThemeMode = 'dark' | 'light';
|
||||||
|
|
||||||
|
// ---------- 토큰 인터페이스 ----------
|
||||||
|
export interface BgToken {
|
||||||
|
bg: string;
|
||||||
|
token: string;
|
||||||
|
hex: string;
|
||||||
|
desc: string;
|
||||||
|
isHover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccentToken {
|
||||||
|
color: string;
|
||||||
|
name: string;
|
||||||
|
token: string;
|
||||||
|
badge: string;
|
||||||
|
glow: string;
|
||||||
|
badgeBg: string;
|
||||||
|
badgeBorder: string;
|
||||||
|
badgeText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusToken {
|
||||||
|
color: string;
|
||||||
|
bg: string;
|
||||||
|
border: string;
|
||||||
|
label: string;
|
||||||
|
hex: string;
|
||||||
|
glow?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BorderToken {
|
||||||
|
token: string;
|
||||||
|
hex: string;
|
||||||
|
border: string;
|
||||||
|
barBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextTokenItem {
|
||||||
|
token: string;
|
||||||
|
sampleText: string;
|
||||||
|
sampleClass: string;
|
||||||
|
desc: string;
|
||||||
|
descColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 테마 인터페이스 ----------
|
||||||
|
export interface DesignTheme {
|
||||||
|
mode: ThemeMode;
|
||||||
|
|
||||||
|
// 레이아웃
|
||||||
|
pageBg: string;
|
||||||
|
sidebarBg: string;
|
||||||
|
sidebarBorder: string;
|
||||||
|
sidebarShadow: string;
|
||||||
|
headerBg: string;
|
||||||
|
headerBorder: string;
|
||||||
|
|
||||||
|
// 텍스트
|
||||||
|
textPrimary: string;
|
||||||
|
textSecondary: string;
|
||||||
|
textMuted: string;
|
||||||
|
textAccent: string;
|
||||||
|
|
||||||
|
// 카드
|
||||||
|
cardBg: string;
|
||||||
|
cardBorder: string;
|
||||||
|
cardBorderHover: string;
|
||||||
|
cardShadow: string;
|
||||||
|
|
||||||
|
// 섹션
|
||||||
|
sectionTitle: string;
|
||||||
|
sectionSub: string;
|
||||||
|
sectionSubSpacing: string;
|
||||||
|
|
||||||
|
// 테이블
|
||||||
|
tableContainerBg: string;
|
||||||
|
tableHeaderBg: string;
|
||||||
|
tableRowBorder: string;
|
||||||
|
tableDataRowBg: string;
|
||||||
|
|
||||||
|
// 뱃지
|
||||||
|
badgeRadius: string;
|
||||||
|
statusBadgeBg: string;
|
||||||
|
statusBadgeBorder: string;
|
||||||
|
statusBadgeDot: string;
|
||||||
|
statusBadgeText: string;
|
||||||
|
systemActiveBg: string;
|
||||||
|
systemActiveBorder: string;
|
||||||
|
systemActiveShadow: string;
|
||||||
|
|
||||||
|
// 폰트 뱃지 (06 타이포그래피)
|
||||||
|
fontBadgePrimaryBg: string;
|
||||||
|
fontBadgePrimaryText: string;
|
||||||
|
fontBadgeSecondaryBorder: string;
|
||||||
|
fontBadgeSecondaryText: string;
|
||||||
|
|
||||||
|
// 타이포 샘플 텍스트
|
||||||
|
typoSampleText: string;
|
||||||
|
typoSizeText: string;
|
||||||
|
typoPropertiesText: string;
|
||||||
|
typoActionBg: string;
|
||||||
|
typoActionBorder: string;
|
||||||
|
typoActionText: string;
|
||||||
|
typoDataText: string;
|
||||||
|
typoCoordText: string;
|
||||||
|
|
||||||
|
// 02 테두리 색상
|
||||||
|
borderCardBg: string;
|
||||||
|
borderCardShadow: string;
|
||||||
|
|
||||||
|
// 03 텍스트 색상
|
||||||
|
textSectionBg: string;
|
||||||
|
textSectionBorder: string;
|
||||||
|
|
||||||
|
// 07 radius
|
||||||
|
radiusSmLabel: string;
|
||||||
|
radiusMdLabel: string;
|
||||||
|
radiusCardBg: string;
|
||||||
|
radiusCardBorder: string;
|
||||||
|
radiusCardShadow: string;
|
||||||
|
radiusDescText: string;
|
||||||
|
|
||||||
|
// 푸터
|
||||||
|
footerBorder: string;
|
||||||
|
footerText: string;
|
||||||
|
footerAccent: string;
|
||||||
|
|
||||||
|
// 01 배경색 카드 스와치 border
|
||||||
|
swatchBorder: string;
|
||||||
|
swatchBorderHover: string;
|
||||||
|
|
||||||
|
// 데이터 토큰
|
||||||
|
bgTokens: BgToken[];
|
||||||
|
accentTokens: AccentToken[];
|
||||||
|
statusTokens: StatusToken[];
|
||||||
|
borderTokens: BorderToken[];
|
||||||
|
textTokens: TextTokenItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- DARK 테마 ----------
|
||||||
|
export const DARK_THEME: DesignTheme = {
|
||||||
|
mode: 'dark',
|
||||||
|
|
||||||
|
pageBg: '#0a0e1a',
|
||||||
|
sidebarBg: '#171b28',
|
||||||
|
sidebarBorder: 'rgba(255,255,255,0.05)',
|
||||||
|
sidebarShadow: 'rgba(0,0,0,0.4)',
|
||||||
|
headerBg: '#0a0e1a',
|
||||||
|
headerBorder: 'rgba(255,255,255,0.05)',
|
||||||
|
|
||||||
|
textPrimary: '#dfe2f3',
|
||||||
|
textSecondary: '#c2c6d6',
|
||||||
|
textMuted: '#8c909f',
|
||||||
|
textAccent: '#4cd7f6',
|
||||||
|
|
||||||
|
cardBg: '#171b28',
|
||||||
|
cardBorder: 'rgba(66,71,84,0.10)',
|
||||||
|
cardBorderHover: 'rgba(66,71,84,0.20)',
|
||||||
|
cardShadow: 'none',
|
||||||
|
|
||||||
|
sectionTitle: '#adc6ff',
|
||||||
|
sectionSub: '#8c909f',
|
||||||
|
sectionSubSpacing: '1px',
|
||||||
|
|
||||||
|
tableContainerBg: '#171b28',
|
||||||
|
tableHeaderBg: '#1b1f2c',
|
||||||
|
tableRowBorder: 'rgba(66,71,84,0.10)',
|
||||||
|
tableDataRowBg: 'rgba(10,14,26,0.50)',
|
||||||
|
|
||||||
|
badgeRadius: 'rounded-xl',
|
||||||
|
statusBadgeBg: 'transparent',
|
||||||
|
statusBadgeBorder: 'transparent',
|
||||||
|
statusBadgeDot: 'transparent',
|
||||||
|
statusBadgeText: 'transparent',
|
||||||
|
systemActiveBg: '#171b28',
|
||||||
|
systemActiveBorder: 'rgba(66,71,84,0.10)',
|
||||||
|
systemActiveShadow: '0px 0px 8px 0px rgba(76, 215, 246, 0.5)',
|
||||||
|
|
||||||
|
fontBadgePrimaryBg: '#edf0f7',
|
||||||
|
fontBadgePrimaryText: '#0a0e1a',
|
||||||
|
fontBadgeSecondaryBorder: 'rgba(66,71,84,0.30)',
|
||||||
|
fontBadgeSecondaryText: '#8c909f',
|
||||||
|
|
||||||
|
typoSampleText: '#c2c6d6',
|
||||||
|
typoSizeText: '#8c909f',
|
||||||
|
typoPropertiesText: '#c2c6d6',
|
||||||
|
typoActionBg: 'rgba(76,215,246,0.10)',
|
||||||
|
typoActionBorder: 'rgba(76,215,246,0.20)',
|
||||||
|
typoActionText: '#4cd7f6',
|
||||||
|
typoDataText: '#4cd7f6',
|
||||||
|
typoCoordText: '#8c909f',
|
||||||
|
|
||||||
|
borderCardBg: 'rgba(15,21,36,0.50)',
|
||||||
|
borderCardShadow: 'none',
|
||||||
|
|
||||||
|
textSectionBg: '#0a0e1a',
|
||||||
|
textSectionBorder: 'rgba(66,71,84,0.10)',
|
||||||
|
|
||||||
|
radiusSmLabel: 'radius-sm (6px)',
|
||||||
|
radiusMdLabel: 'radius-md (10px)',
|
||||||
|
radiusCardBg: '#171b28',
|
||||||
|
radiusCardBorder: 'rgba(66,71,84,0.20)',
|
||||||
|
radiusCardShadow: 'none',
|
||||||
|
radiusDescText: '#c2c6d6',
|
||||||
|
|
||||||
|
footerBorder: 'rgba(66,71,84,0.10)',
|
||||||
|
footerText: '#8c909f',
|
||||||
|
footerAccent: '#4cd7f6',
|
||||||
|
|
||||||
|
swatchBorder: 'rgba(255,255,255,0.05)',
|
||||||
|
swatchBorderHover: 'rgba(76,215,246,0.20)',
|
||||||
|
|
||||||
|
bgTokens: [
|
||||||
|
{ bg: '#0a0e1a', token: 'bg-0', hex: '#0a0e1a', desc: 'Primary page canvas, deepest immersion layer.' },
|
||||||
|
{ bg: '#0f1524', token: 'bg-1', hex: '#0f1524', desc: 'Surface Level 1: Sidebar containers and utility panels.' },
|
||||||
|
{ bg: '#121929', token: 'bg-2', hex: '#121929', desc: 'Surface Level 2: Table headers and subtle sectional shifts.' },
|
||||||
|
{ bg: '#1a2236', token: 'bg-3', hex: '#1a2236', desc: 'Surface Level 3: Elevated cards and floating elements.' },
|
||||||
|
{ bg: '#1e2844', token: 'bg-hover', hex: '#1e2844', desc: 'Interactive states, list item highlighting.', isHover: true },
|
||||||
|
],
|
||||||
|
|
||||||
|
accentTokens: [
|
||||||
|
{
|
||||||
|
color: '#06b6d4', name: 'Cyan Accent', token: 'primary', badge: 'Primary Action',
|
||||||
|
glow: '0px 0px 15px 0px rgba(6,182,212,0.4)',
|
||||||
|
badgeBg: 'rgba(6,182,212,0.10)', badgeBorder: 'rgba(6,182,212,0.25)', badgeText: '#06b6d4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#3b82f6', name: 'Blue Accent', token: 'secondary', badge: 'Information',
|
||||||
|
glow: '0px 0px 15px 0px rgba(59,130,246,0.3)',
|
||||||
|
badgeBg: 'rgba(59,130,246,0.10)', badgeBorder: 'rgba(59,130,246,0.25)', badgeText: '#3b82f6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#a855f7', name: 'Purple Accent', token: 'tertiary', badge: 'Operations',
|
||||||
|
glow: '0px 0px 15px 0px rgba(168,85,247,0.3)',
|
||||||
|
badgeBg: 'rgba(168,85,247,0.10)', badgeBorder: 'rgba(168,85,247,0.25)', badgeText: '#a855f7',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
statusTokens: [
|
||||||
|
{ color: '#ef4444', bg: 'rgba(239,68,68,0.05)', border: 'rgba(239,68,68,0.20)', label: '위험 Critical', hex: '#ef4444', glow: '0px 0px 8px 0px rgba(239, 68, 68, 0.6)' },
|
||||||
|
{ color: '#f97316', bg: 'rgba(249,115,22,0.05)', border: 'rgba(249,115,22,0.20)', label: '주의 Warning', hex: '#f97316' },
|
||||||
|
{ color: '#eab308', bg: 'rgba(234,179,8,0.05)', border: 'rgba(234,179,8,0.20)', label: '경고 Caution', hex: '#eab308' },
|
||||||
|
{ color: '#22c55e', bg: 'rgba(34,197,94,0.05)', border: 'rgba(34,197,94,0.20)', label: '정상 Normal', hex: '#22c55e' },
|
||||||
|
],
|
||||||
|
|
||||||
|
borderTokens: [
|
||||||
|
{ token: 'border', hex: '#1e2a42', border: '#1e2a42', barBg: '#1e2a42' },
|
||||||
|
{ token: 'border-light', hex: '#2a3a5c', border: '#2a3a5c', barBg: '#2a3a5c' },
|
||||||
|
],
|
||||||
|
|
||||||
|
textTokens: [
|
||||||
|
{ token: 'text-1', sampleText: '주요 텍스트 Primary Text', sampleClass: 'text-[#edf0f7] font-korean text-[15px] font-bold', desc: 'Headings, active values, and primary labels.', descColor: 'rgba(237,240,247,0.60)' },
|
||||||
|
{ token: 'text-2', sampleText: '보조 텍스트 Secondary Text', sampleClass: 'text-[#b0b8cc] font-korean text-[15px] font-medium', desc: 'Supporting labels and secondary information.', descColor: 'rgba(176,184,204,0.60)' },
|
||||||
|
{ token: 'text-3', sampleText: '비활성 텍스트 Muted Text', sampleClass: 'text-[#8690a6] font-korean text-[15px]', desc: 'Disabled states, placeholders, and captions.', descColor: 'rgba(134,144,166,0.60)' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- LIGHT 테마 ----------
|
||||||
|
export const LIGHT_THEME: DesignTheme = {
|
||||||
|
mode: 'light',
|
||||||
|
|
||||||
|
pageBg: '#f8fafc',
|
||||||
|
sidebarBg: '#ffffff',
|
||||||
|
sidebarBorder: '#e2e8f0',
|
||||||
|
sidebarShadow: 'rgba(0,0,0,0.05)',
|
||||||
|
headerBg: '#ffffff',
|
||||||
|
headerBorder: '#e2e8f0',
|
||||||
|
|
||||||
|
textPrimary: '#0f172a',
|
||||||
|
textSecondary: '#64748b',
|
||||||
|
textMuted: '#94a3b8',
|
||||||
|
textAccent: '#06b6d4',
|
||||||
|
|
||||||
|
cardBg: '#ffffff',
|
||||||
|
cardBorder: '#e2e8f0',
|
||||||
|
cardBorderHover: 'rgba(6,182,212,0.20)',
|
||||||
|
cardShadow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
|
||||||
|
|
||||||
|
sectionTitle: '#1e293b',
|
||||||
|
sectionSub: '#94a3b8',
|
||||||
|
sectionSubSpacing: '-0.5px',
|
||||||
|
|
||||||
|
tableContainerBg: '#ffffff',
|
||||||
|
tableHeaderBg: '#f8fafc',
|
||||||
|
tableRowBorder: '#f1f5f9',
|
||||||
|
tableDataRowBg: 'rgba(248,250,252,0.50)',
|
||||||
|
|
||||||
|
badgeRadius: 'rounded-full',
|
||||||
|
statusBadgeBg: 'transparent',
|
||||||
|
statusBadgeBorder: 'transparent',
|
||||||
|
statusBadgeDot: 'transparent',
|
||||||
|
statusBadgeText: 'transparent',
|
||||||
|
systemActiveBg: '#ffffff',
|
||||||
|
systemActiveBorder: '#e2e8f0',
|
||||||
|
systemActiveShadow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
|
||||||
|
|
||||||
|
fontBadgePrimaryBg: '#0f172a',
|
||||||
|
fontBadgePrimaryText: '#ffffff',
|
||||||
|
fontBadgeSecondaryBorder: '#cbd5e1',
|
||||||
|
fontBadgeSecondaryText: '#64748b',
|
||||||
|
|
||||||
|
typoSampleText: '#64748b',
|
||||||
|
typoSizeText: '#0f172a',
|
||||||
|
typoPropertiesText: '#94a3b8',
|
||||||
|
typoActionBg: 'rgba(6,182,212,0.10)',
|
||||||
|
typoActionBorder: 'rgba(6,182,212,0.20)',
|
||||||
|
typoActionText: '#06b6d4',
|
||||||
|
typoDataText: '#06b6d4',
|
||||||
|
typoCoordText: '#94a3b8',
|
||||||
|
|
||||||
|
borderCardBg: '#ffffff',
|
||||||
|
borderCardShadow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
|
||||||
|
|
||||||
|
textSectionBg: '#ffffff',
|
||||||
|
textSectionBorder: '#e2e8f0',
|
||||||
|
|
||||||
|
radiusSmLabel: 'radius-sm (4px)',
|
||||||
|
radiusMdLabel: 'radius-md (8px)',
|
||||||
|
radiusCardBg: '#ffffff',
|
||||||
|
radiusCardBorder: '#e2e8f0',
|
||||||
|
radiusCardShadow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
|
||||||
|
radiusDescText: '#475569',
|
||||||
|
|
||||||
|
footerBorder: '#e2e8f0',
|
||||||
|
footerText: '#94a3b8',
|
||||||
|
footerAccent: '#06b6d4',
|
||||||
|
|
||||||
|
swatchBorder: '#e2e8f0',
|
||||||
|
swatchBorderHover: 'rgba(6,182,212,0.20)',
|
||||||
|
|
||||||
|
bgTokens: [
|
||||||
|
{ bg: '#f8fafc', token: 'bg-0', hex: '#f8fafc', desc: 'Primary page canvas, lightest foundation layer.' },
|
||||||
|
{ bg: '#ffffff', token: 'bg-1', hex: '#ffffff', desc: 'Surface Level 1: Sidebar containers and utility panels.' },
|
||||||
|
{ bg: '#f1f5f9', token: 'bg-2', hex: '#f1f5f9', desc: 'Surface Level 2: Table headers and subtle sectional shifts.' },
|
||||||
|
{ bg: '#e2e8f0', token: 'bg-3', hex: '#e2e8f0', desc: 'Surface Level 3: Elevated cards and floating elements.' },
|
||||||
|
{ bg: '#cbd5e1', token: 'bg-hover', hex: '#cbd5e1', desc: 'Interactive states, list item highlighting.', isHover: true },
|
||||||
|
],
|
||||||
|
|
||||||
|
accentTokens: [
|
||||||
|
{
|
||||||
|
color: '#06b6d4', name: 'Cyan Accent', token: 'primary', badge: 'Primary Action',
|
||||||
|
glow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
|
||||||
|
badgeBg: 'rgba(6,182,212,0.10)', badgeBorder: 'rgba(6,182,212,0.25)', badgeText: '#06b6d4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#0891b2', name: 'Teal Accent', token: 'secondary', badge: 'Information',
|
||||||
|
glow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
|
||||||
|
badgeBg: 'rgba(8,145,178,0.10)', badgeBorder: 'rgba(8,145,178,0.25)', badgeText: '#0891b2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: '#6366f1', name: 'Indigo Accent', token: 'tertiary', badge: 'Operations',
|
||||||
|
glow: '0px 1px 2px 0px rgba(0,0,0,0.05)',
|
||||||
|
badgeBg: 'rgba(99,102,241,0.10)', badgeBorder: 'rgba(99,102,241,0.25)', badgeText: '#6366f1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
statusTokens: [
|
||||||
|
{ color: '#dc2626', bg: '#fef2f2', border: '#fecaca', label: '위험 Critical', hex: '#f87171' },
|
||||||
|
{ color: '#c2410c', bg: '#fff7ed', border: '#fed7aa', label: '주의 Warning', hex: '#fb923c' },
|
||||||
|
{ color: '#b45309', bg: '#fffbeb', border: '#fde68a', label: '경고 Caution', hex: '#fbbf24' },
|
||||||
|
{ color: '#047857', bg: '#ecfdf5', border: '#a7f3d0', label: '정상 Normal', hex: '#34d399' },
|
||||||
|
],
|
||||||
|
|
||||||
|
borderTokens: [
|
||||||
|
{ token: 'border', hex: '#cbd5e1', border: '#cbd5e1', barBg: '#cbd5e1' },
|
||||||
|
{ token: 'border-light', hex: '#e2e8f0', border: '#e2e8f0', barBg: '#e2e8f0' },
|
||||||
|
],
|
||||||
|
|
||||||
|
textTokens: [
|
||||||
|
{ token: 'text-1', sampleText: '주요 텍스트 Primary Text', sampleClass: 'text-[#0f172a] font-korean text-[15px] font-bold', desc: 'Headings, active values, and primary labels.', descColor: '#64748b' },
|
||||||
|
{ token: 'text-2', sampleText: '보조 텍스트 Secondary Text', sampleClass: 'text-[#475569] font-korean text-[15px] font-medium', desc: 'Supporting labels and secondary information.', descColor: '#64748b' },
|
||||||
|
{ token: 'text-3', sampleText: '비활성 텍스트 Muted Text', sampleClass: 'text-[#94a3b8] font-korean text-[15px]', desc: 'Disabled states, placeholders, and captions.', descColor: '#94a3b8' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTheme = (mode: ThemeMode): DesignTheme =>
|
||||||
|
mode === 'dark' ? DARK_THEME : LIGHT_THEME;
|
||||||
@ -12,6 +12,10 @@ import CleanupEquipPanel from './CleanupEquipPanel';
|
|||||||
import AssetUploadPanel from './AssetUploadPanel';
|
import AssetUploadPanel from './AssetUploadPanel';
|
||||||
import MapBasePanel from './MapBasePanel';
|
import MapBasePanel from './MapBasePanel';
|
||||||
import LayerPanel from './LayerPanel';
|
import LayerPanel from './LayerPanel';
|
||||||
|
import SensitiveLayerPanel from './SensitiveLayerPanel';
|
||||||
|
import DispersingZonePanel from './DispersingZonePanel';
|
||||||
|
import MonitorRealtimePanel from './MonitorRealtimePanel';
|
||||||
|
import VesselMaterialsPanel from './VesselMaterialsPanel';
|
||||||
|
|
||||||
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
||||||
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
||||||
@ -27,6 +31,11 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
|
|||||||
'asset-upload': () => <AssetUploadPanel />,
|
'asset-upload': () => <AssetUploadPanel />,
|
||||||
'map-base': () => <MapBasePanel />,
|
'map-base': () => <MapBasePanel />,
|
||||||
'map-layer': () => <LayerPanel />,
|
'map-layer': () => <LayerPanel />,
|
||||||
|
'env-ecology': () => <SensitiveLayerPanel categoryCode="LYR001002001" title="환경/생태" />,
|
||||||
|
'social-economy': () => <SensitiveLayerPanel categoryCode="LYR001002002" title="사회/경제" />,
|
||||||
|
'dispersant-zone': () => <DispersingZonePanel />,
|
||||||
|
'vessel-materials': () => <VesselMaterialsPanel />,
|
||||||
|
'monitor-realtime': () => <MonitorRealtimePanel />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdminView() {
|
export function AdminView() {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ function CleanupEquipPanel() {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [regionFilter, setRegionFilter] = useState('전체');
|
const [regionFilter, setRegionFilter] = useState('전체');
|
||||||
const [typeFilter, setTypeFilter] = useState('전체');
|
const [typeFilter, setTypeFilter] = useState('전체');
|
||||||
|
const [equipFilter, setEquipFilter] = useState('전체');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
@ -40,12 +41,21 @@ function CleanupEquipPanel() {
|
|||||||
return Array.from(set).sort();
|
return Array.from(set).sort();
|
||||||
}, [organizations]);
|
}, [organizations]);
|
||||||
|
|
||||||
|
const EQUIP_FIELDS: Record<string, keyof AssetOrgCompat> = {
|
||||||
|
'방제선': 'vessel',
|
||||||
|
'유회수기': 'skimmer',
|
||||||
|
'이송펌프': 'pump',
|
||||||
|
'방제차량': 'vehicle',
|
||||||
|
'살포장치': 'sprayer',
|
||||||
|
};
|
||||||
|
|
||||||
const filtered = useMemo(() =>
|
const filtered = useMemo(() =>
|
||||||
organizations
|
organizations
|
||||||
.filter(o => regionFilter === '전체' || o.jurisdiction.includes(regionFilter))
|
.filter(o => regionFilter === '전체' || o.jurisdiction.includes(regionFilter))
|
||||||
.filter(o => typeFilter === '전체' || o.type === typeFilter)
|
.filter(o => typeFilter === '전체' || o.type === typeFilter)
|
||||||
|
.filter(o => equipFilter === '전체' || (o[EQUIP_FIELDS[equipFilter]] as number) > 0)
|
||||||
.filter(o => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm)),
|
.filter(o => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm)),
|
||||||
[organizations, regionFilter, typeFilter, searchTerm]
|
[organizations, regionFilter, typeFilter, equipFilter, searchTerm]
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
@ -96,6 +106,18 @@ function CleanupEquipPanel() {
|
|||||||
<option key={t} value={t}>{t}</option>
|
<option key={t} value={t}>{t}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
|
value={equipFilter}
|
||||||
|
onChange={handleFilterChange(setEquipFilter)}
|
||||||
|
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||||
|
>
|
||||||
|
<option value="전체">전체 장비</option>
|
||||||
|
<option value="방제선">방제선</option>
|
||||||
|
<option value="유회수기">유회수기</option>
|
||||||
|
<option value="이송펌프">이송펌프</option>
|
||||||
|
<option value="방제차량">방제차량</option>
|
||||||
|
<option value="살포장치">살포장치</option>
|
||||||
|
</select>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="기관명, 주소 검색..."
|
placeholder="기관명, 주소 검색..."
|
||||||
@ -122,16 +144,16 @@ function CleanupEquipPanel() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-bg-1">
|
<tr className="border-b border-border bg-bg-1">
|
||||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10">번호</th>
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10 whitespace-nowrap">번호</th>
|
||||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">유형</th>
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">유형</th>
|
||||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">관할청</th>
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">관할청</th>
|
||||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">기관명</th>
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">기관명</th>
|
||||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">주소</th>
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">주소</th>
|
||||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">방제선</th>
|
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '방제선' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>방제선</th>
|
||||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">유회수기</th>
|
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '유회수기' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>유회수기</th>
|
||||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">이송펌프</th>
|
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '이송펌프' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>이송펌프</th>
|
||||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">방제차량</th>
|
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '방제차량' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>방제차량</th>
|
||||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">살포장치</th>
|
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '살포장치' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>살포장치</th>
|
||||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">총자산</th>
|
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">총자산</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -161,20 +183,20 @@ function CleanupEquipPanel() {
|
|||||||
<td className="px-4 py-3 text-[11px] text-text-3 font-korean max-w-[200px] truncate">
|
<td className="px-4 py-3 text-[11px] text-text-3 font-korean max-w-[200px] truncate">
|
||||||
{org.address}
|
{org.address}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '방제선' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||||
{org.vessel > 0 ? <span className="text-text-1">{org.vessel}</span> : <span className="text-text-3">—</span>}
|
{org.vessel > 0 ? org.vessel : <span className="text-text-3">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '유회수기' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||||
{org.skimmer > 0 ? <span className="text-text-1">{org.skimmer}</span> : <span className="text-text-3">—</span>}
|
{org.skimmer > 0 ? org.skimmer : <span className="text-text-3">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '이송펌프' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||||
{org.pump > 0 ? <span className="text-text-1">{org.pump}</span> : <span className="text-text-3">—</span>}
|
{org.pump > 0 ? org.pump : <span className="text-text-3">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '방제차량' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||||
{org.vehicle > 0 ? <span className="text-text-1">{org.vehicle}</span> : <span className="text-text-3">—</span>}
|
{org.vehicle > 0 ? org.vehicle : <span className="text-text-3">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '살포장치' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||||
{org.sprayer > 0 ? <span className="text-text-1">{org.sprayer}</span> : <span className="text-text-3">—</span>}
|
{org.sprayer > 0 ? org.sprayer : <span className="text-text-3">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-primary-cyan">
|
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-primary-cyan">
|
||||||
{org.totalAssets.toLocaleString()}
|
{org.totalAssets.toLocaleString()}
|
||||||
@ -186,6 +208,33 @@ function CleanupEquipPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 합계 */}
|
||||||
|
{!loading && filtered.length > 0 && (
|
||||||
|
<div className="flex items-center gap-4 px-6 py-2 border-t border-border bg-bg-0/80">
|
||||||
|
<span className="text-[10px] text-text-3 font-korean font-semibold mr-auto">
|
||||||
|
합계 ({filtered.length}개 기관)
|
||||||
|
</span>
|
||||||
|
{[
|
||||||
|
{ label: '방제선', value: filtered.reduce((s, o) => s + o.vessel, 0), unit: '척' },
|
||||||
|
{ label: '유회수기', value: filtered.reduce((s, o) => s + o.skimmer, 0), unit: '대' },
|
||||||
|
{ label: '이송펌프', value: filtered.reduce((s, o) => s + o.pump, 0), unit: '대' },
|
||||||
|
{ label: '방제차량', value: filtered.reduce((s, o) => s + o.vehicle, 0), unit: '대' },
|
||||||
|
{ label: '살포장치', value: filtered.reduce((s, o) => s + o.sprayer, 0), unit: '대' },
|
||||||
|
{ label: '총자산', value: filtered.reduce((s, o) => s + o.totalAssets, 0), unit: '' },
|
||||||
|
].map((t) => {
|
||||||
|
const isActive = t.label === equipFilter || t.label === '총자산';
|
||||||
|
return (
|
||||||
|
<div key={t.label} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${isActive ? 'bg-primary-cyan/10' : ''}`}>
|
||||||
|
<span className={`text-[9px] font-korean ${isActive ? 'text-primary-cyan' : 'text-text-3'}`}>{t.label}</span>
|
||||||
|
<span className={`text-[10px] font-mono font-bold ${isActive ? 'text-primary-cyan' : 'text-text-1'}`}>
|
||||||
|
{t.value.toLocaleString()}{t.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{!loading && filtered.length > 0 && (
|
{!loading && filtered.length > 0 && (
|
||||||
<div className="flex items-center justify-between px-6 py-3 border-t border-border">
|
<div className="flex items-center justify-between px-6 py-3 border-t border-border">
|
||||||
|
|||||||
247
frontend/src/tabs/admin/components/DispersingZonePanel.tsx
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Map, useControl } from '@vis.gl/react-maplibre';
|
||||||
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
|
import { GeoJsonLayer } from '@deck.gl/layers';
|
||||||
|
import type { Layer } from '@deck.gl/core';
|
||||||
|
import type { StyleSpecification } from 'maplibre-gl';
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
|
||||||
|
// CartoDB Dark Matter 스타일
|
||||||
|
const MAP_STYLE: StyleSpecification = {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
'carto-dark': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [
|
||||||
|
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||||||
|
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||||||
|
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||||||
|
],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'carto-dark-layer',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'carto-dark',
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 22,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAP_CENTER: [number, number] = [127.5, 36.0];
|
||||||
|
const MAP_ZOOM = 5.5;
|
||||||
|
|
||||||
|
const CONSIDER_FILL: [number, number, number, number] = [59, 130, 246, 60];
|
||||||
|
const CONSIDER_LINE: [number, number, number, number] = [59, 130, 246, 220];
|
||||||
|
const RESTRICT_FILL: [number, number, number, number] = [239, 68, 68, 60];
|
||||||
|
const RESTRICT_LINE: [number, number, number, number] = [239, 68, 68, 220];
|
||||||
|
|
||||||
|
type ZoneKey = 'consider' | 'restrict';
|
||||||
|
|
||||||
|
// deck.gl 오버레이 컴포넌트
|
||||||
|
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
||||||
|
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||||||
|
overlay.setProps({ layers });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구역 설명 데이터
|
||||||
|
const ZONE_INFO: Record<ZoneKey, { label: string; rows: { key: string; value: string }[] }> = {
|
||||||
|
consider: {
|
||||||
|
label: '사용고려해역',
|
||||||
|
rows: [
|
||||||
|
{ key: '수심', value: '20m 이상 ※ (IMO) 대형 20m, 중소형 10m 이상' },
|
||||||
|
{
|
||||||
|
key: '사용거리',
|
||||||
|
value:
|
||||||
|
'해안 2km, 중요 민감자원으로부터 5km 이상 떨어진 경우 ※ (IMO) 대형 1km, 중소형 0.5km 이상',
|
||||||
|
},
|
||||||
|
{ key: '사용승인(절차)', value: '현장 방제책임자 재량 사용 ※ (IMO) 의결정 절차 지침' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
restrict: {
|
||||||
|
label: '사용제한해역',
|
||||||
|
rows: [
|
||||||
|
{ key: '수심', value: '수심 10m 이하' },
|
||||||
|
{
|
||||||
|
key: '사용거리',
|
||||||
|
value:
|
||||||
|
'어장·양식장, 발전소 취수구, 종묘배양장 및 폐쇄성 해역 특정해역중 수자원 보호구역',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '사용승인(절차)',
|
||||||
|
value:
|
||||||
|
'심의위원회 승인을 받아 관할 방제책임기관 또는 방제대책 본부장이 결정 ※ 긴급한 경우 先사용, 後심의',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DispersingZonePanel = () => {
|
||||||
|
const [showConsider, setShowConsider] = useState(true);
|
||||||
|
const [showRestrict, setShowRestrict] = useState(true);
|
||||||
|
const [expandedZone, setExpandedZone] = useState<ZoneKey | null>(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const [considerData, setConsiderData] = useState<any>(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const [restrictData, setRestrictData] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/dispersant-consider.geojson')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(setConsiderData)
|
||||||
|
.catch(() => {/* GeoJSON 없을 때 빈 상태 유지 */});
|
||||||
|
|
||||||
|
fetch('/dispersant-restrict.geojson')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(setRestrictData)
|
||||||
|
.catch(() => {/* GeoJSON 없을 때 빈 상태 유지 */});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const layers: Layer[] = [
|
||||||
|
...(showConsider && considerData
|
||||||
|
? [
|
||||||
|
new GeoJsonLayer({
|
||||||
|
id: 'dispersant-consider',
|
||||||
|
data: considerData,
|
||||||
|
getFillColor: CONSIDER_FILL,
|
||||||
|
getLineColor: CONSIDER_LINE,
|
||||||
|
lineWidthMinPixels: 1.5,
|
||||||
|
pickable: false,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(showRestrict && restrictData
|
||||||
|
? [
|
||||||
|
new GeoJsonLayer({
|
||||||
|
id: 'dispersant-restrict',
|
||||||
|
data: restrictData,
|
||||||
|
getFillColor: RESTRICT_FILL,
|
||||||
|
getLineColor: RESTRICT_LINE,
|
||||||
|
lineWidthMinPixels: 1.5,
|
||||||
|
pickable: false,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleToggleExpand = (zone: ZoneKey) => {
|
||||||
|
setExpandedZone(prev => (prev === zone ? null : zone));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderZoneCard = (zone: ZoneKey) => {
|
||||||
|
const info = ZONE_INFO[zone];
|
||||||
|
const isConsider = zone === 'consider';
|
||||||
|
const showLayer = isConsider ? showConsider : showRestrict;
|
||||||
|
const setShowLayer = isConsider ? setShowConsider : setShowRestrict;
|
||||||
|
const swatchColor = isConsider ? 'bg-blue-500' : 'bg-red-500';
|
||||||
|
const isExpanded = expandedZone === zone;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={zone} className="border border-border rounded-lg overflow-hidden">
|
||||||
|
{/* 카드 헤더 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-[rgba(255,255,255,0.03)] transition-colors"
|
||||||
|
onClick={() => handleToggleExpand(zone)}
|
||||||
|
>
|
||||||
|
<span className={`w-3 h-3 rounded-sm shrink-0 ${swatchColor}`} />
|
||||||
|
<span className="flex-1 text-xs font-semibold text-text-1 font-korean">{info.label}</span>
|
||||||
|
{/* 토글 스위치 */}
|
||||||
|
<button
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowLayer(prev => !prev);
|
||||||
|
}}
|
||||||
|
title={showLayer ? '레이어 숨기기' : '레이어 표시'}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 ${
|
||||||
|
showLayer
|
||||||
|
? 'bg-primary-cyan'
|
||||||
|
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||||
|
showLayer ? 'translate-x-[18px]' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/* 펼침 화살표 */}
|
||||||
|
<span className="text-text-3 text-[10px] shrink-0">{isExpanded ? '▲' : '▼'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 펼침 영역 */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-border px-3 py-3">
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{info.rows.map(row => (
|
||||||
|
<tr key={row.key} className="border-b border-border last:border-0">
|
||||||
|
<td className="py-2 pr-2 text-[11px] text-text-3 font-korean whitespace-nowrap align-top w-24">
|
||||||
|
{row.key}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-[11px] text-text-2 font-korean leading-relaxed">
|
||||||
|
{row.value}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* 지도 영역 */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Map
|
||||||
|
initialViewState={{
|
||||||
|
longitude: MAP_CENTER[0],
|
||||||
|
latitude: MAP_CENTER[1],
|
||||||
|
zoom: MAP_ZOOM,
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
mapStyle={MAP_STYLE}
|
||||||
|
>
|
||||||
|
<DeckGLOverlay layers={layers} />
|
||||||
|
</Map>
|
||||||
|
|
||||||
|
{/* 범례 */}
|
||||||
|
<div className="absolute bottom-4 left-4 bg-bg-1 border border-border rounded-lg px-3 py-2 flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded-sm bg-blue-500 opacity-80" />
|
||||||
|
<span className="text-[11px] text-text-2 font-korean">사용고려해역</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded-sm bg-red-500 opacity-80" />
|
||||||
|
<span className="text-[11px] text-text-2 font-korean">사용제한해역</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 패널 */}
|
||||||
|
<div className="w-[280px] bg-bg-1 border-l border-border flex flex-col overflow-hidden shrink-0">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-4 py-4 border-b border-border shrink-0">
|
||||||
|
<h1 className="text-sm font-bold text-text-1 font-korean">유처리제 제한구역</h1>
|
||||||
|
<p className="text-[11px] text-text-3 mt-0.5 font-korean">해양환경관리법 기준</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구역 카드 목록 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
|
||||||
|
{renderZoneCard('consider')}
|
||||||
|
{renderZoneCard('restrict')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DispersingZonePanel;
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
interface LayerAdminItem {
|
interface LayerAdminItem {
|
||||||
@ -11,6 +12,7 @@ interface LayerAdminItem {
|
|||||||
useYn: string;
|
useYn: string;
|
||||||
sortOrd: number;
|
sortOrd: number;
|
||||||
regDtm: string | null;
|
regDtm: string | null;
|
||||||
|
parentUseYn: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayerListResponse {
|
interface LayerListResponse {
|
||||||
@ -313,6 +315,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
// ---------- LayerPanel ----------
|
// ---------- LayerPanel ----------
|
||||||
|
|
||||||
const LayerPanel = () => {
|
const LayerPanel = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [items, setItems] = useState<LayerAdminItem[]>([]);
|
const [items, setItems] = useState<LayerAdminItem[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
@ -359,10 +362,15 @@ const LayerPanel = () => {
|
|||||||
try {
|
try {
|
||||||
const result = await toggleLayerUse(layerCd);
|
const result = await toggleLayerUse(layerCd);
|
||||||
setItems(prev =>
|
setItems(prev =>
|
||||||
prev.map(item =>
|
prev.map(item => {
|
||||||
item.layerCd === result.layerCd ? { ...item, useYn: result.useYn } : item
|
if (item.layerCd === result.layerCd) return { ...item, useYn: result.useYn };
|
||||||
)
|
// 직접 자식의 parentUseYn도 즉시 동기화
|
||||||
|
if (item.upLayerCd === result.layerCd) return { ...item, parentUseYn: result.useYn };
|
||||||
|
return item;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
// 레이어 캐시 무효화 → 예측 탭 등 useLayerTree 구독자가 최신 데이터 수신
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['layers'] });
|
||||||
} catch {
|
} catch {
|
||||||
setError('사용여부 변경에 실패했습니다.');
|
setError('사용여부 변경에 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
@ -522,11 +530,19 @@ const LayerPanel = () => {
|
|||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggle(item.layerCd)}
|
onClick={() => handleToggle(item.layerCd)}
|
||||||
disabled={toggling === item.layerCd}
|
disabled={toggling === item.layerCd || item.parentUseYn === 'N'}
|
||||||
title={item.useYn === 'Y' ? '사용 중 (클릭하여 비활성화)' : '미사용 (클릭하여 활성화)'}
|
title={
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${
|
item.parentUseYn === 'N'
|
||||||
item.useYn === 'Y'
|
? '상위 레이어가 비활성화되어 있어 적용되지 않습니다'
|
||||||
|
: item.useYn === 'Y'
|
||||||
|
? '사용 중 (클릭하여 비활성화)'
|
||||||
|
: '미사용 (클릭하여 활성화)'
|
||||||
|
}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-40 ${
|
||||||
|
item.useYn === 'Y' && item.parentUseYn !== 'N'
|
||||||
? 'bg-primary-cyan'
|
? 'bg-primary-cyan'
|
||||||
|
: item.useYn === 'Y' && item.parentUseYn === 'N'
|
||||||
|
? 'bg-primary-cyan/40'
|
||||||
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
436
frontend/src/tabs/admin/components/MonitorRealtimePanel.tsx
Normal file
@ -0,0 +1,436 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
getRecentObservation,
|
||||||
|
OBS_STATION_CODES,
|
||||||
|
type RecentObservation,
|
||||||
|
} from '@tabs/weather/services/khoaApi';
|
||||||
|
import {
|
||||||
|
getUltraShortForecast,
|
||||||
|
getMarineForecast,
|
||||||
|
convertToGridCoords,
|
||||||
|
getCurrentBaseDateTime,
|
||||||
|
MARINE_REGIONS,
|
||||||
|
type WeatherForecastData,
|
||||||
|
type MarineWeatherData,
|
||||||
|
} from '@tabs/weather/services/weatherApi';
|
||||||
|
|
||||||
|
const KEY_TO_NAME: Record<string, string> = {
|
||||||
|
incheon: '인천',
|
||||||
|
gunsan: '군산',
|
||||||
|
mokpo: '목포',
|
||||||
|
yeosu: '여수',
|
||||||
|
tongyeong: '통영',
|
||||||
|
ulsan: '울산',
|
||||||
|
pohang: '포항',
|
||||||
|
donghae: '동해',
|
||||||
|
sokcho: '속초',
|
||||||
|
jeju: '제주',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 조위관측소 목록
|
||||||
|
const STATIONS = Object.entries(OBS_STATION_CODES).map(([key, code]) => ({
|
||||||
|
key,
|
||||||
|
code,
|
||||||
|
name: KEY_TO_NAME[key] ?? key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 기상청 초단기실황 지점 (위경도)
|
||||||
|
const WEATHER_STATIONS = [
|
||||||
|
{ name: '인천', lat: 37.4563, lon: 126.7052 },
|
||||||
|
{ name: '군산', lat: 35.9679, lon: 126.7361 },
|
||||||
|
{ name: '목포', lat: 34.8118, lon: 126.3922 },
|
||||||
|
{ name: '부산', lat: 35.1028, lon: 129.0323 },
|
||||||
|
{ name: '제주', lat: 33.5131, lon: 126.5297 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 해역 목록
|
||||||
|
const MARINE_REGION_LIST = Object.entries(MARINE_REGIONS).map(([name, regId]) => ({
|
||||||
|
name,
|
||||||
|
regId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
type TabId = 'khoa' | 'kma-ultra' | 'kma-marine';
|
||||||
|
|
||||||
|
interface KhoaRow {
|
||||||
|
stationName: string;
|
||||||
|
data: RecentObservation | null;
|
||||||
|
error: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KmaUltraRow {
|
||||||
|
stationName: string;
|
||||||
|
data: WeatherForecastData | null;
|
||||||
|
error: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarineRow {
|
||||||
|
name: string;
|
||||||
|
regId: string;
|
||||||
|
data: MarineWeatherData | null;
|
||||||
|
error: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (v: number | null | undefined, digits = 1): string =>
|
||||||
|
v != null ? v.toFixed(digits) : '-';
|
||||||
|
|
||||||
|
function StatusBadge({ loading, errorCount, total }: { loading: boolean; errorCount: number; total: number }) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-2 text-t2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||||
|
조회 중...
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (errorCount === total) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-400">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
||||||
|
연계 오류
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (errorCount > 0) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-400">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
|
||||||
|
일부 오류 ({errorCount}/{total})
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||||
|
정상
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
|
||||||
|
const headers = ['관측소', '수온(°C)', '기온(°C)', '기압(hPa)', '풍향(°)', '풍속(m/s)', '유향(°)', '유속(m/s)', '조위(cm)', '상태'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
|
||||||
|
{headers.map((h) => (
|
||||||
|
<th key={h} className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading && rows.length === 0
|
||||||
|
? Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-b border-border-1 animate-pulse">
|
||||||
|
{headers.map((_, j) => (
|
||||||
|
<td key={j} className="px-3 py-2">
|
||||||
|
<div className="h-3 bg-bg-2 rounded w-12" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
: rows.map((row) => (
|
||||||
|
<tr key={row.stationName} className="border-b border-border-1 hover:bg-bg-1/50">
|
||||||
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.stationName}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.water_temp)}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.air_temp)}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.air_pres)}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.wind_dir, 0)}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.wind_speed)}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.current_dir, 0)}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.current_speed)}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.tide_level, 0)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{row.error ? (
|
||||||
|
<span className="text-red-400 text-xs">오류</span>
|
||||||
|
) : row.data ? (
|
||||||
|
<span className="text-emerald-400 text-xs">정상</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-t3 text-xs">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolean }) {
|
||||||
|
const headers = ['지점', '기온(°C)', '풍속(m/s)', '풍향(°)', '파고(m)', '강수(mm)', '습도(%)', '상태'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
|
||||||
|
{headers.map((h) => (
|
||||||
|
<th key={h} className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading && rows.length === 0
|
||||||
|
? Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-b border-border-1 animate-pulse">
|
||||||
|
{headers.map((_, j) => (
|
||||||
|
<td key={j} className="px-3 py-2">
|
||||||
|
<div className="h-3 bg-bg-2 rounded w-12" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
: rows.map((row) => (
|
||||||
|
<tr key={row.stationName} className="border-b border-border-1 hover:bg-bg-1/50">
|
||||||
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.stationName}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.temperature)}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.windSpeed)}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.windDirection, 0)}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.waveHeight)}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.precipitation)}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.humidity, 0)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{row.error ? (
|
||||||
|
<span className="text-red-400 text-xs">오류</span>
|
||||||
|
) : row.data ? (
|
||||||
|
<span className="text-emerald-400 text-xs">정상</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-t3 text-xs">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean }) {
|
||||||
|
const headers = ['해역명', '파고(m)', '풍속(m/s)', '풍향', '수온(°C)', '상태'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
|
||||||
|
{headers.map((h) => (
|
||||||
|
<th key={h} className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading && rows.length === 0
|
||||||
|
? Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-b border-border-1 animate-pulse">
|
||||||
|
{headers.map((_, j) => (
|
||||||
|
<td key={j} className="px-3 py-2">
|
||||||
|
<div className="h-3 bg-bg-2 rounded w-14" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
: rows.map((row) => (
|
||||||
|
<tr key={row.regId} className="border-b border-border-1 hover:bg-bg-1/50">
|
||||||
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.name}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.waveHeight)}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.windSpeed)}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{row.data?.windDirection || '-'}</td>
|
||||||
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.temperature)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{row.error ? (
|
||||||
|
<span className="text-red-400 text-xs">오류</span>
|
||||||
|
) : row.data ? (
|
||||||
|
<span className="text-emerald-400 text-xs">정상</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-t3 text-xs">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonitorRealtimePanel() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>('khoa');
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
// KHOA 조위관측소
|
||||||
|
const [khoaRows, setKhoaRows] = useState<KhoaRow[]>([]);
|
||||||
|
const [khoaLoading, setKhoaLoading] = useState(false);
|
||||||
|
|
||||||
|
// 기상청 초단기실황
|
||||||
|
const [kmaRows, setKmaRows] = useState<KmaUltraRow[]>([]);
|
||||||
|
const [kmaLoading, setKmaLoading] = useState(false);
|
||||||
|
|
||||||
|
// 기상청 해상예보
|
||||||
|
const [marineRows, setMarineRows] = useState<MarineRow[]>([]);
|
||||||
|
const [marineLoading, setMarineLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchKhoa = useCallback(async () => {
|
||||||
|
setKhoaLoading(true);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
STATIONS.map((s) => getRecentObservation(s.code))
|
||||||
|
);
|
||||||
|
const rows: KhoaRow[] = STATIONS.map((s, i) => {
|
||||||
|
const result = results[i];
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
return { stationName: s.name, data: result.value, error: false };
|
||||||
|
}
|
||||||
|
return { stationName: s.name, data: null, error: true };
|
||||||
|
});
|
||||||
|
setKhoaRows(rows);
|
||||||
|
setKhoaLoading(false);
|
||||||
|
setLastUpdate(new Date());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchKmaUltra = useCallback(async () => {
|
||||||
|
setKmaLoading(true);
|
||||||
|
const { baseDate, baseTime } = getCurrentBaseDateTime();
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
WEATHER_STATIONS.map((s) => {
|
||||||
|
const { nx, ny } = convertToGridCoords(s.lat, s.lon);
|
||||||
|
return getUltraShortForecast(nx, ny, baseDate, baseTime);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const rows: KmaUltraRow[] = WEATHER_STATIONS.map((s, i) => {
|
||||||
|
const result = results[i];
|
||||||
|
if (result.status === 'fulfilled' && result.value.length > 0) {
|
||||||
|
return { stationName: s.name, data: result.value[0], error: false };
|
||||||
|
}
|
||||||
|
return { stationName: s.name, data: null, error: result.status === 'rejected' };
|
||||||
|
});
|
||||||
|
setKmaRows(rows);
|
||||||
|
setKmaLoading(false);
|
||||||
|
setLastUpdate(new Date());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchMarine = useCallback(async () => {
|
||||||
|
setMarineLoading(true);
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const tmFc = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}00`;
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
MARINE_REGION_LIST.map((r) => getMarineForecast(r.regId, tmFc))
|
||||||
|
);
|
||||||
|
const rows: MarineRow[] = MARINE_REGION_LIST.map((r, i) => {
|
||||||
|
const result = results[i];
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
return { name: r.name, regId: r.regId, data: result.value, error: false };
|
||||||
|
}
|
||||||
|
return { name: r.name, regId: r.regId, data: null, error: true };
|
||||||
|
});
|
||||||
|
setMarineRows(rows);
|
||||||
|
setMarineLoading(false);
|
||||||
|
setLastUpdate(new Date());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 탭 전환 시 해당 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
if (activeTab === 'khoa' && khoaRows.length === 0) {
|
||||||
|
void Promise.resolve().then(() => { if (isMounted) void fetchKhoa(); });
|
||||||
|
} else if (activeTab === 'kma-ultra' && kmaRows.length === 0) {
|
||||||
|
void Promise.resolve().then(() => { if (isMounted) void fetchKmaUltra(); });
|
||||||
|
} else if (activeTab === 'kma-marine' && marineRows.length === 0) {
|
||||||
|
void Promise.resolve().then(() => { if (isMounted) void fetchMarine(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => { isMounted = false; };
|
||||||
|
}, [activeTab, khoaRows.length, kmaRows.length, marineRows.length, fetchKhoa, fetchKmaUltra, fetchMarine]);
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (activeTab === 'khoa') fetchKhoa();
|
||||||
|
else if (activeTab === 'kma-ultra') fetchKmaUltra();
|
||||||
|
else fetchMarine();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = activeTab === 'khoa' ? khoaLoading : activeTab === 'kma-ultra' ? kmaLoading : marineLoading;
|
||||||
|
const currentRows = activeTab === 'khoa' ? khoaRows : activeTab === 'kma-ultra' ? kmaRows : marineRows;
|
||||||
|
const errorCount = currentRows.filter((r) => r.error).length;
|
||||||
|
const totalCount = activeTab === 'khoa' ? STATIONS.length : activeTab === 'kma-ultra' ? WEATHER_STATIONS.length : MARINE_REGION_LIST.length;
|
||||||
|
|
||||||
|
const TABS: { id: TabId; label: string }[] = [
|
||||||
|
{ id: 'khoa', label: 'KHOA 조위관측소' },
|
||||||
|
{ id: 'kma-ultra', label: '기상청 초단기실황' },
|
||||||
|
{ id: 'kma-marine', label: '기상청 해상예보' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-border-1 shrink-0">
|
||||||
|
<h2 className="text-sm font-semibold text-t1">실시간 관측자료 모니터링</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{lastUpdate && (
|
||||||
|
<span className="text-xs text-t3">
|
||||||
|
갱신: {lastUpdate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-2 hover:bg-bg-3 text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex gap-0 border-b border-border-1 shrink-0 px-5">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`px-4 py-2.5 text-xs font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-cyan-400 text-cyan-400'
|
||||||
|
: 'border-transparent text-t3 hover:text-t2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 표시줄 */}
|
||||||
|
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-border-1 bg-bg-0">
|
||||||
|
<StatusBadge loading={isLoading} errorCount={errorCount} total={totalCount} />
|
||||||
|
<span className="text-xs text-t3">
|
||||||
|
{activeTab === 'khoa' && `관측소 ${totalCount}개`}
|
||||||
|
{activeTab === 'kma-ultra' && `지점 ${totalCount}개`}
|
||||||
|
{activeTab === 'kma-marine' && `해역 ${totalCount}개`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 콘텐츠 */}
|
||||||
|
<div className="flex-1 overflow-auto p-5">
|
||||||
|
{activeTab === 'khoa' && (
|
||||||
|
<KhoaTable rows={khoaRows} loading={khoaLoading} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'kma-ultra' && (
|
||||||
|
<KmaUltraTable rows={kmaRows} loading={kmaLoading} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'kma-marine' && (
|
||||||
|
<MarineTable rows={marineRows} loading={marineLoading} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
304
frontend/src/tabs/admin/components/SensitiveLayerPanel.tsx
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
|
interface SensitiveLayerPanelProps {
|
||||||
|
categoryCode: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayerAdminItem {
|
||||||
|
layerCd: string;
|
||||||
|
upLayerCd: string | null;
|
||||||
|
layerFullNm: string;
|
||||||
|
layerNm: string;
|
||||||
|
layerLevel: number;
|
||||||
|
wmsLayerNm: string | null;
|
||||||
|
useYn: string;
|
||||||
|
sortOrd: number;
|
||||||
|
regDtm: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayerListResponse {
|
||||||
|
items: LayerAdminItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
async function fetchSensitiveLayers(
|
||||||
|
page: number,
|
||||||
|
search: string,
|
||||||
|
useYn: string,
|
||||||
|
rootCd: string,
|
||||||
|
): Promise<LayerListResponse> {
|
||||||
|
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE), rootCd });
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
if (useYn) params.set('useYn', useYn);
|
||||||
|
const res = await api.get<LayerListResponse>(`/layers/admin/list?${params}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLayerUse(layerCd: string): Promise<{ layerCd: string; useYn: string }> {
|
||||||
|
const res = await api.post<{ layerCd: string; useYn: string }>('/layers/admin/toggle-use', { layerCd });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- SensitiveLayerPanel ----------
|
||||||
|
|
||||||
|
const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps) => {
|
||||||
|
const [items, setItems] = useState<LayerAdminItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [toggling, setToggling] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [appliedSearch, setAppliedSearch] = useState('');
|
||||||
|
const [filterUseYn, setFilterUseYn] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async (p: number, search: string, useYn: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetchSensitiveLayers(p, search, useYn, categoryCode);
|
||||||
|
setItems(res.items);
|
||||||
|
setTotal(res.total);
|
||||||
|
setTotalPages(res.totalPages);
|
||||||
|
} catch {
|
||||||
|
setError('레이어 목록을 불러오지 못했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [categoryCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
setAppliedSearch('');
|
||||||
|
setFilterUseYn('');
|
||||||
|
setSearchInput('');
|
||||||
|
}, [categoryCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load(page, appliedSearch, filterUseYn);
|
||||||
|
}, [load, page, appliedSearch, filterUseYn]);
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setAppliedSearch(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (layerCd: string) => {
|
||||||
|
if (toggling) return;
|
||||||
|
setToggling(layerCd);
|
||||||
|
try {
|
||||||
|
const result = await toggleLayerUse(layerCd);
|
||||||
|
setItems(prev =>
|
||||||
|
prev.map(item =>
|
||||||
|
item.layerCd === result.layerCd ? { ...item, useYn: result.useYn } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setError('사용여부 변경에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setToggling(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPageButtons = () => {
|
||||||
|
const buttons: (number | 'ellipsis')[] = [];
|
||||||
|
const delta = 2;
|
||||||
|
const left = page - delta;
|
||||||
|
const right = page + delta;
|
||||||
|
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
if (i === 1 || i === totalPages || (i >= left && i <= right)) {
|
||||||
|
buttons.push(i);
|
||||||
|
} else if (buttons[buttons.length - 1] !== 'ellipsis') {
|
||||||
|
buttons.push('ellipsis');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buttons;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-6 py-4 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-text-1 font-korean">{title}</h1>
|
||||||
|
<p className="text-xs text-text-3 mt-1 font-korean">총 {total}개</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||||
|
placeholder="레이어코드 / 레이어명 검색"
|
||||||
|
className="flex-1 px-3 py-1.5 text-xs bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={filterUseYn}
|
||||||
|
onChange={e => setFilterUseYn(e.target.value)}
|
||||||
|
className="px-2 py-1.5 text-xs bg-bg-2 border border-border rounded text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||||
|
>
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="Y">사용</option>
|
||||||
|
<option value="N">미사용</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="px-3 py-1.5 text-xs border border-border text-text-2 rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
|
>
|
||||||
|
검색
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오류 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-border shrink-0 font-korean">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 영역 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-text-3 text-sm font-korean">
|
||||||
|
불러오는 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-bg-1 sticky top-0 z-10">
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10 whitespace-nowrap">번호</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">레이어코드</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">레이어명</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">레이어전체명</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-12 whitespace-nowrap">레벨</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">WMS레이어명</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-16">정렬</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-28">등록일시</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-20">사용여부</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="px-4 py-12 text-center text-text-3 text-sm font-korean">
|
||||||
|
데이터가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
items.map((item, idx) => (
|
||||||
|
<tr
|
||||||
|
key={item.layerCd}
|
||||||
|
className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-xs text-text-3 font-mono">
|
||||||
|
{(page - 1) * PAGE_SIZE + idx + 1}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-2 font-mono">
|
||||||
|
{item.layerCd}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-text-1 font-korean">
|
||||||
|
{item.layerNm}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-text-2 font-korean max-w-[200px]">
|
||||||
|
<span className="block truncate" title={item.layerFullNm}>
|
||||||
|
{item.layerFullNm}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-semibold bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-[rgba(6,182,212,0.3)]">
|
||||||
|
{item.layerLevel}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-2 font-mono">
|
||||||
|
{item.wmsLayerNm ?? <span className="text-text-3">-</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-text-3 text-center font-mono">
|
||||||
|
{item.sortOrd}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-3 font-mono">
|
||||||
|
{item.regDtm ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(item.layerCd)}
|
||||||
|
disabled={toggling === item.layerCd}
|
||||||
|
title={item.useYn === 'Y' ? '사용 중 (클릭하여 비활성화)' : '미사용 (클릭하여 활성화)'}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${
|
||||||
|
item.useYn === 'Y'
|
||||||
|
? 'bg-primary-cyan'
|
||||||
|
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||||
|
item.useYn === 'Y' ? 'translate-x-[18px]' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{!loading && totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-t border-border bg-bg-1 shrink-0">
|
||||||
|
<span className="text-[11px] text-text-3 font-korean">
|
||||||
|
{(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} / {total}개
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
{buildPageButtons().map((btn, i) =>
|
||||||
|
btn === 'ellipsis' ? (
|
||||||
|
<span key={`e${i}`} className="px-1.5 text-[11px] text-text-3">…</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={btn}
|
||||||
|
onClick={() => setPage(btn)}
|
||||||
|
className={`px-2.5 py-1 text-[11px] rounded transition-all ${
|
||||||
|
page === btn
|
||||||
|
? 'bg-primary-cyan text-bg-0 font-semibold shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||||
|
: 'border border-border text-text-3 hover:bg-[rgba(255,255,255,0.04)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{btn}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SensitiveLayerPanel;
|
||||||
255
frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { fetchOrganizations } from '@tabs/assets/services/assetsApi';
|
||||||
|
import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi';
|
||||||
|
import { typeTagCls } from '@tabs/assets/components/assetTypes';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const regionShort = (j: string) =>
|
||||||
|
j.includes('남해') ? '남해청' : j.includes('서해') ? '서해청' :
|
||||||
|
j.includes('중부') ? '중부청' : j.includes('동해') ? '동해청' :
|
||||||
|
j.includes('제주') ? '제주청' : j;
|
||||||
|
|
||||||
|
function VesselMaterialsPanel() {
|
||||||
|
const [organizations, setOrganizations] = useState<AssetOrgCompat[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [regionFilter, setRegionFilter] = useState('전체');
|
||||||
|
const [typeFilter, setTypeFilter] = useState('전체');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
setLoading(true);
|
||||||
|
fetchOrganizations()
|
||||||
|
.then(setOrganizations)
|
||||||
|
.catch(err => console.error('[VesselMaterialsPanel] 데이터 로드 실패:', err))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchOrganizations()
|
||||||
|
.then(data => { if (!cancelled) setOrganizations(data); })
|
||||||
|
.catch(err => console.error('[VesselMaterialsPanel] 데이터 로드 실패:', err))
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const typeOptions = useMemo(() => {
|
||||||
|
const set = new Set(organizations.map(o => o.type));
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [organizations]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() =>
|
||||||
|
organizations
|
||||||
|
.filter(o => o.vessel > 0)
|
||||||
|
.filter(o => regionFilter === '전체' || o.jurisdiction.includes(regionFilter))
|
||||||
|
.filter(o => typeFilter === '전체' || o.type === typeFilter)
|
||||||
|
.filter(o => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm)),
|
||||||
|
[organizations, regionFilter, typeFilter, searchTerm]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const safePage = Math.min(currentPage, totalPages);
|
||||||
|
const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
|
||||||
|
|
||||||
|
const handleFilterChange = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setter(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageNumbers = (() => {
|
||||||
|
const range: number[] = [];
|
||||||
|
const start = Math.max(1, safePage - 2);
|
||||||
|
const end = Math.min(totalPages, safePage + 2);
|
||||||
|
for (let i = start; i <= end; i++) range.push(i);
|
||||||
|
return range;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-text-1 font-korean">방제선 보유자재 현황</h1>
|
||||||
|
<p className="text-xs text-text-3 mt-1 font-korean">총 {filtered.length}개 기관 (방제선 보유)</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={regionFilter}
|
||||||
|
onChange={handleFilterChange(setRegionFilter)}
|
||||||
|
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||||
|
>
|
||||||
|
<option value="전체">전체 관할청</option>
|
||||||
|
<option value="남해">남해청</option>
|
||||||
|
<option value="서해">서해청</option>
|
||||||
|
<option value="중부">중부청</option>
|
||||||
|
<option value="동해">동해청</option>
|
||||||
|
<option value="제주">제주청</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={handleFilterChange(setTypeFilter)}
|
||||||
|
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||||
|
>
|
||||||
|
<option value="전체">전체 유형</option>
|
||||||
|
{typeOptions.map(t => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="기관명, 주소 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||||
|
className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={load}
|
||||||
|
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-2 border border-border text-text-2 hover:border-primary-cyan hover:text-primary-cyan transition-all font-korean"
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">
|
||||||
|
불러오는 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-bg-1">
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10 whitespace-nowrap">번호</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">유형</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">관할청</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">기관명</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">주소</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-primary-cyan bg-primary-cyan/5">방제선</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-text-3">유회수기</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-text-3">이송펌프</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-text-3">방제차량</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-text-3">살포장치</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">총자산</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paged.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11} className="px-6 py-10 text-center text-xs text-text-3 font-korean">
|
||||||
|
조회된 기관이 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : paged.map((org, idx) => (
|
||||||
|
<tr key={org.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-3 font-mono text-center">
|
||||||
|
{(safePage - 1) * PAGE_SIZE + idx + 1}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}>
|
||||||
|
{org.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-2 font-korean">
|
||||||
|
{regionShort(org.jurisdiction)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-1 font-korean font-semibold">
|
||||||
|
{org.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-3 font-korean max-w-[200px] truncate">
|
||||||
|
{org.address}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] font-mono text-center text-primary-cyan font-semibold bg-primary-cyan/5">
|
||||||
|
{org.vessel > 0 ? org.vessel : <span className="text-text-3">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||||
|
{org.skimmer > 0 ? org.skimmer : <span className="text-text-3">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||||
|
{org.pump > 0 ? org.pump : <span className="text-text-3">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||||
|
{org.vehicle > 0 ? org.vehicle : <span className="text-text-3">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||||
|
{org.sprayer > 0 ? org.sprayer : <span className="text-text-3">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-primary-cyan">
|
||||||
|
{org.totalAssets.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 합계 */}
|
||||||
|
{!loading && filtered.length > 0 && (
|
||||||
|
<div className="flex items-center gap-4 px-6 py-2 border-t border-border bg-bg-0/80">
|
||||||
|
<span className="text-[10px] text-text-3 font-korean font-semibold mr-auto">
|
||||||
|
합계 ({filtered.length}개 기관)
|
||||||
|
</span>
|
||||||
|
{[
|
||||||
|
{ label: '방제선', value: filtered.reduce((s, o) => s + o.vessel, 0), unit: '척', active: true },
|
||||||
|
{ label: '유회수기', value: filtered.reduce((s, o) => s + o.skimmer, 0), unit: '대', active: false },
|
||||||
|
{ label: '이송펌프', value: filtered.reduce((s, o) => s + o.pump, 0), unit: '대', active: false },
|
||||||
|
{ label: '방제차량', value: filtered.reduce((s, o) => s + o.vehicle, 0), unit: '대', active: false },
|
||||||
|
{ label: '살포장치', value: filtered.reduce((s, o) => s + o.sprayer, 0), unit: '대', active: false },
|
||||||
|
{ label: '총자산', value: filtered.reduce((s, o) => s + o.totalAssets, 0), unit: '', active: true },
|
||||||
|
].map((t) => (
|
||||||
|
<div key={t.label} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${t.active ? 'bg-primary-cyan/10' : ''}`}>
|
||||||
|
<span className={`text-[9px] font-korean ${t.active ? 'text-primary-cyan' : 'text-text-3'}`}>{t.label}</span>
|
||||||
|
<span className={`text-[10px] font-mono font-bold ${t.active ? 'text-primary-cyan' : 'text-text-1'}`}>
|
||||||
|
{t.value.toLocaleString()}{t.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{!loading && filtered.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-t border-border">
|
||||||
|
<span className="text-[11px] text-text-3 font-korean">
|
||||||
|
{(safePage - 1) * PAGE_SIZE + 1}–{Math.min(safePage * PAGE_SIZE, filtered.length)} / 전체 {filtered.length}개
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={safePage === 1}
|
||||||
|
className="px-2.5 py-1 text-[11px] border border-border rounded text-text-2 hover:border-primary-cyan hover:text-primary-cyan disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</button>
|
||||||
|
{pageNumbers.map(p => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setCurrentPage(p)}
|
||||||
|
className="px-2.5 py-1 text-[11px] border rounded transition-colors"
|
||||||
|
style={p === safePage
|
||||||
|
? { borderColor: 'var(--cyan)', color: 'var(--cyan)', background: 'rgba(6,182,212,0.1)' }
|
||||||
|
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={safePage === totalPages}
|
||||||
|
className="px-2.5 py-1 text-[11px] border border-border rounded text-text-2 hover:border-primary-cyan hover:text-primary-cyan disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VesselMaterialsPanel;
|
||||||
@ -54,7 +54,6 @@ export const ADMIN_MENU: AdminMenuItem[] = [
|
|||||||
{ id: 'asset-upload', label: '자산현행화' },
|
{ id: 'asset-upload', label: '자산현행화' },
|
||||||
{ id: 'dispersant-zone', label: '유처리제 제한구역' },
|
{ id: 'dispersant-zone', label: '유처리제 제한구역' },
|
||||||
{ id: 'vessel-materials', label: '방제선 보유자재' },
|
{ id: 'vessel-materials', label: '방제선 보유자재' },
|
||||||
{ id: 'cleanup-resource', label: '방제자원' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -70,7 +70,8 @@ export function IncidentsLeftPanel({
|
|||||||
// Weather popup
|
// Weather popup
|
||||||
const [weatherPopupId, setWeatherPopupId] = useState<string | null>(null)
|
const [weatherPopupId, setWeatherPopupId] = useState<string | null>(null)
|
||||||
const [weatherPos, setWeatherPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
|
const [weatherPos, setWeatherPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
|
||||||
const [weatherInfo, setWeatherInfo] = useState<WeatherInfo | null>(null)
|
// undefined = 로딩 중, null = 데이터 없음, WeatherInfo = 데이터 있음
|
||||||
|
const [weatherInfo, setWeatherInfo] = useState<WeatherInfo | null | undefined>(undefined)
|
||||||
const weatherRef = useRef<HTMLDivElement>(null)
|
const weatherRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -79,7 +80,7 @@ export function IncidentsLeftPanel({
|
|||||||
fetchIncidentWeather(parseInt(weatherPopupId)).then((data) => {
|
fetchIncidentWeather(parseInt(weatherPopupId)).then((data) => {
|
||||||
if (!cancelled) setWeatherInfo(data)
|
if (!cancelled) setWeatherInfo(data)
|
||||||
})
|
})
|
||||||
return () => { cancelled = true; setWeatherInfo(null) }
|
return () => { cancelled = true; setWeatherInfo(undefined) }
|
||||||
}, [weatherPopupId]);
|
}, [weatherPopupId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -361,7 +362,7 @@ export function IncidentsLeftPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Weather Popup (fixed position) */}
|
{/* Weather Popup (fixed position) */}
|
||||||
{weatherPopupId && weatherInfo && (
|
{weatherPopupId && weatherInfo !== undefined && (
|
||||||
<WeatherPopup
|
<WeatherPopup
|
||||||
ref={weatherRef}
|
ref={weatherRef}
|
||||||
data={weatherInfo}
|
data={weatherInfo}
|
||||||
@ -412,10 +413,11 @@ function PgBtn({ label, active, disabled, onClick }: { label: string; active?: b
|
|||||||
WeatherPopup – 사고 위치 기상정보 팝업
|
WeatherPopup – 사고 위치 기상정보 팝업
|
||||||
════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════ */
|
||||||
const WeatherPopup = forwardRef<HTMLDivElement, {
|
const WeatherPopup = forwardRef<HTMLDivElement, {
|
||||||
data: WeatherInfo
|
data: WeatherInfo | null
|
||||||
position: { top: number; left: number }
|
position: { top: number; left: number }
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}>(({ data, position, onClose }, ref) => {
|
}>(({ data, position, onClose }, ref) => {
|
||||||
|
const forecast = data?.forecast ?? []
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="fixed overflow-hidden rounded-xl border border-border bg-bg-1" style={{
|
<div ref={ref} className="fixed overflow-hidden rounded-xl border border-border bg-bg-1" style={{
|
||||||
zIndex: 9990, width: 280,
|
zIndex: 9990, width: 280,
|
||||||
@ -429,8 +431,8 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm">🌤</span>
|
<span className="text-sm">🌤</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-bold">{data.locNm}</div>
|
<div className="text-[11px] font-bold">{data?.locNm || '기상정보 없음'}</div>
|
||||||
<div className="text-text-3 font-mono text-[8px]">{data.obsDtm}</div>
|
<div className="text-text-3 font-mono text-[8px]">{data?.obsDtm || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span onClick={onClose} className="cursor-pointer text-text-3 text-sm p-0.5">✕</span>
|
<span onClick={onClose} className="cursor-pointer text-text-3 text-sm p-0.5">✕</span>
|
||||||
@ -440,21 +442,21 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
<div className="px-3.5 py-3">
|
<div className="px-3.5 py-3">
|
||||||
{/* Main weather */}
|
{/* Main weather */}
|
||||||
<div className="flex items-center gap-3 mb-2.5">
|
<div className="flex items-center gap-3 mb-2.5">
|
||||||
<div className="text-[28px]">{data.icon}</div>
|
<div className="text-[28px]">{data?.icon || '❓'}</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold font-mono text-[20px]">{data.temp}</div>
|
<div className="font-bold font-mono text-[20px]">{data?.temp || '-'}</div>
|
||||||
<div className="text-text-3 text-[9px]">{data.weatherDc}</div>
|
<div className="text-text-3 text-[9px]">{data?.weatherDc || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail grid */}
|
{/* Detail grid */}
|
||||||
<div className="grid grid-cols-2 gap-1.5 text-[9px]">
|
<div className="grid grid-cols-2 gap-1.5 text-[9px]">
|
||||||
<WxCell icon="💨" label="풍향/풍속" value={data.wind} />
|
<WxCell icon="💨" label="풍향/풍속" value={data?.wind} />
|
||||||
<WxCell icon="🌊" label="파고" value={data.wave} />
|
<WxCell icon="🌊" label="파고" value={data?.wave} />
|
||||||
<WxCell icon="💧" label="습도" value={data.humid} />
|
<WxCell icon="💧" label="습도" value={data?.humid} />
|
||||||
<WxCell icon="👁" label="시정" value={data.vis} />
|
<WxCell icon="👁" label="시정" value={data?.vis} />
|
||||||
<WxCell icon="🌡" label="수온" value={data.sst} />
|
<WxCell icon="🌡" label="수온" value={data?.sst} />
|
||||||
<WxCell icon="🔄" label="조류" value={data.tide} />
|
<WxCell icon="🔄" label="조류" value={data?.tide} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tide info */}
|
{/* Tide info */}
|
||||||
@ -464,7 +466,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
<span className="text-xs">⬆</span>
|
<span className="text-xs">⬆</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-text-3 text-[7px]">고조 (만조)</div>
|
<div className="text-text-3 text-[7px]">고조 (만조)</div>
|
||||||
<div className="font-bold font-mono text-[10px] text-[#60a5fa]">{data.highTide}</div>
|
<div className="font-bold font-mono text-[10px] text-[#60a5fa]">{data?.highTide || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center gap-1.5 px-2 py-1.5 rounded-md"
|
<div className="flex-1 flex items-center gap-1.5 px-2 py-1.5 rounded-md"
|
||||||
@ -472,7 +474,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
<span className="text-xs">⬇</span>
|
<span className="text-xs">⬇</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-text-3 text-[7px]">저조 (간조)</div>
|
<div className="text-text-3 text-[7px]">저조 (간조)</div>
|
||||||
<div className="text-primary-cyan font-bold font-mono text-[10px]">{data.lowTide}</div>
|
<div className="text-primary-cyan font-bold font-mono text-[10px]">{data?.lowTide || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -480,8 +482,9 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
{/* 24h Forecast */}
|
{/* 24h Forecast */}
|
||||||
<div className="bg-bg-0 mt-2.5 px-2.5 py-2 rounded-md">
|
<div className="bg-bg-0 mt-2.5 px-2.5 py-2 rounded-md">
|
||||||
<div className="font-bold text-text-3 text-[8px] mb-1.5">24h 예보</div>
|
<div className="font-bold text-text-3 text-[8px] mb-1.5">24h 예보</div>
|
||||||
|
{forecast.length > 0 ? (
|
||||||
<div className="flex justify-between font-mono text-text-2 text-[8px]">
|
<div className="flex justify-between font-mono text-text-2 text-[8px]">
|
||||||
{data.forecast.map((f, i) => (
|
{forecast.map((f, i) => (
|
||||||
<div key={i} className="text-center">
|
<div key={i} className="text-center">
|
||||||
<div>{f.hour}</div>
|
<div>{f.hour}</div>
|
||||||
<div className="text-xs my-0.5">{f.icon}</div>
|
<div className="text-xs my-0.5">{f.icon}</div>
|
||||||
@ -489,6 +492,9 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-text-3 text-center text-[8px] py-1">예보 데이터 없음</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Impact */}
|
{/* Impact */}
|
||||||
@ -497,7 +503,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)',
|
background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)',
|
||||||
}}>
|
}}>
|
||||||
<div className="font-bold text-status-orange text-[8px] mb-[3px]">⚠ 방제 작업 영향</div>
|
<div className="font-bold text-status-orange text-[8px] mb-[3px]">⚠ 방제 작업 영향</div>
|
||||||
<div className="text-text-2 text-[8px] leading-[1.5]">{data.impactDc}</div>
|
<div className="text-text-2 text-[8px] leading-[1.5]">{data?.impactDc || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -505,13 +511,13 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
|
|||||||
})
|
})
|
||||||
WeatherPopup.displayName = 'WeatherPopup'
|
WeatherPopup.displayName = 'WeatherPopup'
|
||||||
|
|
||||||
function WxCell({ icon, label, value }: { icon: string; label: string; value: string }) {
|
function WxCell({ icon, label, value }: { icon: string; label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center bg-bg-0 rounded gap-[6px] py-1.5 px-2">
|
<div className="flex items-center bg-bg-0 rounded gap-[6px] py-1.5 px-2">
|
||||||
<span className="text-[12px]">{icon}</span>
|
<span className="text-[12px]">{icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-text-3 text-[7px]">{label}</div>
|
<div className="text-text-3 text-[7px]">{label}</div>
|
||||||
<div className="font-semibold font-mono">{value}</div>
|
<div className="font-semibold font-mono">{value || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState } from 'react'
|
||||||
import { LayerTree } from '@common/components/layer/LayerTree'
|
import { LayerTree } from '@common/components/layer/LayerTree'
|
||||||
import { useLayerTree } from '@common/hooks/useLayers'
|
import { useLayerTree } from '@common/hooks/useLayers'
|
||||||
import { layerData } from '@common/data/layerData'
|
|
||||||
import type { LayerNode } from '@common/data/layerData'
|
|
||||||
import type { Layer } from '@common/services/layerService'
|
import type { Layer } from '@common/services/layerService'
|
||||||
|
|
||||||
interface InfoLayerSectionProps {
|
interface InfoLayerSectionProps {
|
||||||
@ -26,29 +24,13 @@ const InfoLayerSection = ({
|
|||||||
layerBrightness,
|
layerBrightness,
|
||||||
onLayerBrightnessChange,
|
onLayerBrightnessChange,
|
||||||
}: InfoLayerSectionProps) => {
|
}: InfoLayerSectionProps) => {
|
||||||
// API에서 레이어 트리 데이터 가져오기
|
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
|
||||||
const { data: layerTree, isLoading } = useLayerTree()
|
const { data: layerTree, isLoading } = useLayerTree()
|
||||||
|
|
||||||
const [layerColors, setLayerColors] = useState<Record<string, string>>({})
|
const [layerColors, setLayerColors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
// 정적 데이터를 Layer 형식으로 변환 (API 실패 시 폴백)
|
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
|
||||||
const staticLayers = useMemo(() => {
|
const effectiveLayers: Layer[] = layerTree ?? []
|
||||||
const convert = (node: LayerNode): Layer => ({
|
|
||||||
id: node.code,
|
|
||||||
parentId: node.parentCode,
|
|
||||||
name: node.name,
|
|
||||||
fullName: node.fullName,
|
|
||||||
level: node.level,
|
|
||||||
wmsLayer: node.layerName,
|
|
||||||
icon: node.icon,
|
|
||||||
count: node.count,
|
|
||||||
children: node.children?.map(convert),
|
|
||||||
})
|
|
||||||
return layerData.map(convert)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// API 데이터 우선, 실패 시 정적 데이터 폴백
|
|
||||||
const effectiveLayers = (layerTree && layerTree.length > 0) ? layerTree : staticLayers
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border">
|
<div className="border-b border-border">
|
||||||
|
|||||||
@ -1,5 +1,63 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { LeftPanelProps, ExpandedSections } from './leftPanelTypes'
|
import type { LeftPanelProps, ExpandedSections } from './leftPanelTypes'
|
||||||
|
|
||||||
|
interface CategoryMeta {
|
||||||
|
icon: string;
|
||||||
|
bg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_ICON_MAP: Record<string, CategoryMeta> = {
|
||||||
|
// 수산자원 / 양식장 (green)
|
||||||
|
'어장정보': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'양식장': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'양식어업': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'어류양식장': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'패류양식장': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'해조류양식장': { icon: '🌿', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'가두리양식장': { icon: '🔲', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'갑각류양식장': { icon: '🦐', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'기타양식장': { icon: '📦', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'영세어업': { icon: '🎣', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'유어장': { icon: '🎣', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'수산시장': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'인공어초': { icon: '🪸', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'암초': { icon: '🪨', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
'침선': { icon: '🚢', bg: 'rgba(34,197,94,0.15)' },
|
||||||
|
// 관광자원 / 낚시 (yellow)
|
||||||
|
'해수욕장': { icon: '🏖', bg: 'rgba(250,204,21,0.15)' },
|
||||||
|
'갯바위낚시': { icon: '🪨', bg: 'rgba(250,204,21,0.15)' },
|
||||||
|
'선상낚시': { icon: '🚤', bg: 'rgba(250,204,21,0.15)' },
|
||||||
|
'마리나항': { icon: '⛵', bg: 'rgba(250,204,21,0.15)' },
|
||||||
|
// 항만 / 산업시설 (blue)
|
||||||
|
'무역항': { icon: '🚢', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'연안항': { icon: '⛵', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'국가어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'지방어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'항만구역': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'항로': { icon: '🚢', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'정박지': { icon: '⛵', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'항로표지': { icon: '🔴', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'해수취수시설': { icon: '💧', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'취수구·배수구': { icon: '🚰', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'LNG': { icon: '⚡', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'발전소': { icon: '🔌', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'발전소·산단': { icon: '🏭', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'임해공단': { icon: '🏭', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'저유시설': { icon: '🛢', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
'해저케이블·배관': { icon: '🔌', bg: 'rgba(99,179,237,0.15)' },
|
||||||
|
// 환경 / 생태 (lime)
|
||||||
|
'갯벌': { icon: '🪨', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
'해안선_ESI': { icon: '🏖', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
'보호지역': { icon: '🛡', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
'해양보호구역': { icon: '🌿', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
'철새도래지': { icon: '🐦', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
'습지보호구역': { icon: '🏖', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
'보호종서식지': { icon: '🐢', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
'보호종 서식지': { icon: '🐢', bg: 'rgba(163,230,53,0.12)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_META: CategoryMeta = { icon: '🌊', bg: 'rgba(148,163,184,0.15)' };
|
||||||
import PredictionInputSection from './PredictionInputSection'
|
import PredictionInputSection from './PredictionInputSection'
|
||||||
import InfoLayerSection from './InfoLayerSection'
|
import InfoLayerSection from './InfoLayerSection'
|
||||||
import OilBoomSection from './OilBoomSection'
|
import OilBoomSection from './OilBoomSection'
|
||||||
@ -50,6 +108,7 @@ export function LeftPanel({
|
|||||||
onLayerOpacityChange,
|
onLayerOpacityChange,
|
||||||
layerBrightness,
|
layerBrightness,
|
||||||
onLayerBrightnessChange,
|
onLayerBrightnessChange,
|
||||||
|
sensitiveResources = [],
|
||||||
onImageAnalysisResult,
|
onImageAnalysisResult,
|
||||||
}: LeftPanelProps) {
|
}: LeftPanelProps) {
|
||||||
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
|
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
|
||||||
@ -160,7 +219,7 @@ export function LeftPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고일시</span>
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고일시</span>
|
||||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16) : '—'}</span>
|
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16).replace(' ', 'T') : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유종</span>
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유종</span>
|
||||||
@ -204,7 +263,33 @@ export function LeftPanel({
|
|||||||
|
|
||||||
{expandedSections.impactResources && (
|
{expandedSections.impactResources && (
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<p className="text-[11px] text-text-3">영향받는 민감자원 목록</p>
|
{sensitiveResources.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-text-3 font-korean">영향받는 민감자원 목록</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{sensitiveResources.map(({ category, count, totalArea }) => {
|
||||||
|
const meta = CATEGORY_ICON_MAP[category] ?? FALLBACK_META;
|
||||||
|
return (
|
||||||
|
<div key={category} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center justify-center w-5 h-5 rounded text-[11px] shrink-0"
|
||||||
|
style={{ background: meta.bg }}
|
||||||
|
>
|
||||||
|
{meta.icon}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-text-2 font-korean">{category}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-primary font-bold font-mono">
|
||||||
|
{totalArea != null
|
||||||
|
? `${totalArea.toLocaleString('ko-KR', { maximumFractionDigits: 2 })} ha`
|
||||||
|
: `${count}개`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,11 +9,13 @@ import { BacktrackModal } from './BacktrackModal'
|
|||||||
import { RecalcModal } from './RecalcModal'
|
import { RecalcModal } from './RecalcModal'
|
||||||
import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar'
|
import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar'
|
||||||
import { useSubMenu, navigateToTab, setReportGenCategory, setOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu'
|
import { useSubMenu, navigateToTab, setReportGenCategory, setOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu'
|
||||||
|
import { fetchWeatherSnapshotForCoord } from '@tabs/weather/services/weatherUtils'
|
||||||
|
import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore'
|
||||||
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
|
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
|
||||||
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||||
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
|
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
|
||||||
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi'
|
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '../services/predictionApi'
|
||||||
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, WindPoint } from '../services/predictionApi'
|
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, SensitiveResourceCategory, SensitiveResourceFeatureCollection, WindPoint } from '../services/predictionApi'
|
||||||
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
|
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
|
||||||
import SimulationErrorModal from './SimulationErrorModal'
|
import SimulationErrorModal from './SimulationErrorModal'
|
||||||
import { api } from '@common/services/api'
|
import { api } from '@common/services/api'
|
||||||
@ -22,6 +24,13 @@ import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
|
|||||||
|
|
||||||
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
||||||
|
|
||||||
|
const toLocalDateTimeStr = (raw: string): string => {
|
||||||
|
const d = new Date(raw)
|
||||||
|
if (isNaN(d.getTime())) return ''
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 민감자원 타입 + 데모 데이터
|
// 민감자원 타입 + 데모 데이터
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -40,16 +49,9 @@ export interface DisplayControls {
|
|||||||
showWind: boolean; // 풍향/풍속
|
showWind: boolean; // 풍향/풍속
|
||||||
showBeached: boolean; // 해안부착
|
showBeached: boolean; // 해안부착
|
||||||
showTimeLabel: boolean; // 시간 표시
|
showTimeLabel: boolean; // 시간 표시
|
||||||
|
showSensitiveResources: boolean; // 민감자원
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [
|
|
||||||
{ id: 'bc-1', name: '종포 해수욕장', type: 'beach', lat: 34.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 },
|
|
||||||
{ id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 },
|
|
||||||
{ id: 'ec-1', name: '여자만 습지보호구역', type: 'ecology', lat: 34.758, lon: 127.614, radiusM: 1200, arrivalTimeH: 6 },
|
|
||||||
{ id: 'aq-2', name: '화태도 김 양식장', type: 'aquaculture', lat: 34.648, lon: 127.652, radiusM: 800, arrivalTimeH: 10 },
|
|
||||||
{ id: 'aq-3', name: '개도 해안 양식장', type: 'aquaculture', lat: 34.612, lon: 127.636, radiusM: 600, arrivalTimeH: 18 },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 데모 궤적 생성 (seeded PRNG — deterministic)
|
// 데모 궤적 생성 (seeded PRNG — deterministic)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -137,6 +139,8 @@ export function OilSpillView() {
|
|||||||
|
|
||||||
// 민감자원
|
// 민감자원
|
||||||
const [sensitiveResources, setSensitiveResources] = useState<SensitiveResource[]>([])
|
const [sensitiveResources, setSensitiveResources] = useState<SensitiveResource[]>([])
|
||||||
|
const [sensitiveResourceCategories, setSensitiveResourceCategories] = useState<SensitiveResourceCategory[]>([])
|
||||||
|
const [sensitiveResourceGeojson, setSensitiveResourceGeojson] = useState<SensitiveResourceFeatureCollection | null>(null)
|
||||||
|
|
||||||
// 오일펜스 배치 상태
|
// 오일펜스 배치 상태
|
||||||
const [boomLines, setBoomLines] = useState<BoomLine[]>([])
|
const [boomLines, setBoomLines] = useState<BoomLine[]>([])
|
||||||
@ -160,6 +164,7 @@ export function OilSpillView() {
|
|||||||
showWind: false,
|
showWind: false,
|
||||||
showBeached: false,
|
showBeached: false,
|
||||||
showTimeLabel: false,
|
showTimeLabel: false,
|
||||||
|
showSensitiveResources: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 타임라인 플레이어 상태
|
// 타임라인 플레이어 상태
|
||||||
@ -195,6 +200,13 @@ export function OilSpillView() {
|
|||||||
const [summaryByModel, setSummaryByModel] = useState<Record<string, SimulationSummary>>({})
|
const [summaryByModel, setSummaryByModel] = useState<Record<string, SimulationSummary>>({})
|
||||||
const [stepSummariesByModel, setStepSummariesByModel] = useState<Record<string, SimulationSummary[]>>({})
|
const [stepSummariesByModel, setStepSummariesByModel] = useState<Record<string, SimulationSummary[]>>({})
|
||||||
|
|
||||||
|
// 펜스차단량 계산 (오일붐 차단 효율 × 총 유류량)
|
||||||
|
const boomBlockedVolume = useMemo(() => {
|
||||||
|
if (!containmentResult || !simulationSummary) return 0;
|
||||||
|
const totalVolumeM3 = simulationSummary.remainingVolume + simulationSummary.weatheredVolume + simulationSummary.beachedVolume;
|
||||||
|
return totalVolumeM3 * (containmentResult.overallEfficiency / 100);
|
||||||
|
}, [containmentResult, simulationSummary])
|
||||||
|
|
||||||
// 오염분석 상태
|
// 오염분석 상태
|
||||||
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
|
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
|
||||||
const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null)
|
const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null)
|
||||||
@ -215,7 +227,7 @@ export function OilSpillView() {
|
|||||||
setOilTrajectory(demoTrajectory)
|
setOilTrajectory(demoTrajectory)
|
||||||
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
|
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
|
||||||
setBoomLines(demoBooms)
|
setBoomLines(demoBooms)
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
setSensitiveResources([])
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeSubTab])
|
}, [activeSubTab])
|
||||||
@ -467,7 +479,7 @@ export function OilSpillView() {
|
|||||||
setSelectedAnalysis(analysis)
|
setSelectedAnalysis(analysis)
|
||||||
setCenterPoints([])
|
setCenterPoints([])
|
||||||
if (analysis.occurredAt) {
|
if (analysis.occurredAt) {
|
||||||
setAccidentTime(analysis.occurredAt.slice(0, 16))
|
setAccidentTime(toLocalDateTimeStr(analysis.occurredAt))
|
||||||
}
|
}
|
||||||
if (analysis.lon != null && analysis.lat != null) {
|
if (analysis.lon != null && analysis.lat != null) {
|
||||||
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
|
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
|
||||||
@ -517,7 +529,13 @@ export function OilSpillView() {
|
|||||||
if (sbModel) setSummaryByModel(sbModel);
|
if (sbModel) setSummaryByModel(sbModel);
|
||||||
if (stepSbModel) setStepSummariesByModel(stepSbModel);
|
if (stepSbModel) setStepSummariesByModel(stepSbModel);
|
||||||
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
|
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
setSensitiveResources([])
|
||||||
|
fetchSensitiveResources(analysis.acdntSn)
|
||||||
|
.then(setSensitiveResourceCategories)
|
||||||
|
.catch(err => console.warn('[prediction] 민감자원 조회 실패:', err))
|
||||||
|
fetchSensitiveResourcesGeojson(analysis.acdntSn)
|
||||||
|
.then(setSensitiveResourceGeojson)
|
||||||
|
.catch(err => console.warn('[prediction] 민감자원 GeoJSON 조회 실패:', err))
|
||||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||||
pendingPlayRef.current = true
|
pendingPlayRef.current = true
|
||||||
@ -539,7 +557,8 @@ export function OilSpillView() {
|
|||||||
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
|
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
|
||||||
setOilTrajectory(demoTrajectory)
|
setOilTrajectory(demoTrajectory)
|
||||||
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
|
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
setSensitiveResources([])
|
||||||
|
setSensitiveResourceCategories([])
|
||||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||||
pendingPlayRef.current = true
|
pendingPlayRef.current = true
|
||||||
@ -553,7 +572,7 @@ export function OilSpillView() {
|
|||||||
setDrawingPoints(prev => [...prev, { lat, lon }])
|
setDrawingPoints(prev => [...prev, { lat, lon }])
|
||||||
} else if (drawAnalysisMode === 'polygon') {
|
} else if (drawAnalysisMode === 'polygon') {
|
||||||
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
|
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
|
||||||
} else {
|
} else if (isSelectingLocation) {
|
||||||
setIncidentCoord({ lon, lat })
|
setIncidentCoord({ lon, lat })
|
||||||
setIsSelectingLocation(false)
|
setIsSelectingLocation(false)
|
||||||
}
|
}
|
||||||
@ -565,7 +584,7 @@ export function OilSpillView() {
|
|||||||
setAnalysisResult(null)
|
setAnalysisResult(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRunPolygonAnalysis = () => {
|
const handleRunPolygonAnalysis = async () => {
|
||||||
if (analysisPolygonPoints.length < 3) return
|
if (analysisPolygonPoints.length < 3) return
|
||||||
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
|
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
|
||||||
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
|
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
|
||||||
@ -580,7 +599,7 @@ export function OilSpillView() {
|
|||||||
setDrawAnalysisMode(null)
|
setDrawAnalysisMode(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRunCircleAnalysis = () => {
|
const handleRunCircleAnalysis = async () => {
|
||||||
if (!incidentCoord) return
|
if (!incidentCoord) return
|
||||||
const radiusM = circleRadiusNm * 1852
|
const radiusM = circleRadiusNm * 1852
|
||||||
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
|
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
|
||||||
@ -613,7 +632,7 @@ export function OilSpillView() {
|
|||||||
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
|
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
|
||||||
setIncidentCoord({ lat: result.lat, lon: result.lon })
|
setIncidentCoord({ lat: result.lat, lon: result.lon })
|
||||||
setFlyToCoord({ lat: result.lat, lon: result.lon })
|
setFlyToCoord({ lat: result.lat, lon: result.lon })
|
||||||
setAccidentTime(result.occurredAt.slice(0, 16))
|
setAccidentTime(toLocalDateTimeStr(result.occurredAt))
|
||||||
setOilType(result.oilType)
|
setOilType(result.oilType)
|
||||||
setSpillAmount(parseFloat(result.volume.toFixed(4)))
|
setSpillAmount(parseFloat(result.volume.toFixed(4)))
|
||||||
setSpillUnit('kL')
|
setSpillUnit('kL')
|
||||||
@ -746,9 +765,10 @@ export function OilSpillView() {
|
|||||||
const newWindDataByModel: Record<string, WindPoint[][]> = {};
|
const newWindDataByModel: Record<string, WindPoint[][]> = {};
|
||||||
const newHydrDataByModel: Record<string, (HydrDataStep | null)[]> = {};
|
const newHydrDataByModel: Record<string, (HydrDataStep | null)[]> = {};
|
||||||
const newSummaryByModel: Record<string, SimulationSummary> = {};
|
const newSummaryByModel: Record<string, SimulationSummary> = {};
|
||||||
|
const newStepSummariesByModel: Record<string, SimulationSummary[]> = {};
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
data.results.forEach(({ model, status, trajectory, summary, centerPoints, windData, hydrData, error }) => {
|
data.results.forEach(({ model, status, trajectory, summary, stepSummaries, centerPoints, windData, hydrData, error }) => {
|
||||||
if (status === 'ERROR') {
|
if (status === 'ERROR') {
|
||||||
errors.push(error || `${model} 분석 중 오류가 발생했습니다.`);
|
errors.push(error || `${model} 분석 중 오류가 발생했습니다.`);
|
||||||
return;
|
return;
|
||||||
@ -760,6 +780,7 @@ export function OilSpillView() {
|
|||||||
newSummaryByModel[model] = summary;
|
newSummaryByModel[model] = summary;
|
||||||
if (model === 'OpenDrift' || !latestSummary) latestSummary = summary;
|
if (model === 'OpenDrift' || !latestSummary) latestSummary = summary;
|
||||||
}
|
}
|
||||||
|
if (stepSummaries) newStepSummariesByModel[model] = stepSummaries;
|
||||||
if (windData) newWindDataByModel[model] = windData;
|
if (windData) newWindDataByModel[model] = windData;
|
||||||
if (hydrData) newHydrDataByModel[model] = hydrData;
|
if (hydrData) newHydrDataByModel[model] = hydrData;
|
||||||
if (centerPoints) {
|
if (centerPoints) {
|
||||||
@ -788,9 +809,10 @@ export function OilSpillView() {
|
|||||||
setWindDataByModel(newWindDataByModel);
|
setWindDataByModel(newWindDataByModel);
|
||||||
setHydrDataByModel(newHydrDataByModel);
|
setHydrDataByModel(newHydrDataByModel);
|
||||||
setSummaryByModel(newSummaryByModel);
|
setSummaryByModel(newSummaryByModel);
|
||||||
|
setStepSummariesByModel(newStepSummariesByModel);
|
||||||
const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings);
|
const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings);
|
||||||
setBoomLines(booms);
|
setBoomLines(booms);
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
|
setSensitiveResources([]);
|
||||||
setCurrentStep(0);
|
setCurrentStep(0);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat });
|
setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat });
|
||||||
@ -800,6 +822,26 @@ export function OilSpillView() {
|
|||||||
setSimulationError(errors.join('; '));
|
setSimulationError(errors.join('; '));
|
||||||
} else {
|
} else {
|
||||||
simulationSucceeded = true;
|
simulationSucceeded = true;
|
||||||
|
const effectiveAcdntSn = data.acdntSn ?? selectedAnalysis?.acdntSn;
|
||||||
|
if (effectiveCoord) {
|
||||||
|
fetchWeatherSnapshotForCoord(effectiveCoord.lat, effectiveCoord.lon)
|
||||||
|
.then(snapshot => {
|
||||||
|
useWeatherSnapshotStore.getState().setSnapshot(snapshot);
|
||||||
|
if (effectiveAcdntSn) {
|
||||||
|
api.post(`/incidents/${effectiveAcdntSn}/weather`, snapshot)
|
||||||
|
.catch(err => console.warn('[weather] 기상 저장 실패:', err));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.warn('[weather] 기상 데이터 수집 실패:', err));
|
||||||
|
}
|
||||||
|
if (effectiveAcdntSn) {
|
||||||
|
fetchSensitiveResources(effectiveAcdntSn)
|
||||||
|
.then(setSensitiveResourceCategories)
|
||||||
|
.catch(err => console.warn('[prediction] 민감자원 조회 실패:', err));
|
||||||
|
fetchSensitiveResourcesGeojson(effectiveAcdntSn)
|
||||||
|
.then(setSensitiveResourceGeojson)
|
||||||
|
.catch(err => console.warn('[prediction] 민감자원 GeoJSON 조회 실패:', err));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg =
|
const msg =
|
||||||
@ -827,6 +869,7 @@ export function OilSpillView() {
|
|||||||
accidentTime ||
|
accidentTime ||
|
||||||
'';
|
'';
|
||||||
const wx = analysisDetail?.weather?.[0] ?? null;
|
const wx = analysisDetail?.weather?.[0] ?? null;
|
||||||
|
const weatherSnapshot = useWeatherSnapshotStore.getState().snapshot;
|
||||||
|
|
||||||
const payload: OilReportPayload = {
|
const payload: OilReportPayload = {
|
||||||
incident: {
|
incident: {
|
||||||
@ -854,9 +897,27 @@ export function OilSpillView() {
|
|||||||
coastLength: simulationSummary ? `${simulationSummary.pollutionCoastLength.toFixed(2)} km` : '—',
|
coastLength: simulationSummary ? `${simulationSummary.pollutionCoastLength.toFixed(2)} km` : '—',
|
||||||
oilType: OIL_TYPE_CODE[oilType] || oilType,
|
oilType: OIL_TYPE_CODE[oilType] || oilType,
|
||||||
},
|
},
|
||||||
weather: wx
|
weather: (() => {
|
||||||
? { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp }
|
if (weatherSnapshot) {
|
||||||
: null,
|
return {
|
||||||
|
windDir: `${weatherSnapshot.wind.directionLabel} ${weatherSnapshot.wind.direction}°`,
|
||||||
|
windSpeed: `${weatherSnapshot.wind.speed.toFixed(1)} m/s`,
|
||||||
|
waveHeight: `${weatherSnapshot.wave.height.toFixed(1)} m`,
|
||||||
|
temp: `${weatherSnapshot.temperature.current.toFixed(1)} °C`,
|
||||||
|
pressure: `${weatherSnapshot.pressure} hPa`,
|
||||||
|
visibility: `${weatherSnapshot.visibility} km`,
|
||||||
|
salinity: `${weatherSnapshot.salinity} PSU`,
|
||||||
|
waveMaxHeight: `${weatherSnapshot.wave.maxHeight.toFixed(1)} m`,
|
||||||
|
wavePeriod: `${weatherSnapshot.wave.period} s`,
|
||||||
|
currentDir: '',
|
||||||
|
currentSpeed: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (wx) {
|
||||||
|
return { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})(),
|
||||||
spread: (() => {
|
spread: (() => {
|
||||||
const fmt = (model: string) => {
|
const fmt = (model: string) => {
|
||||||
const s = summaryByModel[model];
|
const s = summaryByModel[model];
|
||||||
@ -884,6 +945,14 @@ export function OilSpillView() {
|
|||||||
return [toRow('3시간', steps[3]), toRow('6시간', steps[6])];
|
return [toRow('3시간', steps[3]), toRow('6시간', steps[6])];
|
||||||
})(),
|
})(),
|
||||||
hasSimulation: simulationSummary !== null,
|
hasSimulation: simulationSummary !== null,
|
||||||
|
sensitiveResources: sensitiveResourceCategories.length > 0
|
||||||
|
? sensitiveResourceCategories.map(r => ({
|
||||||
|
category: r.category,
|
||||||
|
count: r.count,
|
||||||
|
totalArea: r.totalArea,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
acdntSn: selectedAnalysis?.acdntSn ?? undefined,
|
||||||
mapData: incidentCoord ? {
|
mapData: incidentCoord ? {
|
||||||
center: [incidentCoord.lat, incidentCoord.lon],
|
center: [incidentCoord.lat, incidentCoord.lon],
|
||||||
zoom: 10,
|
zoom: 10,
|
||||||
@ -947,6 +1016,7 @@ export function OilSpillView() {
|
|||||||
onLayerOpacityChange={setLayerOpacity}
|
onLayerOpacityChange={setLayerOpacity}
|
||||||
layerBrightness={layerBrightness}
|
layerBrightness={layerBrightness}
|
||||||
onLayerBrightnessChange={setLayerBrightness}
|
onLayerBrightnessChange={setLayerBrightness}
|
||||||
|
sensitiveResources={sensitiveResourceCategories}
|
||||||
onImageAnalysisResult={handleImageAnalysisResult}
|
onImageAnalysisResult={handleImageAnalysisResult}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -975,6 +1045,7 @@ export function OilSpillView() {
|
|||||||
layerOpacity={layerOpacity}
|
layerOpacity={layerOpacity}
|
||||||
layerBrightness={layerBrightness}
|
layerBrightness={layerBrightness}
|
||||||
sensitiveResources={sensitiveResources}
|
sensitiveResources={sensitiveResources}
|
||||||
|
sensitiveResourceGeojson={displayControls.showSensitiveResources ? sensitiveResourceGeojson : null}
|
||||||
lightMode
|
lightMode
|
||||||
centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
|
centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
|
||||||
windData={windData}
|
windData={windData}
|
||||||
@ -1197,6 +1268,7 @@ export function OilSpillView() {
|
|||||||
onOpenReport={handleOpenReport}
|
onOpenReport={handleOpenReport}
|
||||||
detail={analysisDetail}
|
detail={analysisDetail}
|
||||||
summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary}
|
summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary}
|
||||||
|
boomBlockedVolume={boomBlockedVolume}
|
||||||
displayControls={displayControls}
|
displayControls={displayControls}
|
||||||
onDisplayControlsChange={setDisplayControls}
|
onDisplayControlsChange={setDisplayControls}
|
||||||
windHydrModel={windHydrModel}
|
windHydrModel={windHydrModel}
|
||||||
@ -1210,6 +1282,8 @@ export function OilSpillView() {
|
|||||||
onCircleRadiusChange={setCircleRadiusNm}
|
onCircleRadiusChange={setCircleRadiusNm}
|
||||||
analysisResult={analysisResult}
|
analysisResult={analysisResult}
|
||||||
incidentCoord={incidentCoord}
|
incidentCoord={incidentCoord}
|
||||||
|
centerPoints={centerPoints}
|
||||||
|
predictionTime={predictionTime}
|
||||||
onStartPolygonDraw={handleStartPolygonDraw}
|
onStartPolygonDraw={handleStartPolygonDraw}
|
||||||
onRunPolygonAnalysis={handleRunPolygonAnalysis}
|
onRunPolygonAnalysis={handleRunPolygonAnalysis}
|
||||||
onRunCircleAnalysis={handleRunCircleAnalysis}
|
onRunCircleAnalysis={handleRunCircleAnalysis}
|
||||||
|
|||||||
@ -430,7 +430,9 @@ function DateTimeInput({ value, onChange }: { value: string; onChange: (v: strin
|
|||||||
|
|
||||||
const datePart = value ? value.split('T')[0] : ''
|
const datePart = value ? value.split('T')[0] : ''
|
||||||
const timePart = value && value.includes('T') ? value.split('T')[1] : '00:00'
|
const timePart = value && value.includes('T') ? value.split('T')[1] : '00:00'
|
||||||
const [hh, mm] = timePart.split(':').map(Number)
|
const timeParts = timePart.split(':').map(Number)
|
||||||
|
const hh = isNaN(timeParts[0]) ? 0 : timeParts[0]
|
||||||
|
const mm = (timeParts[1] === undefined || isNaN(timeParts[1])) ? 0 : timeParts[1]
|
||||||
|
|
||||||
const parsed = datePart ? new Date(datePart + 'T00:00:00') : new Date()
|
const parsed = datePart ? new Date(datePart + 'T00:00:00') : new Date()
|
||||||
const [viewYear, setViewYear] = useState(parsed.getFullYear())
|
const [viewYear, setViewYear] = useState(parsed.getFullYear())
|
||||||
@ -561,9 +563,15 @@ function DateTimeInput({ value, onChange }: { value: string; onChange: (v: strin
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setViewYear(todayY)
|
const now = new Date()
|
||||||
setViewMonth(todayM)
|
setViewYear(now.getFullYear())
|
||||||
pickDate(todayD)
|
setViewMonth(now.getMonth())
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(now.getDate()).padStart(2, '0')
|
||||||
|
const hh = String(now.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(now.getMinutes()).padStart(2, '0')
|
||||||
|
onChange(`${now.getFullYear()}-${m}-${d}T${hh}:${mm}`)
|
||||||
|
setShowCal(false)
|
||||||
}}
|
}}
|
||||||
className="w-full text-[8px] font-korean font-semibold cursor-pointer rounded-sm"
|
className="w-full text-[8px] font-korean font-semibold cursor-pointer rounded-sm"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
|
import type { PredictionDetail, SimulationSummary, CenterPoint } from '../services/predictionApi'
|
||||||
import type { DisplayControls } from './OilSpillView'
|
import type { DisplayControls } from './OilSpillView'
|
||||||
|
import { haversineDistance, computeBearing } from '@common/utils/geo'
|
||||||
|
|
||||||
interface AnalysisResult {
|
interface AnalysisResult {
|
||||||
area: number
|
area: number
|
||||||
@ -29,6 +30,9 @@ interface RightPanelProps {
|
|||||||
onCircleRadiusChange?: (nm: number) => void
|
onCircleRadiusChange?: (nm: number) => void
|
||||||
analysisResult?: AnalysisResult | null
|
analysisResult?: AnalysisResult | null
|
||||||
incidentCoord?: { lat: number; lon: number } | null
|
incidentCoord?: { lat: number; lon: number } | null
|
||||||
|
centerPoints?: CenterPoint[]
|
||||||
|
predictionTime?: number
|
||||||
|
boomBlockedVolume?: number
|
||||||
onStartPolygonDraw?: () => void
|
onStartPolygonDraw?: () => void
|
||||||
onRunPolygonAnalysis?: () => void
|
onRunPolygonAnalysis?: () => void
|
||||||
onRunCircleAnalysis?: () => void
|
onRunCircleAnalysis?: () => void
|
||||||
@ -44,6 +48,10 @@ export function RightPanel({
|
|||||||
drawAnalysisMode, analysisPolygonPoints = [],
|
drawAnalysisMode, analysisPolygonPoints = [],
|
||||||
circleRadiusNm = 5, onCircleRadiusChange,
|
circleRadiusNm = 5, onCircleRadiusChange,
|
||||||
analysisResult,
|
analysisResult,
|
||||||
|
incidentCoord,
|
||||||
|
centerPoints,
|
||||||
|
predictionTime,
|
||||||
|
boomBlockedVolume = 0,
|
||||||
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
|
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
|
||||||
onCancelAnalysis, onClearAnalysis,
|
onCancelAnalysis, onClearAnalysis,
|
||||||
}: RightPanelProps) {
|
}: RightPanelProps) {
|
||||||
@ -54,6 +62,38 @@ export function RightPanel({
|
|||||||
const [shipExpanded, setShipExpanded] = useState(false)
|
const [shipExpanded, setShipExpanded] = useState(false)
|
||||||
const [insuranceExpanded, setInsuranceExpanded] = useState(false)
|
const [insuranceExpanded, setInsuranceExpanded] = useState(false)
|
||||||
|
|
||||||
|
const weatheringStatus = useMemo(() => {
|
||||||
|
if (!summary) return null;
|
||||||
|
const total = summary.remainingVolume + summary.evaporationVolume
|
||||||
|
+ summary.dispersionVolume + summary.beachedVolume + boomBlockedVolume;
|
||||||
|
if (total <= 0) return null;
|
||||||
|
const pct = (v: number) => Math.round((v / total) * 100);
|
||||||
|
return {
|
||||||
|
surface: pct(summary.remainingVolume),
|
||||||
|
evaporation: pct(summary.evaporationVolume),
|
||||||
|
dispersion: pct(summary.dispersionVolume),
|
||||||
|
boom: pct(boomBlockedVolume),
|
||||||
|
beached: pct(summary.beachedVolume),
|
||||||
|
};
|
||||||
|
}, [summary, boomBlockedVolume])
|
||||||
|
|
||||||
|
const spreadSummary = useMemo(() => {
|
||||||
|
if (!incidentCoord || !centerPoints || centerPoints.length === 0) return null
|
||||||
|
const finalPoint = [...centerPoints].sort((a, b) => b.time - a.time)[0]
|
||||||
|
const distM = haversineDistance(incidentCoord, { lat: finalPoint.lat, lon: finalPoint.lon })
|
||||||
|
const distKm = distM / 1000
|
||||||
|
const bearing = computeBearing(incidentCoord, { lat: finalPoint.lat, lon: finalPoint.lon })
|
||||||
|
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||||
|
const dirLabel = directions[Math.round(bearing / 45) % 8]
|
||||||
|
const speedMs = predictionTime && predictionTime > 0 ? distM / (predictionTime * 3600) : null
|
||||||
|
return {
|
||||||
|
area: summary?.pollutionArea ?? null,
|
||||||
|
distance: distKm,
|
||||||
|
directionLabel: `${dirLabel} ${Math.round(bearing)}°`,
|
||||||
|
speed: speedMs,
|
||||||
|
}
|
||||||
|
}, [incidentCoord, centerPoints, summary, predictionTime])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col">
|
<div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col">
|
||||||
{/* Tab Header */}
|
{/* Tab Header */}
|
||||||
@ -81,7 +121,10 @@ export function RightPanel({
|
|||||||
checked={displayControls?.showBeached ?? false}
|
checked={displayControls?.showBeached ?? false}
|
||||||
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
|
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
|
||||||
>해안부착</ControlledCheckbox>
|
>해안부착</ControlledCheckbox>
|
||||||
<ControlledCheckbox checked={false} onChange={() => {}} disabled>
|
<ControlledCheckbox
|
||||||
|
checked={displayControls?.showSensitiveResources ?? false}
|
||||||
|
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showSensitiveResources: v })}
|
||||||
|
>
|
||||||
민감자원
|
민감자원
|
||||||
</ControlledCheckbox>
|
</ControlledCheckbox>
|
||||||
<ControlledCheckbox
|
<ControlledCheckbox
|
||||||
@ -233,23 +276,29 @@ export function RightPanel({
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 확산 예측 요약 */}
|
{/* 확산 예측 요약 */}
|
||||||
<Section title="확산 예측 요약 (+18h)" badge="위험" badgeColor="red">
|
<Section title={`확산 예측 요약 (+${predictionTime ?? 18}h)`} badge="위험" badgeColor="red">
|
||||||
<div className="grid grid-cols-2 gap-0.5 text-[9px]">
|
<div className="grid grid-cols-2 gap-0.5 text-[9px]">
|
||||||
<PredictionCard value="4.7 km²" label="영향 면적" color="var(--red)" />
|
<PredictionCard value={spreadSummary?.area != null ? `${spreadSummary.area.toFixed(1)} km²` : '—'} label="영향 면적" color="var(--red)" />
|
||||||
<PredictionCard value="6.2 km" label="확산 거리" color="var(--orange)" />
|
<PredictionCard value={spreadSummary?.distance != null ? `${spreadSummary.distance.toFixed(1)} km` : '—'} label="확산 거리" color="var(--orange)" />
|
||||||
<PredictionCard value="NE 42°" label="확산 방향" color="var(--cyan)" />
|
<PredictionCard value={spreadSummary?.directionLabel ?? '—'} label="확산 방향" color="var(--cyan)" />
|
||||||
<PredictionCard value="0.35 m/s" label="확산 속도" color="var(--t1)" />
|
<PredictionCard value={spreadSummary?.speed != null ? `${spreadSummary.speed.toFixed(2)} m/s` : '—'} label="확산 속도" color="var(--t1)" />
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 유출유 풍화 상태 */}
|
{/* 유출유 풍화 상태 */}
|
||||||
<Section title="유출유 풍화 상태">
|
<Section title="유출유 풍화 상태">
|
||||||
<div className="flex flex-col gap-[3px] text-[8px]">
|
<div className="flex flex-col gap-[3px] text-[8px]">
|
||||||
<ProgressBar label="수면잔류" value={58} color="var(--blue)" />
|
{weatheringStatus ? (
|
||||||
<ProgressBar label="증발" value={22} color="var(--cyan)" />
|
<>
|
||||||
<ProgressBar label="분산" value={12} color="var(--green)" />
|
<ProgressBar label="수면잔류" value={weatheringStatus.surface} color="var(--blue)" />
|
||||||
<ProgressBar label="펜스차단" value={5} color="var(--boom)" />
|
<ProgressBar label="증발" value={weatheringStatus.evaporation} color="var(--cyan)" />
|
||||||
<ProgressBar label="해안도달" value={3} color="var(--red)" />
|
<ProgressBar label="분산" value={weatheringStatus.dispersion} color="var(--green)" />
|
||||||
|
<ProgressBar label="펜스차단" value={weatheringStatus.boom} color="var(--boom)" />
|
||||||
|
<ProgressBar label="해안도달" value={weatheringStatus.beached} color="var(--red)" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[9px] text-text-3 font-korean text-center py-2">시뮬레이션 실행 후 표시됩니다</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@ -654,13 +703,13 @@ function PollResult({
|
|||||||
{summary && (
|
{summary && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-text-3">해상잔존량</span>
|
<span className="text-text-3">해상잔존량</span>
|
||||||
<span className="font-semibold font-mono" style={{ color: 'var(--blue)' }}>{summary.remainingVolume.toFixed(2)} kL</span>
|
<span className="font-semibold font-mono" style={{ color: 'var(--blue)' }}>{summary.remainingVolume.toFixed(2)} m³</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{summary && (
|
{summary && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-text-3">연안부착량</span>
|
<span className="text-text-3">연안부착량</span>
|
||||||
<span className="font-semibold font-mono" style={{ color: 'var(--red)' }}>{summary.beachedVolume.toFixed(2)} kL</span>
|
<span className="font-semibold font-mono" style={{ color: 'var(--red)' }}>{summary.beachedVolume.toFixed(2)} m³</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type { PredictionModel } from './OilSpillView'
|
import type { PredictionModel } from './OilSpillView'
|
||||||
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
|
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
|
||||||
import type { Analysis } from './AnalysisListTable'
|
import type { Analysis } from './AnalysisListTable'
|
||||||
import type { ImageAnalyzeResult } from '../services/predictionApi'
|
import type { ImageAnalyzeResult, SensitiveResourceCategory } from '../services/predictionApi'
|
||||||
|
|
||||||
export interface LeftPanelProps {
|
export interface LeftPanelProps {
|
||||||
selectedAnalysis?: Analysis | null
|
selectedAnalysis?: Analysis | null
|
||||||
@ -49,6 +49,8 @@ export interface LeftPanelProps {
|
|||||||
onLayerOpacityChange: (val: number) => void
|
onLayerOpacityChange: (val: number) => void
|
||||||
layerBrightness: number
|
layerBrightness: number
|
||||||
onLayerBrightnessChange: (val: number) => void
|
onLayerBrightnessChange: (val: number) => void
|
||||||
|
// 영향 민감자원
|
||||||
|
sensitiveResources?: SensitiveResourceCategory[]
|
||||||
// 이미지 분석 결과 콜백
|
// 이미지 분석 결과 콜백
|
||||||
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void
|
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@ -168,6 +168,8 @@ export interface OilParticle {
|
|||||||
export interface SimulationSummary {
|
export interface SimulationSummary {
|
||||||
remainingVolume: number;
|
remainingVolume: number;
|
||||||
weatheredVolume: number;
|
weatheredVolume: number;
|
||||||
|
evaporationVolume: number;
|
||||||
|
dispersionVolume: number;
|
||||||
pollutionArea: number;
|
pollutionArea: number;
|
||||||
beachedVolume: number;
|
beachedVolume: number;
|
||||||
pollutionCoastLength: number;
|
pollutionCoastLength: number;
|
||||||
@ -190,6 +192,7 @@ export interface RunModelSyncResult {
|
|||||||
status: 'DONE' | 'ERROR';
|
status: 'DONE' | 'ERROR';
|
||||||
trajectory?: OilParticle[];
|
trajectory?: OilParticle[];
|
||||||
summary?: SimulationSummary;
|
summary?: SimulationSummary;
|
||||||
|
stepSummaries?: SimulationSummary[];
|
||||||
centerPoints?: CenterPoint[];
|
centerPoints?: CenterPoint[];
|
||||||
windData?: WindPoint[][];
|
windData?: WindPoint[][];
|
||||||
hydrData?: (HydrDataStep | null)[];
|
hydrData?: (HydrDataStep | null)[];
|
||||||
@ -218,6 +221,73 @@ export const fetchAnalysisTrajectory = async (acdntSn: number): Promise<Trajecto
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface SensitiveResourceCategory {
|
||||||
|
category: string;
|
||||||
|
count: number;
|
||||||
|
totalArea: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchSensitiveResources = async (
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<SensitiveResourceCategory[]> => {
|
||||||
|
const response = await api.get<SensitiveResourceCategory[]>(
|
||||||
|
`/prediction/analyses/${acdntSn}/sensitive-resources`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SensitiveResourceFeature {
|
||||||
|
type: 'Feature';
|
||||||
|
geometry: { type: string; coordinates: unknown };
|
||||||
|
properties: {
|
||||||
|
srId: number;
|
||||||
|
category: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensitiveResourceFeatureCollection {
|
||||||
|
type: 'FeatureCollection';
|
||||||
|
features: SensitiveResourceFeature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchSensitiveResourcesGeojson = async (
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<SensitiveResourceFeatureCollection> => {
|
||||||
|
const response = await api.get<SensitiveResourceFeatureCollection>(
|
||||||
|
`/prediction/analyses/${acdntSn}/sensitive-resources/geojson`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SpreadParticlesGeojson {
|
||||||
|
type: 'FeatureCollection';
|
||||||
|
features: Array<{
|
||||||
|
type: 'Feature';
|
||||||
|
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||||
|
properties: { model: string; time: number; stranded: 0 | 1; isLastStep: boolean };
|
||||||
|
}>;
|
||||||
|
maxStep: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchPredictionParticlesGeojson = async (
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<SpreadParticlesGeojson> => {
|
||||||
|
const response = await api.get<SpreadParticlesGeojson>(
|
||||||
|
`/prediction/analyses/${acdntSn}/spread-particles`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchSensitivityEvaluationGeojson = async (
|
||||||
|
acdntSn: number,
|
||||||
|
): Promise<{ type: 'FeatureCollection'; features: unknown[] }> => {
|
||||||
|
const response = await api.get<{ type: 'FeatureCollection'; features: unknown[] }>(
|
||||||
|
`/prediction/analyses/${acdntSn}/sensitivity-evaluation`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 이미지 업로드 분석
|
// 이미지 업로드 분석
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import maplibregl from 'maplibre-gl'
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { saveReport } from '../services/reportsApi'
|
import { saveReport } from '../services/reportsApi'
|
||||||
|
import { fetchSensitiveResourcesGeojson, fetchPredictionParticlesGeojson, fetchSensitivityEvaluationGeojson } from '@tabs/prediction/services/predictionApi'
|
||||||
|
|
||||||
// ─── Data Types ─────────────────────────────────────────────
|
// ─── Data Types ─────────────────────────────────────────────
|
||||||
export type ReportType = '초기보고서' | '지휘부 보고' | '예측보고서' | '종합보고서' | '유출유 보고'
|
export type ReportType = '초기보고서' | '지휘부 보고' | '예측보고서' | '종합보고서' | '유출유 보고'
|
||||||
@ -39,7 +42,12 @@ export interface OilSpillReportData {
|
|||||||
recovery: { shipName: string; period: string }[]
|
recovery: { shipName: string; period: string }[]
|
||||||
result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string }
|
result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string }
|
||||||
capturedMapImage?: string;
|
capturedMapImage?: string;
|
||||||
|
step3MapImage?: string;
|
||||||
|
step6MapImage?: string;
|
||||||
hasMapCapture?: boolean;
|
hasMapCapture?: boolean;
|
||||||
|
acdntSn?: number;
|
||||||
|
sensitiveMapImage?: string;
|
||||||
|
sensitivityMapImage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
@ -268,8 +276,18 @@ function Page2({ data, editing, onChange }: { data: OilSpillReportData; editing:
|
|||||||
|
|
||||||
<div style={S.sectionTitle}>3. 유출유 확산예측</div>
|
<div style={S.sectionTitle}>3. 유출유 확산예측</div>
|
||||||
<div className="flex gap-4 mb-4">
|
<div className="flex gap-4 mb-4">
|
||||||
<div style={S.mapPlaceholder}>확산예측 3시간 지도</div>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={S.mapPlaceholder}>확산예측 6시간 지도</div>
|
{data.step3MapImage
|
||||||
|
? <img src={data.step3MapImage} alt="확산예측 3시간 지도" style={{ width: '100%', height: 'auto', display: 'block', borderRadius: '4px', border: '1px solid var(--bd)' }} />
|
||||||
|
: <div style={S.mapPlaceholder}>확산예측 3시간 지도</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{data.step6MapImage
|
||||||
|
? <img src={data.step6MapImage} alt="확산예측 6시간 지도" style={{ width: '100%', height: 'auto', display: 'block', borderRadius: '4px', border: '1px solid var(--bd)' }} />
|
||||||
|
: <div style={S.mapPlaceholder}>확산예측 6시간 지도</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={S.subHeader}>시간별 상세정보</div>
|
<div style={S.subHeader}>시간별 상세정보</div>
|
||||||
<table style={S.table}>
|
<table style={S.table}>
|
||||||
@ -312,6 +330,405 @@ function Page3({ data, editing, onChange }: { data: OilSpillReportData; editing:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSeasonKey = (occurTime: string): 'SP_G' | 'SM_G' | 'FA_G' | 'WT_G' => {
|
||||||
|
const m = occurTime.match(/\d{4}[.\-\s]+(\d{1,2})[.\-\s]/)
|
||||||
|
const month = m ? parseInt(m[1]) : 0
|
||||||
|
if (month >= 3 && month <= 5) return 'SP_G'
|
||||||
|
if (month >= 6 && month <= 8) return 'SM_G'
|
||||||
|
if (month >= 9 && month <= 11) return 'FA_G'
|
||||||
|
return 'WT_G'
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseCoord = (s: string): number | null => {
|
||||||
|
const d = parseFloat(s)
|
||||||
|
if (!isNaN(d)) return d
|
||||||
|
const m = s.match(/(\d+)[°]\s*(\d+)[′']\s*([\d.]+)[″"]/)
|
||||||
|
if (m) return Number(m[1]) + Number(m[2]) / 60 + Number(m[3]) / 3600
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const haversineKm = (lat1: number, lon1: number, lat2: number, lon2: number): number => {
|
||||||
|
const R = 6371
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGeomCentroid = (geom: { type: string; coordinates: unknown }): [number, number] | null => {
|
||||||
|
if (geom.type === 'Point') return geom.coordinates as [number, number]
|
||||||
|
if (geom.type === 'Polygon') {
|
||||||
|
const pts = (geom.coordinates as [number, number][][])[0]
|
||||||
|
const avg = pts.reduce((a, c) => [a[0] + c[0], a[1] + c[1]], [0, 0])
|
||||||
|
return [avg[0] / pts.length, avg[1] / pts.length]
|
||||||
|
}
|
||||||
|
if (geom.type === 'MultiPolygon') {
|
||||||
|
const all = (geom.coordinates as [number, number][][][]).flatMap(p => p[0])
|
||||||
|
const avg = all.reduce((a, c) => [a[0] + c[0], a[1] + c[1]], [0, 0])
|
||||||
|
return [avg[0] / all.length, avg[1] / all.length]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_COLORS = [
|
||||||
|
{ keywords: ['어장', '양식'], color: '#f97316', label: '어장/양식장' },
|
||||||
|
{ keywords: ['해수욕'], color: '#3b82f6', label: '해수욕장' },
|
||||||
|
{ keywords: ['수산시장', '어시장'], color: '#a855f7', label: '수산시장' },
|
||||||
|
{ keywords: ['갯벌'], color: '#92400e', label: '갯벌' },
|
||||||
|
{ keywords: ['서식지'], color: '#16a34a', label: '서식지' },
|
||||||
|
{ keywords: ['보호종', '생물', '조류', '포유', '파충', '양서'], color: '#ec4899', label: '보호종/생물종' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const getCategoryColor = (category: string): string => {
|
||||||
|
for (const { keywords, color } of CATEGORY_COLORS) {
|
||||||
|
if ((keywords as readonly string[]).some(kw => category.includes(kw))) return color
|
||||||
|
}
|
||||||
|
return '#06b6d4'
|
||||||
|
}
|
||||||
|
|
||||||
|
function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||||
|
const [mapVisible, setMapVisible] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [capturing, setCapturing] = useState(false)
|
||||||
|
const [legendLabels, setLegendLabels] = useState<string[]>([])
|
||||||
|
const acdntSn = data.acdntSn
|
||||||
|
|
||||||
|
const RELEVANT_KEYWORDS = ['양식', '어장', '해수욕', '수산시장', '어시장', '갯벌', '서식지', '보호종', '생물', '조류', '포유', '파충']
|
||||||
|
|
||||||
|
const handleLoad = async () => {
|
||||||
|
if (!acdntSn) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [geojson, particlesGeojson] = await Promise.all([
|
||||||
|
fetchSensitiveResourcesGeojson(acdntSn),
|
||||||
|
fetchPredictionParticlesGeojson(acdntSn),
|
||||||
|
])
|
||||||
|
// 관련 카테고리만 필터링 + 카테고리별 색상 추가
|
||||||
|
const filteredGeojson = {
|
||||||
|
...geojson,
|
||||||
|
features: geojson.features
|
||||||
|
.filter(f => {
|
||||||
|
const cat = (f.properties as { category?: string })?.category ?? ''
|
||||||
|
return RELEVANT_KEYWORDS.some(kw => cat.includes(kw))
|
||||||
|
})
|
||||||
|
.map(f => ({
|
||||||
|
...f,
|
||||||
|
properties: {
|
||||||
|
...f.properties,
|
||||||
|
_color: getCategoryColor((f.properties as { category?: string })?.category ?? ''),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
// 실제 존재하는 카테고리 라벨만 범례에 표시
|
||||||
|
const presentColors = new Set(filteredGeojson.features.map(f => (f.properties as { _color: string })._color))
|
||||||
|
setLegendLabels(CATEGORY_COLORS.filter(c => presentColors.has(c.color)).map(c => c.label))
|
||||||
|
// 민감자원 GeoJSON → 6개 섹션 자동 채우기
|
||||||
|
const HABITAT_TYPES = ['갯벌', '해조류서식지', '바다목장', '바다숲', '산호류서식지', '인공어초', '해초류서식지']
|
||||||
|
const incLat = parseCoord(data.incident.lat)
|
||||||
|
const incLon = parseCoord(data.incident.lon)
|
||||||
|
const calcDist = (geom: { type: string; coordinates: unknown }) => {
|
||||||
|
if (incLat == null || incLon == null) return ''
|
||||||
|
const centroid = getGeomCentroid(geom)
|
||||||
|
return centroid ? haversineKm(incLat, incLon, centroid[1], centroid[0]).toFixed(2) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// aquaculture (어장)
|
||||||
|
const aquacultureRows = geojson.features
|
||||||
|
.filter(f => ((f.properties as { category?: string })?.category ?? '').includes('어장'))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aNo = String((a.properties as Record<string, unknown>)['lcns_no'] ?? '');
|
||||||
|
const bNo = String((b.properties as Record<string, unknown>)['lcns_no'] ?? '');
|
||||||
|
return aNo.localeCompare(bNo, 'ko', { numeric: true });
|
||||||
|
})
|
||||||
|
.map(f => {
|
||||||
|
const p = f.properties as Record<string, unknown>
|
||||||
|
const lcnsNo = String(p['lcns_no'] ?? '');
|
||||||
|
const fidsKnd = String(p['fids_knd'] ?? '');
|
||||||
|
const farmKnd = String(p['farm_knd'] ?? '');
|
||||||
|
const parts = [lcnsNo, fidsKnd, farmKnd].filter(Boolean);
|
||||||
|
return { type: parts.join('_'), area: p['area'] != null ? Number(p['area']).toFixed(2) : '', distance: calcDist(f.geometry as { type: string; coordinates: unknown }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// beaches (해수욕장)
|
||||||
|
const beachRows = geojson.features
|
||||||
|
.filter(f => ((f.properties as { category?: string })?.category ?? '').includes('해수욕'))
|
||||||
|
.map(f => {
|
||||||
|
const p = f.properties as Record<string, unknown>
|
||||||
|
return { name: String(p['beach_nm'] ?? p['name'] ?? p['nm'] ?? ''), distance: calcDist(f.geometry as { type: string; coordinates: unknown }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// markets (수산시장·어시장)
|
||||||
|
const marketRows = geojson.features
|
||||||
|
.filter(f => { const cat = (f.properties as { category?: string })?.category ?? ''; return cat.includes('수산시장') || cat.includes('어시장') })
|
||||||
|
.map(f => {
|
||||||
|
const p = f.properties as Record<string, unknown>
|
||||||
|
return { name: String(p['name'] ?? p['market_nm'] ?? p['nm'] ?? ''), distance: calcDist(f.geometry as { type: string; coordinates: unknown }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// esi (해안선) — 기존 10개 행의 length만 갱신
|
||||||
|
const esiFeatures = geojson.features.filter(f => { const cat = (f.properties as { category?: string })?.category ?? ''; return cat.includes('해안선') || cat.includes('ESI') })
|
||||||
|
const esiLengthMap: Record<string, string> = {}
|
||||||
|
esiFeatures.forEach(f => {
|
||||||
|
const p = f.properties as Record<string, unknown>
|
||||||
|
const code = String(p['esi_cd'] ?? '')
|
||||||
|
const len = p['length_km'] ?? p['len_km']
|
||||||
|
if (code && len != null) esiLengthMap[code] = `${Number(len).toFixed(2)} km`
|
||||||
|
})
|
||||||
|
const esiRows = esiFeatures.length > 0
|
||||||
|
? data.esi.map(row => ({ ...row, length: esiLengthMap[row.code.replace('ESI ', '')] ?? esiLengthMap[row.code] ?? row.length }))
|
||||||
|
: data.esi
|
||||||
|
|
||||||
|
// species (보호종·생물종) — 3개 고정 행 유지, species 컬럼만 갱신
|
||||||
|
const SPECIES_MAP: Record<string, string[]> = { '양서파충류': ['파충', '양서'], '조류': ['조류'], '포유류': ['포유'] }
|
||||||
|
const speciesCollected: Record<string, string[]> = { '양서파충류': [], '조류': [], '포유류': [] }
|
||||||
|
geojson.features.forEach(f => {
|
||||||
|
const cat = (f.properties as { category?: string })?.category ?? ''
|
||||||
|
const p = f.properties as Record<string, unknown>
|
||||||
|
const nm = String(p['name'] ?? p['species_nm'] ?? '')
|
||||||
|
if (!nm) return
|
||||||
|
for (const [row, kws] of Object.entries(SPECIES_MAP)) {
|
||||||
|
if (kws.some(kw => cat.includes(kw))) { speciesCollected[row].push(nm); break }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const hasSpecies = Object.values(speciesCollected).some(arr => arr.length > 0)
|
||||||
|
const speciesRows = hasSpecies
|
||||||
|
? data.species.map(row => ({ ...row, species: speciesCollected[row.category]?.join(', ') ?? row.species }))
|
||||||
|
: data.species
|
||||||
|
|
||||||
|
// habitat (서식지) — 타입별 면적 합산
|
||||||
|
const habitatFeatures = geojson.features.filter(f => HABITAT_TYPES.some(t => ((f.properties as { category?: string })?.category ?? '').includes(t)))
|
||||||
|
const habitatMap: Record<string, number> = {}
|
||||||
|
habitatFeatures.forEach(f => {
|
||||||
|
const cat = (f.properties as { category?: string })?.category ?? ''
|
||||||
|
const p = f.properties as Record<string, unknown>
|
||||||
|
habitatMap[cat] = (habitatMap[cat] ?? 0) + (p['area'] != null ? Number(p['area']) : 0)
|
||||||
|
})
|
||||||
|
const habitatRows = habitatFeatures.length > 0
|
||||||
|
? Object.entries(habitatMap).map(([type, area]) => ({ type, area: area > 0 ? area.toFixed(2) : '' }))
|
||||||
|
: data.habitat
|
||||||
|
|
||||||
|
// 단일 onChange 일괄 업데이트
|
||||||
|
const updates: Partial<OilSpillReportData> = {}
|
||||||
|
if (aquacultureRows.length > 0) updates.aquaculture = aquacultureRows
|
||||||
|
if (beachRows.length > 0) updates.beaches = beachRows
|
||||||
|
if (marketRows.length > 0) updates.markets = marketRows
|
||||||
|
if (esiFeatures.length > 0) updates.esi = esiRows
|
||||||
|
if (hasSpecies) updates.species = speciesRows
|
||||||
|
if (habitatFeatures.length > 0) updates.habitat = habitatRows
|
||||||
|
if (Object.keys(updates).length > 0) onChange({ ...data, ...updates })
|
||||||
|
|
||||||
|
setMapVisible(true)
|
||||||
|
// 다음 렌더 사이클 후 지도 초기화
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!mapContainerRef.current) return
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container: mapContainerRef.current,
|
||||||
|
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||||
|
center: [127.5, 35.5],
|
||||||
|
zoom: 8,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
})
|
||||||
|
mapRef.current = map
|
||||||
|
map.on('load', () => {
|
||||||
|
// 확산 파티클 — sensitive 레이어 아래 (예측 탭과 동일한 색상 로직)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
map.addSource('particles', { type: 'geojson', data: particlesGeojson as any })
|
||||||
|
// 과거 스텝: 회색 반투명
|
||||||
|
map.addLayer({ id: 'particles-past', type: 'circle', source: 'particles', filter: ['==', ['get', 'isLastStep'], false], paint: { 'circle-radius': 2.5, 'circle-color': '#828282', 'circle-opacity': 0.4 } })
|
||||||
|
// 최신 스텝: 모델별 색상 (OpenDrift=파랑, POSEIDON=빨강)
|
||||||
|
map.addLayer({ id: 'particles-current', type: 'circle', source: 'particles', filter: ['==', ['get', 'isLastStep'], true], paint: { 'circle-radius': 3, 'circle-color': ['case', ['==', ['get', 'model'], 'OpenDrift'], '#3b82f6', ['==', ['get', 'model'], 'POSEIDON'], '#ef4444', '#06b6d4'], 'circle-opacity': 0.85 } })
|
||||||
|
// 민감자원 레이어
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
map.addSource('sensitive', { type: 'geojson', data: filteredGeojson as any })
|
||||||
|
map.addLayer({ id: 'sensitive-fill', type: 'fill', source: 'sensitive', filter: ['==', '$type', 'Polygon'], paint: { 'fill-color': ['get', '_color'], 'fill-opacity': 0.4 } })
|
||||||
|
map.addLayer({ id: 'sensitive-line', type: 'line', source: 'sensitive', filter: ['==', '$type', 'Polygon'], paint: { 'line-color': ['get', '_color'], 'line-width': 1.5 } })
|
||||||
|
map.addLayer({ id: 'sensitive-circle', type: 'circle', source: 'sensitive', filter: ['==', '$type', 'Point'], paint: { 'circle-radius': 6, 'circle-color': ['get', '_color'], 'circle-stroke-width': 1.5, 'circle-stroke-color': '#fff' } })
|
||||||
|
// fit bounds — spread + sensitive 합산
|
||||||
|
const coords: [number, number][] = []
|
||||||
|
const collectCoords = (geom: { type: string; coordinates: unknown }) => {
|
||||||
|
if (geom.type === 'Point') {
|
||||||
|
coords.push(geom.coordinates as [number, number])
|
||||||
|
} else if (geom.type === 'Polygon') {
|
||||||
|
const rings = geom.coordinates as [number, number][][]
|
||||||
|
rings[0]?.forEach(c => coords.push(c))
|
||||||
|
} else if (geom.type === 'MultiPolygon') {
|
||||||
|
const polys = geom.coordinates as [number, number][][][]
|
||||||
|
polys.forEach(rings => rings[0]?.forEach(c => coords.push(c)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filteredGeojson.features.forEach(f => collectCoords(f.geometry as { type: string; coordinates: unknown }))
|
||||||
|
particlesGeojson.features.forEach(f => {
|
||||||
|
coords.push(f.geometry.coordinates as [number, number])
|
||||||
|
})
|
||||||
|
if (coords.length > 0) {
|
||||||
|
const lngs = coords.map(c => c[0])
|
||||||
|
const lats = coords.map(c => c[1])
|
||||||
|
map.fitBounds(
|
||||||
|
[[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]],
|
||||||
|
{ padding: 60, maxZoom: 13 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 사고 위치 마커 (캔버스 레이어 — 캡처에 포함)
|
||||||
|
if (incLat != null && incLon != null) {
|
||||||
|
map.addSource('incident-point', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [incLon, incLat] }, properties: {} },
|
||||||
|
})
|
||||||
|
map.addLayer({ id: 'incident-circle', type: 'circle', source: 'incident-point', paint: { 'circle-radius': 7, 'circle-color': '#ef4444', 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff' } })
|
||||||
|
map.addLayer({ id: 'incident-label', type: 'symbol', source: 'incident-point', layout: { 'text-field': '사고위치', 'text-size': 11, 'text-offset': [0, 1.6], 'text-anchor': 'top', 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'] }, paint: { 'text-color': '#ffffff', 'text-halo-color': '#ef4444', 'text-halo-width': 3 } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
} catch {
|
||||||
|
alert('민감자원 지도 데이터를 불러오지 못했습니다.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCapture = () => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) return
|
||||||
|
setCapturing(true)
|
||||||
|
map.once('idle', () => {
|
||||||
|
const mapCanvas = map.getCanvas()
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
const W = mapCanvas.width
|
||||||
|
const H = mapCanvas.height
|
||||||
|
|
||||||
|
const composite = document.createElement('canvas')
|
||||||
|
composite.width = W
|
||||||
|
composite.height = H
|
||||||
|
const ctx = composite.getContext('2d')!
|
||||||
|
|
||||||
|
// 지도 그리기
|
||||||
|
ctx.drawImage(mapCanvas, 0, 0)
|
||||||
|
|
||||||
|
// 범례 그리기
|
||||||
|
const items = CATEGORY_COLORS.filter(c => legendLabels.includes(c.label))
|
||||||
|
if (items.length > 0) {
|
||||||
|
const pad = 8 * dpr
|
||||||
|
const swSize = 12 * dpr
|
||||||
|
const lineH = 18 * dpr
|
||||||
|
const fontSize = 11 * dpr
|
||||||
|
const boxW = 130 * dpr
|
||||||
|
const boxH = pad * 2 + items.length * lineH
|
||||||
|
const lx = pad, ly = pad
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.88)'
|
||||||
|
ctx.fillRect(lx, ly, boxW, boxH)
|
||||||
|
ctx.strokeStyle = 'rgba(0,0,0,0.15)'
|
||||||
|
ctx.lineWidth = dpr
|
||||||
|
ctx.strokeRect(lx, ly, boxW, boxH)
|
||||||
|
|
||||||
|
items.forEach(({ color, label }, i) => {
|
||||||
|
const iy = ly + pad + i * lineH
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.fillRect(lx + pad, iy + (lineH - swSize) / 2, swSize, swSize)
|
||||||
|
ctx.fillStyle = '#1f2937'
|
||||||
|
ctx.font = `${fontSize}px sans-serif`
|
||||||
|
ctx.fillText(label, lx + pad + swSize + 4 * dpr, iy + lineH / 2 + fontSize * 0.35)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({ ...data, sensitiveMapImage: composite.toDataURL('image/png') })
|
||||||
|
setCapturing(false)
|
||||||
|
})
|
||||||
|
map.triggerRepaint()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.remove()
|
||||||
|
mapRef.current = null
|
||||||
|
}
|
||||||
|
setMapVisible(false)
|
||||||
|
setLegendLabels([])
|
||||||
|
onChange({ ...data, sensitiveMapImage: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { mapRef.current?.remove() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 뷰 모드: 이미지 있으면 표시, 없으면 플레이스홀더
|
||||||
|
if (!editing) {
|
||||||
|
if (data.sensitiveMapImage) {
|
||||||
|
return <img src={data.sensitiveMapImage} alt="민감자원 분포 지도" style={{ width: '100%', height: 'auto', display: 'block', border: '1px solid #ddd', borderRadius: 4 }} />
|
||||||
|
}
|
||||||
|
return <div style={S.mapPlaceholder}>민감자원 분포(10km 내) 지도</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편집 모드: acdntSn 없음
|
||||||
|
if (!acdntSn) {
|
||||||
|
return <div style={S.mapPlaceholder}>민감자원 분포(10km 내) 지도</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편집 모드: 캡처 이미지 있음
|
||||||
|
if (data.sensitiveMapImage) {
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<img src={data.sensitiveMapImage} alt="민감자원 분포 지도" style={{ width: '100%', height: 'auto', display: 'block', border: '1px solid #ddd', borderRadius: 4 }} />
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
style={{ position: 'absolute', top: 8, right: 8, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
재캡처
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편집 모드: 지도 로드/캡처
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{!mapVisible ? (
|
||||||
|
<div style={{ ...S.mapPlaceholder, flexDirection: 'column', gap: 8 }}>
|
||||||
|
<span>민감자원 분포(10km 내) 지도</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLoad}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ background: '#0891b2', color: '#fff', border: 'none', borderRadius: 4, padding: '5px 14px', fontSize: 12, cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{loading ? '불러오는 중...' : '지도 불러오기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<div ref={mapContainerRef} style={{ width: '100%', height: 440, borderRadius: 4, overflow: 'hidden' }} />
|
||||||
|
{legendLabels.length > 0 && (
|
||||||
|
<div style={{ position: 'absolute', top: 8, left: 8, background: 'rgba(255,255,255,0.88)', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 4, padding: '6px 10px', pointerEvents: 'none' }}>
|
||||||
|
{CATEGORY_COLORS.filter(c => legendLabels.includes(c.label)).map(({ color, label }) => (
|
||||||
|
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3, fontSize: 11, color: '#1f2937', whiteSpace: 'nowrap' }}>
|
||||||
|
<div style={{ width: 12, height: 12, background: color, flexShrink: 0 }} />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 6, justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
style={{ background: 'transparent', color: '#6b7280', border: '1px solid #374151', borderRadius: 4, padding: '3px 10px', fontSize: 11, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCapture}
|
||||||
|
disabled={capturing}
|
||||||
|
style={{ background: '#0891b2', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 12px', fontSize: 11, cursor: capturing ? 'not-allowed' : 'pointer', opacity: capturing ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{capturing ? '캡처 중...' : '이 화면으로 캡처'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
|
function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
|
||||||
const setArr = <T extends Record<string, string>>(key: keyof OilSpillReportData, arr: T[], i: number, k: string, v: string) => {
|
const setArr = <T extends Record<string, string>>(key: keyof OilSpillReportData, arr: T[], i: number, k: string, v: string) => {
|
||||||
const copy = [...arr]; copy[i] = { ...copy[i], [k]: v }; onChange({ ...data, [key]: copy })
|
const copy = [...arr]; copy[i] = { ...copy[i], [k]: v }; onChange({ ...data, [key]: copy })
|
||||||
@ -321,9 +738,9 @@ function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing:
|
|||||||
<div style={S.page}>
|
<div style={S.page}>
|
||||||
<div className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</div>
|
<div className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</div>
|
||||||
<div style={S.sectionTitle}>4. 민감자원 및 민감도 평가</div>
|
<div style={S.sectionTitle}>4. 민감자원 및 민감도 평가</div>
|
||||||
<div style={S.mapPlaceholder}>민감자원 분포(10km 내) 지도</div>
|
<SensitiveResourceMapSection data={data} editing={editing} onChange={onChange} />
|
||||||
|
|
||||||
<div style={S.subHeader}>양식장 분포</div>
|
<div style={{ ...S.subHeader, marginTop: '16px' }}>양식장 분포</div>
|
||||||
<table style={S.table}>
|
<table style={S.table}>
|
||||||
<thead><tr><th style={S.th}>구분</th><th style={S.th}>면적(ha)</th><th style={S.th}>사고지점과의 거리(km)</th></tr></thead>
|
<thead><tr><th style={S.th}>구분</th><th style={S.th}>면적(ha)</th><th style={S.th}>사고지점과의 거리(km)</th></tr></thead>
|
||||||
<tbody>{data.aquaculture.map((a, i) => (
|
<tbody>{data.aquaculture.map((a, i) => (
|
||||||
@ -406,13 +823,197 @@ function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SENS_LEVELS = [
|
||||||
|
{ key: 5, level: '매우 높음', color: '#ef4444' },
|
||||||
|
{ key: 4, level: '높음', color: '#f97316' },
|
||||||
|
{ key: 3, level: '보통', color: '#eab308' },
|
||||||
|
{ key: 2, level: '낮음', color: '#22c55e' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function SensitivityMapSection({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||||
|
const [mapVisible, setMapVisible] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [capturing, setCapturing] = useState(false)
|
||||||
|
const acdntSn = data.acdntSn
|
||||||
|
|
||||||
|
const handleLoad = async () => {
|
||||||
|
if (!acdntSn) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const geojson = await fetchSensitivityEvaluationGeojson(acdntSn)
|
||||||
|
const seasonKey = getSeasonKey(data.incident.occurTime)
|
||||||
|
|
||||||
|
// 레벨 계산 및 면적 집계 (레벨 1 제외)
|
||||||
|
const areaByLevel: Record<number, number> = {}
|
||||||
|
geojson.features.forEach(f => {
|
||||||
|
const p = (f as { properties: Record<string, unknown> }).properties
|
||||||
|
const lvl = Number(p[seasonKey] ?? 1)
|
||||||
|
if (lvl >= 2) areaByLevel[lvl] = (areaByLevel[lvl] ?? 0) + Number(p['area_km2'] ?? 0)
|
||||||
|
})
|
||||||
|
const sensitivityRows = SENS_LEVELS.map(s => ({
|
||||||
|
level: s.level, color: s.color,
|
||||||
|
area: areaByLevel[s.key] != null ? areaByLevel[s.key].toFixed(2) : '',
|
||||||
|
}))
|
||||||
|
onChange({ ...data, sensitivity: sensitivityRows })
|
||||||
|
|
||||||
|
// 레벨 속성 추가 + 레벨 1 제외한 표시용 GeoJSON
|
||||||
|
const displayGeojson = {
|
||||||
|
...geojson,
|
||||||
|
features: geojson.features
|
||||||
|
.map(f => {
|
||||||
|
const p = (f as { properties: Record<string, unknown> }).properties
|
||||||
|
return { ...(f as object), properties: { ...p, level: Number(p[seasonKey] ?? 1) } }
|
||||||
|
})
|
||||||
|
.filter(f => (f as { properties: { level: number } }).properties.level >= 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
setMapVisible(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!mapContainerRef.current) return
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container: mapContainerRef.current,
|
||||||
|
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||||
|
center: [127.5, 35.5],
|
||||||
|
zoom: 8,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
})
|
||||||
|
mapRef.current = map
|
||||||
|
map.on('load', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
map.addSource('sensitivity', { type: 'geojson', data: displayGeojson as any })
|
||||||
|
const colorExpr: maplibregl.ExpressionSpecification = ['case',
|
||||||
|
['==', ['get', 'level'], 5], '#ef4444',
|
||||||
|
['==', ['get', 'level'], 4], '#f97316',
|
||||||
|
['==', ['get', 'level'], 3], '#eab308',
|
||||||
|
['==', ['get', 'level'], 2], '#22c55e',
|
||||||
|
'transparent',
|
||||||
|
]
|
||||||
|
map.addLayer({ id: 'sensitivity-fill', type: 'fill', source: 'sensitivity', filter: ['==', '$type', 'Polygon'], paint: { 'fill-color': colorExpr, 'fill-opacity': 0.6 } })
|
||||||
|
map.addLayer({ id: 'sensitivity-line', type: 'line', source: 'sensitivity', filter: ['==', '$type', 'Polygon'], paint: { 'line-color': colorExpr, 'line-width': 0.5, 'line-opacity': 0.4 } })
|
||||||
|
map.addLayer({ id: 'sensitivity-circle', type: 'circle', source: 'sensitivity', filter: ['==', '$type', 'Point'], paint: { 'circle-radius': 5, 'circle-color': colorExpr } })
|
||||||
|
|
||||||
|
// fitBounds
|
||||||
|
const coords: [number, number][] = []
|
||||||
|
displayGeojson.features.forEach(f => {
|
||||||
|
const geom = (f as { geometry: { type: string; coordinates: unknown } }).geometry
|
||||||
|
if (geom.type === 'Point') coords.push(geom.coordinates as [number, number])
|
||||||
|
else if (geom.type === 'Polygon') (geom.coordinates as [number, number][][])[0]?.forEach(c => coords.push(c))
|
||||||
|
else if (geom.type === 'MultiPolygon') (geom.coordinates as [number, number][][][]).forEach(rings => rings[0]?.forEach(c => coords.push(c)))
|
||||||
|
})
|
||||||
|
if (coords.length > 0) {
|
||||||
|
const lngs = coords.map(c => c[0])
|
||||||
|
const lats = coords.map(c => c[1])
|
||||||
|
map.fitBounds([[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]], { padding: 60, maxZoom: 13 })
|
||||||
|
}
|
||||||
|
// 사고 위치 마커 (캔버스 레이어 — 캡처에 포함)
|
||||||
|
const incLat = parseCoord(data.incident.lat)
|
||||||
|
const incLon = parseCoord(data.incident.lon)
|
||||||
|
if (incLat != null && incLon != null) {
|
||||||
|
map.addSource('incident-point', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [incLon, incLat] }, properties: {} },
|
||||||
|
})
|
||||||
|
map.addLayer({ id: 'incident-circle', type: 'circle', source: 'incident-point', paint: { 'circle-radius': 7, 'circle-color': '#ef4444', 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff' } })
|
||||||
|
map.addLayer({ id: 'incident-label', type: 'symbol', source: 'incident-point', layout: { 'text-field': '사고위치', 'text-size': 11, 'text-offset': [0, 1.6], 'text-anchor': 'top', 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'] }, paint: { 'text-color': '#ffffff', 'text-halo-color': '#ef4444', 'text-halo-width': 3 } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
} catch {
|
||||||
|
alert('통합민감도 평가 데이터를 불러오지 못했습니다.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCapture = () => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) return
|
||||||
|
setCapturing(true)
|
||||||
|
map.once('idle', () => {
|
||||||
|
const dataUrl = map.getCanvas().toDataURL('image/png')
|
||||||
|
onChange({ ...data, sensitivityMapImage: dataUrl })
|
||||||
|
setCapturing(false)
|
||||||
|
})
|
||||||
|
map.triggerRepaint()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (mapRef.current) { mapRef.current.remove(); mapRef.current = null }
|
||||||
|
setMapVisible(false)
|
||||||
|
onChange({ ...data, sensitivityMapImage: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { return () => { mapRef.current?.remove() } }, [])
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
if (data.sensitivityMapImage) {
|
||||||
|
return <img src={data.sensitivityMapImage} alt="통합민감도 평가 지도" style={{ width: '100%', height: 'auto', display: 'block', border: '1px solid #ddd', borderRadius: 4, marginBottom: 16 }} />
|
||||||
|
}
|
||||||
|
return <div style={S.mapPlaceholder}>통합민감도 평가 지도</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acdntSn) return <div style={S.mapPlaceholder}>통합민감도 평가 지도</div>
|
||||||
|
|
||||||
|
if (data.sensitivityMapImage) {
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<img src={data.sensitivityMapImage} alt="통합민감도 평가 지도" style={{ width: '100%', height: 'auto', display: 'block', border: '1px solid #ddd', borderRadius: 4 }} />
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
style={{ position: 'absolute', top: 8, right: 8, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
재캡처
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{!mapVisible ? (
|
||||||
|
<div style={{ ...S.mapPlaceholder, flexDirection: 'column', gap: 8 }}>
|
||||||
|
<span>민감도 분포(10km내) 지도</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLoad}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ background: '#0891b2', color: '#fff', border: 'none', borderRadius: 4, padding: '5px 14px', fontSize: 12, cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{loading ? '불러오는 중...' : '지도 불러오기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<div ref={mapContainerRef} style={{ width: '100%', height: 440, borderRadius: 4, overflow: 'hidden' }} />
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 6, justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
style={{ background: 'transparent', color: '#6b7280', border: '1px solid #374151', borderRadius: 4, padding: '3px 10px', fontSize: 11, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCapture}
|
||||||
|
disabled={capturing}
|
||||||
|
style={{ background: '#0891b2', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 12px', fontSize: 11, cursor: capturing ? 'not-allowed' : 'pointer', opacity: capturing ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{capturing ? '캡처 중...' : '이 화면으로 캡처'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Page5({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
|
function Page5({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
|
||||||
const setSens = (i: number, v: string) => { const s = [...data.sensitivity]; s[i] = { ...s[i], area: v }; onChange({ ...data, sensitivity: s }) }
|
const setSens = (i: number, v: string) => { const s = [...data.sensitivity]; s[i] = { ...s[i], area: v }; onChange({ ...data, sensitivity: s }) }
|
||||||
return (
|
return (
|
||||||
<div style={S.page}>
|
<div style={S.page}>
|
||||||
<div className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</div>
|
<div className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</div>
|
||||||
<div style={S.sectionTitle}>통합민감도 평가 (해당 계절)</div>
|
<div style={S.sectionTitle}>통합민감도 평가 (해당 계절)</div>
|
||||||
<div style={S.mapPlaceholder}>통합민감도 평가 지도</div>
|
<SensitivityMapSection data={data} editing={editing} onChange={onChange} />
|
||||||
<table style={S.table}>
|
<table style={S.table}>
|
||||||
<thead><tr><th style={S.th}>민감도</th><th style={S.th}>분포 면적(km²)</th></tr></thead>
|
<thead><tr><th style={S.th}>민감도</th><th style={S.th}>분포 면적(km²)</th></tr></thead>
|
||||||
<tbody>{data.sensitivity.map((s, i) => (
|
<tbody>{data.sensitivity.map((s, i) => (
|
||||||
|
|||||||
@ -4,12 +4,24 @@ import type { OilReportPayload } from '@common/hooks/useSubMenu';
|
|||||||
|
|
||||||
interface OilSpreadMapPanelProps {
|
interface OilSpreadMapPanelProps {
|
||||||
mapData: OilReportPayload['mapData'];
|
mapData: OilReportPayload['mapData'];
|
||||||
capturedImage: string | null;
|
capturedStep3: string | null;
|
||||||
|
capturedStep6: string | null;
|
||||||
|
onCaptureStep3: (dataUrl: string) => void;
|
||||||
|
onCaptureStep6: (dataUrl: string) => void;
|
||||||
|
onResetStep3: () => void;
|
||||||
|
onResetStep6: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapSlotProps {
|
||||||
|
label: string;
|
||||||
|
step: number;
|
||||||
|
mapData: NonNullable<OilReportPayload['mapData']>;
|
||||||
|
captured: string | null;
|
||||||
onCapture: (dataUrl: string) => void;
|
onCapture: (dataUrl: string) => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSpreadMapPanelProps) => {
|
const MapSlot = ({ label, step, mapData, captured, onCapture, onReset }: MapSlotProps) => {
|
||||||
const captureRef = useRef<(() => Promise<string | null>) | null>(null);
|
const captureRef = useRef<(() => Promise<string | null>) | null>(null);
|
||||||
const [isCapturing, setIsCapturing] = useState(false);
|
const [isCapturing, setIsCapturing] = useState(false);
|
||||||
|
|
||||||
@ -18,29 +30,29 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
|
|||||||
setIsCapturing(true);
|
setIsCapturing(true);
|
||||||
const dataUrl = await captureRef.current();
|
const dataUrl = await captureRef.current();
|
||||||
setIsCapturing(false);
|
setIsCapturing(false);
|
||||||
if (dataUrl) {
|
if (dataUrl) onCapture(dataUrl);
|
||||||
onCapture(dataUrl);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!mapData) {
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-[280px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
|
<div className="flex flex-col">
|
||||||
확산 예측 데이터가 없습니다. 예측 탭에서 시뮬레이션을 실행 후 보고서를 생성하세요.
|
{/* 라벨 */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
|
<span
|
||||||
|
className="text-[11px] font-bold font-korean px-2 py-0.5 rounded"
|
||||||
|
style={{ background: 'rgba(6,182,212,0.12)', color: '#06b6d4', border: '1px solid rgba(6,182,212,0.25)' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{/* 지도 + 캡처 오버레이 */}
|
||||||
<div className="mb-4">
|
<div className="relative rounded-lg border border-border overflow-hidden" style={{ height: '300px' }}>
|
||||||
{/* 지도 + 오버레이 컨테이너 — MapView 항상 마운트 유지 (deck.gl rAF race condition 방지) */}
|
|
||||||
<div className="relative w-full rounded-lg border border-border overflow-hidden" style={{ height: '620px' }}>
|
|
||||||
<MapView
|
<MapView
|
||||||
center={mapData.center}
|
center={mapData.center}
|
||||||
zoom={mapData.zoom}
|
zoom={mapData.zoom}
|
||||||
incidentCoord={{ lat: mapData.center[0], lon: mapData.center[1] }}
|
incidentCoord={{ lat: mapData.center[0], lon: mapData.center[1] }}
|
||||||
oilTrajectory={mapData.trajectory}
|
oilTrajectory={mapData.trajectory}
|
||||||
externalCurrentTime={mapData.currentStep}
|
externalCurrentTime={step}
|
||||||
centerPoints={mapData.centerPoints}
|
centerPoints={mapData.centerPoints}
|
||||||
showBeached={true}
|
showBeached={true}
|
||||||
showTimeLabel={true}
|
showTimeLabel={true}
|
||||||
@ -50,27 +62,26 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
|
|||||||
lightMode
|
lightMode
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 캡처 이미지 오버레이 — 우측 상단 */}
|
{captured && (
|
||||||
{capturedImage && (
|
<div className="absolute top-2 right-2 z-10" style={{ width: '180px' }}>
|
||||||
<div className="absolute top-3 right-3 z-10" style={{ width: '220px' }}>
|
|
||||||
<div
|
<div
|
||||||
className="rounded-lg overflow-hidden"
|
className="rounded-lg overflow-hidden"
|
||||||
style={{ border: '1px solid rgba(6,182,212,0.5)', boxShadow: '0 4px 16px rgba(0,0,0,0.5)' }}
|
style={{ border: '1px solid rgba(6,182,212,0.5)', boxShadow: '0 4px 16px rgba(0,0,0,0.5)' }}
|
||||||
>
|
>
|
||||||
<img src={capturedImage} alt="확산예측 지도 캡처" className="w-full block" />
|
<img src={captured} alt={`${label} 캡처`} className="w-full block" />
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between px-2.5 py-1.5"
|
className="flex items-center justify-between px-2 py-1"
|
||||||
style={{ background: 'rgba(15,23,42,0.85)', borderTop: '1px solid rgba(6,182,212,0.3)' }}
|
style={{ background: 'rgba(15,23,42,0.85)', borderTop: '1px solid rgba(6,182,212,0.3)' }}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] font-korean font-semibold" style={{ color: '#06b6d4' }}>
|
<span className="text-[9px] font-korean font-semibold" style={{ color: '#06b6d4' }}>
|
||||||
📷 캡처 완료
|
📷 캡처 완료
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
className="text-[10px] font-korean hover:text-text-1 transition-colors"
|
className="text-[9px] font-korean hover:text-text-1 transition-colors"
|
||||||
style={{ color: 'rgba(148,163,184,0.8)' }}
|
style={{ color: 'rgba(148,163,184,0.8)' }}
|
||||||
>
|
>
|
||||||
다시 선택
|
다시
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -78,30 +89,69 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 하단 안내 + 캡처 버튼 */}
|
{/* 캡처 버튼 */}
|
||||||
<div className="flex items-center justify-between mt-2">
|
<div className="flex items-center justify-between mt-1.5">
|
||||||
<p className="text-[10px] text-text-3 font-korean">
|
<p className="text-[9px] text-text-3 font-korean">
|
||||||
{capturedImage
|
{captured ? 'PDF 출력 시 포함됩니다.' : '원하는 범위를 선택 후 캡처하세요.'}
|
||||||
? 'PDF 다운로드 시 캡처된 이미지가 포함됩니다.'
|
|
||||||
: '지도를 이동/확대하여 원하는 범위를 선택한 후 캡처하세요.'}
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleCapture}
|
onClick={handleCapture}
|
||||||
disabled={isCapturing || !!capturedImage}
|
disabled={isCapturing || !!captured}
|
||||||
className="px-3 py-1.5 text-[11px] font-semibold rounded transition-all font-korean flex items-center gap-1.5"
|
className="px-2.5 py-1 text-[10px] font-semibold rounded transition-all font-korean flex items-center gap-1"
|
||||||
style={{
|
style={{
|
||||||
background: capturedImage ? 'rgba(6,182,212,0.06)' : 'rgba(6,182,212,0.12)',
|
background: captured ? 'rgba(6,182,212,0.06)' : 'rgba(6,182,212,0.12)',
|
||||||
border: '1px solid rgba(6,182,212,0.4)',
|
border: '1px solid rgba(6,182,212,0.4)',
|
||||||
color: capturedImage ? 'rgba(6,182,212,0.5)' : '#06b6d4',
|
color: captured ? 'rgba(6,182,212,0.5)' : '#06b6d4',
|
||||||
opacity: isCapturing ? 0.6 : 1,
|
opacity: isCapturing ? 0.6 : 1,
|
||||||
cursor: capturedImage ? 'default' : 'pointer',
|
cursor: captured ? 'default' : 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{capturedImage ? '✓ 캡처됨' : '📷 이 범위로 캡처'}
|
{captured ? '✓ 캡처됨' : '📷 캡처'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OilSpreadMapPanel = ({
|
||||||
|
mapData,
|
||||||
|
capturedStep3,
|
||||||
|
capturedStep6,
|
||||||
|
onCaptureStep3,
|
||||||
|
onCaptureStep6,
|
||||||
|
onResetStep3,
|
||||||
|
onResetStep6,
|
||||||
|
}: OilSpreadMapPanelProps) => {
|
||||||
|
if (!mapData) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[200px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
|
||||||
|
확산 예측 데이터가 없습니다. 예측 탭에서 시뮬레이션을 실행 후 보고서를 생성하세요.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<MapSlot
|
||||||
|
label="3시간 후"
|
||||||
|
step={3}
|
||||||
|
mapData={mapData}
|
||||||
|
captured={capturedStep3}
|
||||||
|
onCapture={onCaptureStep3}
|
||||||
|
onReset={onResetStep3}
|
||||||
|
/>
|
||||||
|
<MapSlot
|
||||||
|
label="6시간 후"
|
||||||
|
step={6}
|
||||||
|
mapData={mapData}
|
||||||
|
captured={capturedStep6}
|
||||||
|
onCapture={onCaptureStep6}
|
||||||
|
onReset={onResetStep6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default OilSpreadMapPanel;
|
export default OilSpreadMapPanel;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
createEmptyReport,
|
createEmptyReport,
|
||||||
} from './OilSpillReportTemplate';
|
} from './OilSpillReportTemplate';
|
||||||
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu';
|
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu';
|
||||||
|
import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore';
|
||||||
import OilSpreadMapPanel from './OilSpreadMapPanel';
|
import OilSpreadMapPanel from './OilSpreadMapPanel';
|
||||||
import { saveReport } from '../services/reportsApi';
|
import { saveReport } from '../services/reportsApi';
|
||||||
import {
|
import {
|
||||||
@ -32,8 +33,11 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null)
|
const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null)
|
||||||
// OIL 실 데이터 (없으면 sampleOilData fallback)
|
// OIL 실 데이터 (없으면 sampleOilData fallback)
|
||||||
const [oilPayload, setOilPayload] = useState<OilReportPayload | null>(null)
|
const [oilPayload, setOilPayload] = useState<OilReportPayload | null>(null)
|
||||||
// 확산예측 지도 캡처 이미지
|
// 기상 스냅샷 (관측소명, 수집시각)
|
||||||
const [oilMapCaptured, setOilMapCaptured] = useState<string | null>(null)
|
const weatherSnapshot = useWeatherSnapshotStore(s => s.snapshot)
|
||||||
|
// 확산예측 지도 캡처 이미지 (3h/6h)
|
||||||
|
const [capturedStep3, setCapturedStep3] = useState<string | null>(null)
|
||||||
|
const [capturedStep6, setCapturedStep6] = useState<string | null>(null)
|
||||||
|
|
||||||
// 외부에서 카테고리 힌트가 변경되면 반영
|
// 외부에서 카테고리 힌트가 변경되면 반영
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -94,8 +98,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
sunset: '',
|
sunset: '',
|
||||||
windDir: oilPayload.weather.windDir,
|
windDir: oilPayload.weather.windDir,
|
||||||
windSpeed: oilPayload.weather.windSpeed,
|
windSpeed: oilPayload.weather.windSpeed,
|
||||||
currentDir: '',
|
currentDir: oilPayload.weather.currentDir ?? '',
|
||||||
currentSpeed: '',
|
currentSpeed: oilPayload.weather.currentSpeed ?? '',
|
||||||
waveHeight: oilPayload.weather.waveHeight,
|
waveHeight: oilPayload.weather.waveHeight,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@ -109,27 +113,24 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
coastAttachTotal: oilPayload.pollution.coastAttach,
|
coastAttachTotal: oilPayload.pollution.coastAttach,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 유출유확산예측 결과 — 모델별 비교 (oil-spread)
|
|
||||||
const spreadLines = [
|
|
||||||
oilPayload.spread.kosps ? `KOSPS: ${oilPayload.spread.kosps}` : '',
|
|
||||||
oilPayload.spread.openDrift ? `OpenDrift: ${oilPayload.spread.openDrift}` : '',
|
|
||||||
oilPayload.spread.poseidon ? `POSEIDON: ${oilPayload.spread.poseidon}` : '',
|
|
||||||
].filter(Boolean);
|
|
||||||
if (spreadLines.length > 0) {
|
|
||||||
report.analysis = spreadLines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 스텝별 오염종합 상황 (3h/6h) → report.spread
|
// 스텝별 오염종합 상황 (3h/6h) → report.spread
|
||||||
if (oilPayload.spreadSteps) {
|
if (oilPayload.spreadSteps) {
|
||||||
report.spread = oilPayload.spreadSteps;
|
report.spread = oilPayload.spreadSteps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// acdntSn 전달 (민감자원 지도 로드용)
|
||||||
|
if (oilPayload.acdntSn) {
|
||||||
|
(report as typeof report & { acdntSn?: number }).acdntSn = oilPayload.acdntSn;
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
report.incident.pollutant = '';
|
report.incident.pollutant = '';
|
||||||
report.incident.spillAmount = '';
|
report.incident.spillAmount = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (activeCat === 0 && oilMapCaptured) {
|
if (activeCat === 0) {
|
||||||
report.capturedMapImage = oilMapCaptured;
|
if (capturedStep3) report.step3MapImage = capturedStep3;
|
||||||
|
if (capturedStep6) report.step6MapImage = capturedStep6;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await saveReport(report)
|
await saveReport(report)
|
||||||
@ -148,20 +149,22 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
// OIL 섹션에 실 데이터 삽입
|
// OIL 섹션에 실 데이터 삽입
|
||||||
if (activeCat === 0) {
|
if (activeCat === 0) {
|
||||||
if (sec.id === 'oil-spread') {
|
if (sec.id === 'oil-spread') {
|
||||||
const mapImg = oilMapCaptured
|
const img3 = capturedStep3
|
||||||
? `<img src="${oilMapCaptured}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:8px;margin-bottom:12px;" />`
|
? `<img src="${capturedStep3}" style="width:100%;max-height:260px;object-fit:contain;border:1px solid #ddd;border-radius:6px;" />`
|
||||||
: '<div style="height:60px;background:#f5f5f5;border:1px solid #ddd;border-radius:8px;display:flex;align-items:center;justify-content:center;color:#999;margin-bottom:12px;font-size:12px;">[확산예측 지도 미캡처]</div>';
|
: '<div style="height:60px;background:#f5f5f5;border:1px solid #ddd;border-radius:6px;display:flex;align-items:center;justify-content:center;color:#999;font-size:11px;">[미캡처]</div>';
|
||||||
const spreadRows = oilPayload
|
const img6 = capturedStep6
|
||||||
? [
|
? `<img src="${capturedStep6}" style="width:100%;max-height:260px;object-fit:contain;border:1px solid #ddd;border-radius:6px;" />`
|
||||||
['KOSPS', oilPayload.spread.kosps],
|
: '<div style="height:60px;background:#f5f5f5;border:1px solid #ddd;border-radius:6px;display:flex;align-items:center;justify-content:center;color:#999;font-size:11px;">[미캡처]</div>';
|
||||||
['OpenDrift', oilPayload.spread.openDrift],
|
const mapsHtml = `<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px"><div><p style="font-size:11px;font-weight:bold;color:#0891b2;margin-bottom:4px;">3시간 후</p>${img3}</div><div><p style="font-size:11px;font-weight:bold;color:#0891b2;margin-bottom:4px;">6시간 후</p>${img6}</div></div>`;
|
||||||
['POSEIDON', oilPayload.spread.poseidon],
|
const spreadStepRows = oilPayload?.spreadSteps && oilPayload.spreadSteps.length > 0
|
||||||
]
|
? oilPayload.spreadSteps.map(s =>
|
||||||
: [['KOSPS', '—'], ['OpenDrift', '—'], ['POSEIDON', '—']];
|
`<tr><td style="padding:6px 8px;border:1px solid #ddd;text-align:center;font-weight:bold;">${s.elapsed}</td><td style="padding:6px 8px;border:1px solid #ddd;text-align:right;">${s.weathered || '—'}</td><td style="padding:6px 8px;border:1px solid #ddd;text-align:right;">${s.seaRemain || '—'}</td><td style="padding:6px 8px;border:1px solid #ddd;text-align:right;">${s.coastAttach || '—'}</td><td style="padding:6px 8px;border:1px solid #ddd;text-align:right;">${s.area || '—'}</td></tr>`
|
||||||
const tds = spreadRows.map(r =>
|
).join('')
|
||||||
`<td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>${r[0]}</b><br/>${r[1]}</td>`
|
: '';
|
||||||
).join('');
|
const stepsTable = spreadStepRows
|
||||||
content = `${mapImg}<table style="width:100%;border-collapse:collapse;font-size:12px;"><tr>${tds}</tr></table>`;
|
? `<table style="width:100%;border-collapse:collapse;font-size:11px;margin-top:8px;"><thead><tr><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;">경과시간</th><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;">풍화량(kl)</th><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;">해상잔유량(kl)</th><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;">연안부착량(kl)</th><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;">오염해역면적(km²)</th></tr></thead><tbody>${spreadStepRows}</tbody></table>`
|
||||||
|
: '';
|
||||||
|
content = `${mapsHtml}${stepsTable}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (activeCat === 0 && sec.id === 'oil-coastal') {
|
if (activeCat === 0 && sec.id === 'oil-coastal') {
|
||||||
@ -173,6 +176,17 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
: `<p style="font-size:12px;">최초 부착시간: <b>${oilPayload.coastal?.firstTime ?? '—'}</b> / 부착 해안길이: <b>${coastLength}</b></p>`;
|
: `<p style="font-size:12px;">최초 부착시간: <b>${oilPayload.coastal?.firstTime ?? '—'}</b> / 부착 해안길이: <b>${coastLength}</b></p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (activeCat === 0 && sec.id === 'oil-sensitive') {
|
||||||
|
const resources = oilPayload?.sensitiveResources;
|
||||||
|
if (resources && resources.length > 0) {
|
||||||
|
const headerRow = `<tr><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;text-align:left;">구분</th><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;text-align:center;">개소</th><th style="padding:6px 8px;border:1px solid #ddd;background:#f5f5f5;text-align:right;">면적</th></tr>`;
|
||||||
|
const dataRows = resources.map(r => {
|
||||||
|
const areaText = r.totalArea != null ? `${r.totalArea.toFixed(2)} ha` : '—';
|
||||||
|
return `<tr><td style="padding:6px 8px;border:1px solid #ddd;">${r.category}</td><td style="padding:6px 8px;border:1px solid #ddd;text-align:center;">${r.count}개소</td><td style="padding:6px 8px;border:1px solid #ddd;text-align:right;">${areaText}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
content = `<table style="width:100%;border-collapse:collapse;font-size:12px;">${headerRow}${dataRows}</table>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (activeCat === 0 && oilPayload) {
|
if (activeCat === 0 && oilPayload) {
|
||||||
if (sec.id === 'oil-pollution') {
|
if (sec.id === 'oil-pollution') {
|
||||||
const rows = [
|
const rows = [
|
||||||
@ -366,10 +380,39 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
<>
|
<>
|
||||||
<OilSpreadMapPanel
|
<OilSpreadMapPanel
|
||||||
mapData={oilPayload?.mapData ?? null}
|
mapData={oilPayload?.mapData ?? null}
|
||||||
capturedImage={oilMapCaptured}
|
capturedStep3={capturedStep3}
|
||||||
onCapture={(dataUrl) => setOilMapCaptured(dataUrl)}
|
capturedStep6={capturedStep6}
|
||||||
onReset={() => setOilMapCaptured(null)}
|
onCaptureStep3={setCapturedStep3}
|
||||||
|
onCaptureStep6={setCapturedStep6}
|
||||||
|
onResetStep3={() => setCapturedStep3(null)}
|
||||||
|
onResetStep6={() => setCapturedStep6(null)}
|
||||||
/>
|
/>
|
||||||
|
{oilPayload?.spreadSteps && oilPayload.spreadSteps.length > 0 && (
|
||||||
|
<div className="mb-4 overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-bg-3">
|
||||||
|
<th className="px-3 py-2 text-center font-semibold text-text-3 font-korean">경과시간</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold text-text-3 font-korean">풍화량(kl)</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold text-text-3 font-korean">해상잔유량(kl)</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold text-text-3 font-korean">연안부착량(kl)</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold text-text-3 font-korean">오염해역면적(km²)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{oilPayload.spreadSteps.map((s, i) => (
|
||||||
|
<tr key={i} className="border-b border-border">
|
||||||
|
<td className="px-3 py-2 text-center font-semibold text-accent-1 font-korean">{s.elapsed}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-mono text-text-1">{s.weathered || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-mono text-text-1">{s.seaRemain || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-mono text-text-1">{s.coastAttach || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-mono text-text-1">{s.area || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{[
|
{[
|
||||||
{ label: 'KOSPS', value: oilPayload?.spread.kosps || '—', color: '#06b6d4' },
|
{ label: 'KOSPS', value: oilPayload?.spread.kosps || '—', color: '#06b6d4' },
|
||||||
@ -410,11 +453,43 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
</table>
|
</table>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{sec.id === 'oil-sensitive' && (
|
{sec.id === 'oil-sensitive' && (() => {
|
||||||
|
const resources = oilPayload?.sensitiveResources;
|
||||||
|
if (!resources || resources.length === 0) {
|
||||||
|
return (
|
||||||
<p className="text-[12px] text-text-3 font-korean italic">
|
<p className="text-[12px] text-text-3 font-korean italic">
|
||||||
현재 민감자원 데이터가 없습니다.
|
현재 민감자원 데이터가 없습니다.
|
||||||
</p>
|
</p>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<table className="w-full table-fixed border-collapse">
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '40%' }} />
|
||||||
|
<col style={{ width: '30%' }} />
|
||||||
|
<col style={{ width: '30%' }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="px-4 py-2 text-[11px] text-text-3 font-korean text-left bg-[rgba(255,255,255,0.02)]">구분</th>
|
||||||
|
<th className="px-4 py-2 text-[11px] text-text-3 font-korean text-right bg-[rgba(255,255,255,0.02)]">개소</th>
|
||||||
|
<th className="px-4 py-2 text-[11px] text-text-3 font-korean text-right bg-[rgba(255,255,255,0.02)]">면적</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{resources.map((r, i) => (
|
||||||
|
<tr key={i} className="border-b border-border">
|
||||||
|
<td className="px-4 py-3 text-[12px] text-text-1 font-korean">{r.category}</td>
|
||||||
|
<td className="px-4 py-3 text-[12px] text-text-1 text-right"><span className="font-mono">{r.count}</span><span className="font-korean">개소</span></td>
|
||||||
|
<td className="px-4 py-3 text-[12px] text-text-1 font-mono text-right">
|
||||||
|
{r.totalArea != null ? `${r.totalArea.toFixed(2)} ha` : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{sec.id === 'oil-coastal' && (() => {
|
{sec.id === 'oil-coastal' && (() => {
|
||||||
if (!oilPayload) {
|
if (!oilPayload) {
|
||||||
return (
|
return (
|
||||||
@ -448,11 +523,50 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sec.id === 'oil-tide' && (
|
{sec.id === 'oil-tide' && (() => {
|
||||||
|
const wx = oilPayload?.weather;
|
||||||
|
if (!wx) {
|
||||||
|
return (
|
||||||
<p className="text-[12px] text-text-3 font-korean italic">
|
<p className="text-[12px] text-text-3 font-korean italic">
|
||||||
현재 조석·기상 데이터가 없습니다.
|
현재 조석·기상 데이터가 없습니다.
|
||||||
</p>
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const stationLabel = weatherSnapshot
|
||||||
|
? `${weatherSnapshot.stationName} 조위관측소`
|
||||||
|
: '조위관측소';
|
||||||
|
const capturedAt = weatherSnapshot
|
||||||
|
? new Date(weatherSnapshot.capturedAt).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
: '';
|
||||||
|
const rows = [
|
||||||
|
{ label: '풍향/풍속', value: `${wx.windDir} / ${wx.windSpeed}` },
|
||||||
|
{ label: '파고', value: wx.waveHeight + (wx.waveMaxHeight ? ` (최대 ${wx.waveMaxHeight})` : '') },
|
||||||
|
{ label: '파주기', value: wx.wavePeriod ?? '—' },
|
||||||
|
{ label: '수온', value: wx.temp },
|
||||||
|
{ label: '기압', value: wx.pressure ?? '—' },
|
||||||
|
{ label: '시정', value: wx.visibility ?? '—' },
|
||||||
|
{ label: '염분', value: wx.salinity ?? '—' },
|
||||||
|
...(wx.currentDir ? [{ label: '유향/유속', value: `${wx.currentDir} / ${wx.currentSpeed ?? '—'}` }] : []),
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="text-[11px] font-semibold text-accent-1 font-korean">{stationLabel}</span>
|
||||||
|
{capturedAt && (
|
||||||
|
<span className="text-[10px] text-text-3 font-korean">수집: {capturedAt}</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5">
|
||||||
|
{rows.map(row => (
|
||||||
|
<div key={row.label} className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] text-text-3 font-korean w-[64px] shrink-0">{row.label}</span>
|
||||||
|
<span className="text-[12px] font-semibold text-text-1 font-mono">{row.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* ── HNS 대기확산 섹션들 ── */}
|
{/* ── HNS 대기확산 섹션들 ── */}
|
||||||
{sec.id === 'hns-atm' && (
|
{sec.id === 'hns-atm' && (
|
||||||
|
|||||||
@ -302,7 +302,12 @@ export function ReportsView() {
|
|||||||
const getVal = buildReportGetVal(previewReport)
|
const getVal = buildReportGetVal(previewReport)
|
||||||
const meta = { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }
|
const meta = { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }
|
||||||
const filename = previewReport.title || tpl.label
|
const filename = previewReport.title || tpl.label
|
||||||
exportAsHWP(tpl.label, meta, tpl.sections, getVal, filename)
|
exportAsHWP(tpl.label, meta, tpl.sections, getVal, filename, {
|
||||||
|
step3: previewReport.step3MapImage || undefined,
|
||||||
|
step6: previewReport.step6MapImage || undefined,
|
||||||
|
sensitiveMap: previewReport.sensitiveMapImage || undefined,
|
||||||
|
sensitivityMap: previewReport.sensitivityMapImage || undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-full flex items-center justify-center gap-1.5 font-korean text-[12px] font-bold cursor-pointer rounded-md py-[11px]"
|
className="w-full flex items-center justify-center gap-1.5 font-korean text-[12px] font-bold cursor-pointer rounded-md py-[11px]"
|
||||||
@ -357,38 +362,52 @@ export function ReportsView() {
|
|||||||
{[
|
{[
|
||||||
previewReport.incident.pollutant && `유출유종: ${previewReport.incident.pollutant}`,
|
previewReport.incident.pollutant && `유출유종: ${previewReport.incident.pollutant}`,
|
||||||
previewReport.incident.spillAmount && `유출량: ${previewReport.incident.spillAmount}`,
|
previewReport.incident.spillAmount && `유출량: ${previewReport.incident.spillAmount}`,
|
||||||
previewReport.spread?.length > 0 && `확산예측: ${previewReport.spread.length}개 시점 데이터`,
|
|
||||||
].filter(Boolean).join('\n') || '—'}
|
].filter(Boolean).join('\n') || '—'}
|
||||||
</div>
|
</div>
|
||||||
|
{(previewReport.capturedMapImage || previewReport.step3MapImage || previewReport.step6MapImage) && (
|
||||||
|
<div className="flex flex-col gap-2 mt-3">
|
||||||
{previewReport.capturedMapImage && (
|
{previewReport.capturedMapImage && (
|
||||||
<img
|
<img
|
||||||
src={previewReport.capturedMapImage}
|
src={previewReport.capturedMapImage}
|
||||||
alt="확산예측 지도 캡처"
|
alt="확산예측 지도"
|
||||||
className="w-full rounded-lg border border-border mt-3"
|
className="w-full rounded-lg border border-border"
|
||||||
style={{ maxHeight: '300px', objectFit: 'contain' }}
|
style={{ maxHeight: '300px', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{(previewReport.step3MapImage || previewReport.step6MapImage) && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{previewReport.step3MapImage && (
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute top-1.5 left-1.5 z-10 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-cyan-500/70 text-white">
|
||||||
|
3시간 후 예측
|
||||||
|
</span>
|
||||||
|
<img
|
||||||
|
src={previewReport.step3MapImage}
|
||||||
|
alt="3시간 예측 지도"
|
||||||
|
className="w-full rounded-lg border border-border"
|
||||||
|
style={{ maxHeight: '220px', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{previewReport.step6MapImage && (
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute top-1.5 left-1.5 z-10 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-cyan-500/70 text-white">
|
||||||
|
6시간 후 예측
|
||||||
|
</span>
|
||||||
|
<img
|
||||||
|
src={previewReport.step6MapImage}
|
||||||
|
alt="6시간 예측 지도"
|
||||||
|
className="w-full rounded-lg border border-border"
|
||||||
|
style={{ maxHeight: '220px', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3. 초동조치 / 대응현황 */}
|
|
||||||
<div>
|
|
||||||
<div className="font-korean text-[12px] font-bold text-primary-cyan border-b pb-1 mb-2" style={{ borderColor: 'rgba(6,182,212,0.15)' }}>
|
|
||||||
3. 초동조치 / 대응현황
|
|
||||||
</div>
|
|
||||||
<div className="font-korean text-[12px] leading-[1.7] whitespace-pre-wrap mt-2">
|
|
||||||
{previewReport.analysis || '—'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 4. 향후 계획 */}
|
|
||||||
<div>
|
|
||||||
<div className="font-korean text-[12px] font-bold text-primary-cyan border-b pb-1 mb-2" style={{ borderColor: 'rgba(6,182,212,0.15)' }}>
|
|
||||||
4. 향후 계획
|
|
||||||
</div>
|
|
||||||
<div className="font-korean text-[12px] leading-[1.7] whitespace-pre-wrap mt-2">
|
|
||||||
{previewReport.etcEquipment || '—'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -101,7 +101,7 @@ const MANIFEST_XML =
|
|||||||
/**
|
/**
|
||||||
* Contents/content.hpf: Skeleton.hwpx 기반, 날짜만 동적 생성
|
* Contents/content.hpf: Skeleton.hwpx 기반, 날짜만 동적 생성
|
||||||
*/
|
*/
|
||||||
function buildContentHpf(): string {
|
function buildContentHpf(extraManifestItems = ''): string {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
return (
|
return (
|
||||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>' +
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>' +
|
||||||
@ -135,6 +135,7 @@ function buildContentHpf(): string {
|
|||||||
'<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>' +
|
'<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>' +
|
||||||
'<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>' +
|
'<opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>' +
|
||||||
'<opf:item id="settings" href="settings.xml" media-type="application/xml"/>' +
|
'<opf:item id="settings" href="settings.xml" media-type="application/xml"/>' +
|
||||||
|
extraManifestItems +
|
||||||
'</opf:manifest>' +
|
'</opf:manifest>' +
|
||||||
'<opf:spine>' +
|
'<opf:spine>' +
|
||||||
'<opf:itemref idref="header" linear="yes"/>' +
|
'<opf:itemref idref="header" linear="yes"/>' +
|
||||||
@ -490,6 +491,30 @@ function buildEmptyPara(): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지(인라인 그림) 단락 생성
|
||||||
|
* binDataId: hh:binData id 값, widthHwp/heightHwp: HWPUNIT 크기
|
||||||
|
*/
|
||||||
|
function buildPicParagraph(binDataId: number, widthHwp: number, heightHwp: number): string {
|
||||||
|
const pId = nextId();
|
||||||
|
const picId = nextId();
|
||||||
|
return (
|
||||||
|
`<hp:p id="${pId}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">` +
|
||||||
|
'<hp:run charPrIDRef="0">' +
|
||||||
|
`<hp:pic id="${picId}" zOrder="0" numberingType="FIGURE" textWrap="FLOAT" ` +
|
||||||
|
`textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="CELL">` +
|
||||||
|
`<hp:sz width="${widthHwp}" height="${heightHwp}" widthRelTo="ABSOLUTE" heightRelTo="ABSOLUTE" protect="0"/>` +
|
||||||
|
'<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" ' +
|
||||||
|
'holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="COLUMN" ' +
|
||||||
|
'vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>' +
|
||||||
|
'<hp:outMargin left="0" right="0" top="0" bottom="0"/>' +
|
||||||
|
`<hp:img binDataIDRef="${binDataId}" effect="REAL_PIC" alpha="0"/>` +
|
||||||
|
'</hp:pic>' +
|
||||||
|
'</hp:run>' +
|
||||||
|
'</hp:p>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 셀 내 단락 (subList 내부용)
|
* 테이블 셀 내 단락 (subList 내부용)
|
||||||
*/
|
*/
|
||||||
@ -542,6 +567,104 @@ const CONTENT_WIDTH = 42520;
|
|||||||
const LABEL_WIDTH = Math.round(CONTENT_WIDTH * 0.33);
|
const LABEL_WIDTH = Math.round(CONTENT_WIDTH * 0.33);
|
||||||
const VALUE_WIDTH = CONTENT_WIDTH - LABEL_WIDTH;
|
const VALUE_WIDTH = CONTENT_WIDTH - LABEL_WIDTH;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 콘텐츠 여부 판별 (reportUtils의 __tide, __weather 등 키가 HTML 테이블 반환)
|
||||||
|
*/
|
||||||
|
function isHtmlContent(text: string): boolean {
|
||||||
|
return text.trimStart().startsWith('<');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파싱된 HTML <table> Element → HWPX hp:tbl XML 변환
|
||||||
|
*/
|
||||||
|
function buildHwpxFromHtmlTableElement(table: Element): string {
|
||||||
|
const rows = Array.from(table.querySelectorAll('tr'));
|
||||||
|
if (rows.length === 0) return '';
|
||||||
|
|
||||||
|
// 최대 열 수 산출 (colspan 고려)
|
||||||
|
let colCount = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
let rowCols = 0;
|
||||||
|
for (const cell of Array.from(row.children)) {
|
||||||
|
const span = parseInt((cell as HTMLElement).getAttribute('colspan') || '1', 10) || 1;
|
||||||
|
rowCols += span;
|
||||||
|
}
|
||||||
|
if (rowCols > colCount) colCount = rowCols;
|
||||||
|
}
|
||||||
|
if (colCount === 0) colCount = 1;
|
||||||
|
|
||||||
|
const colWidth = Math.floor(CONTENT_WIDTH / colCount);
|
||||||
|
const rowCnt = rows.length;
|
||||||
|
|
||||||
|
let rowsXml = '';
|
||||||
|
rows.forEach((row, rowIdx) => {
|
||||||
|
let colAddr = 0;
|
||||||
|
let cells = '';
|
||||||
|
Array.from(row.children).forEach((cell) => {
|
||||||
|
const isLabel = cell.tagName.toLowerCase() === 'th';
|
||||||
|
const colSpan = parseInt((cell as HTMLElement).getAttribute('colspan') || '1', 10) || 1;
|
||||||
|
const text = ((cell as HTMLElement).textContent || '').trim();
|
||||||
|
const cellWidth = colWidth * colSpan;
|
||||||
|
cells += buildCell(text, colAddr, rowIdx, colSpan, 1, cellWidth, isLabel);
|
||||||
|
colAddr += colSpan;
|
||||||
|
});
|
||||||
|
rowsXml += '<hp:tr>' + cells + '</hp:tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
const pId = nextId();
|
||||||
|
const tblId = nextId();
|
||||||
|
const tblHeight = rowCnt * 564;
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<hp:p id="${pId}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">` +
|
||||||
|
'<hp:run charPrIDRef="0">' +
|
||||||
|
`<hp:tbl id="${tblId}" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" ` +
|
||||||
|
`textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="CELL" ` +
|
||||||
|
`repeatHeader="0" rowCnt="${rowCnt}" colCnt="${colCount}" cellSpacing="0" ` +
|
||||||
|
`borderFillIDRef="2" noAdjust="0">` +
|
||||||
|
`<hp:sz width="${CONTENT_WIDTH}" height="${tblHeight}" widthRelTo="ABSOLUTE" heightRelTo="ABSOLUTE" protect="0"/>` +
|
||||||
|
'<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" ' +
|
||||||
|
'holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="COLUMN" ' +
|
||||||
|
'vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>' +
|
||||||
|
'<hp:outMargin left="0" right="0" top="0" bottom="0"/>' +
|
||||||
|
'<hp:inMargin left="0" right="0" top="0" bottom="0"/>' +
|
||||||
|
rowsXml +
|
||||||
|
'</hp:tbl>' +
|
||||||
|
'</hp:run>' +
|
||||||
|
'</hp:p>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 문자열 → HWPX XML 변환
|
||||||
|
* <table> → hp:tbl, <p> → buildPara, 복합 구조도 처리
|
||||||
|
*/
|
||||||
|
function htmlContentToHwpx(html: string): string {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(`<div>${html}</div>`, 'text/html');
|
||||||
|
const container = doc.body.firstElementChild;
|
||||||
|
if (!container) return buildPara('-', 0);
|
||||||
|
|
||||||
|
let xml = '';
|
||||||
|
for (const child of Array.from(container.childNodes)) {
|
||||||
|
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const el = child as Element;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
if (tag === 'table') {
|
||||||
|
xml += buildHwpxFromHtmlTableElement(el);
|
||||||
|
} else {
|
||||||
|
const text = ((el as HTMLElement).textContent || '').trim();
|
||||||
|
if (text) xml += buildPara(text, 0);
|
||||||
|
}
|
||||||
|
} else if (child.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = (child.textContent || '').trim();
|
||||||
|
if (text) xml += buildPara(text, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return xml || buildPara('-', 0);
|
||||||
|
}
|
||||||
|
|
||||||
function buildFieldTable(
|
function buildFieldTable(
|
||||||
fields: { key: string; label: string }[],
|
fields: { key: string; label: string }[],
|
||||||
getVal: (key: string) => string,
|
getVal: (key: string) => string,
|
||||||
@ -549,6 +672,14 @@ function buildFieldTable(
|
|||||||
const rowCnt = fields.length;
|
const rowCnt = fields.length;
|
||||||
if (rowCnt === 0) return '';
|
if (rowCnt === 0) return '';
|
||||||
|
|
||||||
|
// 단일 필드 + 빈 label + HTML 값인 경우 → HTML→HWPX 변환
|
||||||
|
if (fields.length === 1 && !fields[0].label) {
|
||||||
|
const value = getVal(fields[0].key) || '-';
|
||||||
|
if (isHtmlContent(value)) {
|
||||||
|
return htmlContentToHwpx(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let rows = '';
|
let rows = '';
|
||||||
fields.forEach((field, rowIdx) => {
|
fields.forEach((field, rowIdx) => {
|
||||||
const value = getVal(field.key) || '-';
|
const value = getVal(field.key) || '-';
|
||||||
@ -604,6 +735,7 @@ function buildSection0Xml(
|
|||||||
meta: ReportMeta,
|
meta: ReportMeta,
|
||||||
sections: ReportSection[],
|
sections: ReportSection[],
|
||||||
getVal: (key: string) => string,
|
getVal: (key: string) => string,
|
||||||
|
imageBinIds?: { step3?: number; step6?: number; sensitiveMap?: number; sensitivityMap?: number },
|
||||||
): string {
|
): string {
|
||||||
// ID 시퀀스 초기화 (재사용 시 충돌 방지)
|
// ID 시퀀스 초기화 (재사용 시 충돌 방지)
|
||||||
_idSeq = 1000000000;
|
_idSeq = 1000000000;
|
||||||
@ -634,9 +766,43 @@ function buildSection0Xml(
|
|||||||
// 섹션 제목 (11pt = charPrId 6)
|
// 섹션 제목 (11pt = charPrId 6)
|
||||||
body += buildPara(section.title, 6);
|
body += buildPara(section.title, 6);
|
||||||
|
|
||||||
// 필드 테이블
|
// __spreadMaps 필드 포함 섹션: 이미지 단락 삽입 후 나머지 필드 처리
|
||||||
if (section.fields.length > 0) {
|
const hasSpreadMaps = section.fields.some(f => f.key === '__spreadMaps');
|
||||||
|
const hasSensitive = section.fields.some(f => f.key === '__sensitive');
|
||||||
|
if (hasSpreadMaps && imageBinIds) {
|
||||||
|
const regularFields = section.fields.filter(f => f.key !== '__spreadMaps');
|
||||||
|
if (imageBinIds.step3) {
|
||||||
|
body += buildPara('3시간 후 예측', 0);
|
||||||
|
body += buildPicParagraph(imageBinIds.step3, CONTENT_WIDTH, 24000);
|
||||||
|
}
|
||||||
|
if (imageBinIds.step6) {
|
||||||
|
body += buildPara('6시간 후 예측', 0);
|
||||||
|
body += buildPicParagraph(imageBinIds.step6, CONTENT_WIDTH, 24000);
|
||||||
|
}
|
||||||
|
if (regularFields.length > 0) {
|
||||||
|
body += buildFieldTable(regularFields, getVal);
|
||||||
|
}
|
||||||
|
} else if (hasSensitive) {
|
||||||
|
// 민감자원 분포 지도 — 테이블 앞
|
||||||
|
if (imageBinIds?.sensitiveMap) {
|
||||||
|
body += buildPara('민감자원 분포 지도', 0);
|
||||||
|
body += buildPicParagraph(imageBinIds.sensitiveMap, CONTENT_WIDTH, 24000);
|
||||||
|
}
|
||||||
body += buildFieldTable(section.fields, getVal);
|
body += buildFieldTable(section.fields, getVal);
|
||||||
|
// 통합민감도 평가 지도 — 테이블 뒤
|
||||||
|
if (imageBinIds?.sensitivityMap) {
|
||||||
|
body += buildPara('통합민감도 평가 지도', 0);
|
||||||
|
body += buildPicParagraph(imageBinIds.sensitivityMap, CONTENT_WIDTH, 24000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 필드 테이블
|
||||||
|
const fields = section.fields.filter(f => f.key !== '__spreadMaps');
|
||||||
|
if (hasSpreadMaps) {
|
||||||
|
// 이미지 없는 경우 __spreadMaps 필드 제외하고 나머지만 출력
|
||||||
|
if (fields.length > 0) body += buildFieldTable(fields, getVal);
|
||||||
|
} else if (section.fields.length > 0) {
|
||||||
|
body += buildFieldTable(section.fields, getVal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 섹션 후 빈 줄
|
// 섹션 후 빈 줄
|
||||||
@ -669,7 +835,10 @@ function buildPrvText(
|
|||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
lines.push(`[${section.title}]`);
|
lines.push(`[${section.title}]`);
|
||||||
for (const field of section.fields) {
|
for (const field of section.fields) {
|
||||||
const value = getVal(field.key) || '-';
|
const raw = getVal(field.key) || '-';
|
||||||
|
const value = isHtmlContent(raw)
|
||||||
|
? raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() || '-'
|
||||||
|
: raw;
|
||||||
if (field.label) {
|
if (field.label) {
|
||||||
lines.push(` ${field.label}: ${value}`);
|
lines.push(` ${field.label}: ${value}`);
|
||||||
} else {
|
} else {
|
||||||
@ -690,6 +859,7 @@ export async function exportAsHWPX(
|
|||||||
sections: ReportSection[],
|
sections: ReportSection[],
|
||||||
getVal: (key: string) => string,
|
getVal: (key: string) => string,
|
||||||
filename: string,
|
filename: string,
|
||||||
|
images?: { step3?: string; step6?: string; sensitiveMap?: string; sensitivityMap?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
|
|
||||||
@ -703,10 +873,66 @@ export async function exportAsHWPX(
|
|||||||
zip.file('META-INF/container.rdf', CONTAINER_RDF);
|
zip.file('META-INF/container.rdf', CONTAINER_RDF);
|
||||||
zip.file('META-INF/manifest.xml', MANIFEST_XML);
|
zip.file('META-INF/manifest.xml', MANIFEST_XML);
|
||||||
|
|
||||||
|
// 이미지 처리
|
||||||
|
let imageBinIds: { step3?: number; step6?: number; sensitiveMap?: number; sensitivityMap?: number } | undefined;
|
||||||
|
let extraManifestItems = '';
|
||||||
|
let binDataListXml = '';
|
||||||
|
let binCount = 0;
|
||||||
|
|
||||||
|
const processImage = (src: string, binId: number, fileId: string) => {
|
||||||
|
// 실제 이미지 포맷 감지 (JPEG vs PNG)
|
||||||
|
const isJpeg = src.startsWith('data:image/jpeg') || src.startsWith('data:image/jpg');
|
||||||
|
const ext = isJpeg ? 'jpg' : 'png';
|
||||||
|
const mediaType = isJpeg ? 'image/jpeg' : 'image/png';
|
||||||
|
const filePath = `BinData/image${binId}.${ext}`;
|
||||||
|
|
||||||
|
const base64 = src.replace(/^data:image\/\w+;base64,/, '');
|
||||||
|
zip.file(filePath, base64, { base64: true });
|
||||||
|
extraManifestItems += `<opf:item id="${fileId}" href="${filePath}" media-type="${mediaType}"/>`;
|
||||||
|
// inMemory="NO": 데이터는 ZIP 내 파일로 저장, 요소 내용은 파일 경로
|
||||||
|
binDataListXml +=
|
||||||
|
`<hh:binData id="${binId}" isSameDocData="0" compress="YES" inMemory="NO" ` +
|
||||||
|
`doNotCompressFile="0" blockDecompress="0" limitWidth="0" limitHeight="0">${filePath}</hh:binData>`;
|
||||||
|
binCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (images?.step3 || images?.step6) {
|
||||||
|
imageBinIds = {};
|
||||||
|
if (images.step3) {
|
||||||
|
imageBinIds.step3 = 1;
|
||||||
|
processImage(images.step3, 1, 'image1');
|
||||||
|
}
|
||||||
|
if (images.step6) {
|
||||||
|
imageBinIds.step6 = 2;
|
||||||
|
processImage(images.step6, 2, 'image2');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (images?.sensitiveMap) {
|
||||||
|
imageBinIds = imageBinIds ?? {};
|
||||||
|
imageBinIds.sensitiveMap = 3;
|
||||||
|
processImage(images.sensitiveMap, 3, 'image3');
|
||||||
|
}
|
||||||
|
if (images?.sensitivityMap) {
|
||||||
|
imageBinIds = imageBinIds ?? {};
|
||||||
|
imageBinIds.sensitivityMap = 4;
|
||||||
|
processImage(images.sensitivityMap, 4, 'image4');
|
||||||
|
}
|
||||||
|
|
||||||
|
// header.xml: binDataList를 hh:refList 내부에 삽입 (HWPML 스펙 준수)
|
||||||
|
let headerXml = HEADER_XML;
|
||||||
|
if (binCount > 0) {
|
||||||
|
const binDataList =
|
||||||
|
`<hh:binDataList itemCnt="${binCount}">` +
|
||||||
|
binDataListXml +
|
||||||
|
'</hh:binDataList>';
|
||||||
|
// refList 닫힘 태그 직전에 삽입해야 함 (binDataList는 refList의 자식)
|
||||||
|
headerXml = HEADER_XML.replace('</hh:refList>', binDataList + '</hh:refList>');
|
||||||
|
}
|
||||||
|
|
||||||
// Contents
|
// Contents
|
||||||
zip.file('Contents/content.hpf', buildContentHpf());
|
zip.file('Contents/content.hpf', buildContentHpf(extraManifestItems));
|
||||||
zip.file('Contents/header.xml', HEADER_XML);
|
zip.file('Contents/header.xml', headerXml);
|
||||||
zip.file('Contents/section0.xml', buildSection0Xml(templateLabel, meta, sections, getVal));
|
zip.file('Contents/section0.xml', buildSection0Xml(templateLabel, meta, sections, getVal, imageBinIds));
|
||||||
|
|
||||||
// Preview
|
// Preview
|
||||||
zip.file('Preview/PrvText.txt', buildPrvText(templateLabel, meta, sections, getVal));
|
zip.file('Preview/PrvText.txt', buildPrvText(templateLabel, meta, sections, getVal));
|
||||||
|
|||||||
@ -77,7 +77,10 @@ export const templateTypes: TemplateType[] = [
|
|||||||
]},
|
]},
|
||||||
{ title: '3. 조석 현황', fields: [{ key: '__tide', label: '', type: 'textarea' }] },
|
{ title: '3. 조석 현황', fields: [{ key: '__tide', label: '', type: 'textarea' }] },
|
||||||
{ title: '4. 해양기상 현황', fields: [{ key: '__weather', label: '', type: 'textarea' }] },
|
{ title: '4. 해양기상 현황', fields: [{ key: '__weather', label: '', type: 'textarea' }] },
|
||||||
{ title: '5. 확산예측 결과', fields: [{ key: '__spread', label: '', type: 'textarea' }] },
|
{ title: '5. 확산예측 결과', fields: [
|
||||||
|
{ key: '__spreadMaps', label: '', type: 'textarea' },
|
||||||
|
{ key: '__spread', label: '', type: 'textarea' },
|
||||||
|
] },
|
||||||
{ title: '6. 민감자원 현황', fields: [{ key: '__sensitive', label: '', type: 'textarea' }] },
|
{ title: '6. 민감자원 현황', fields: [{ key: '__sensitive', label: '', type: 'textarea' }] },
|
||||||
{ title: '7. 방제 자원', fields: [{ key: '__vessels', label: '', type: 'textarea' }] },
|
{ title: '7. 방제 자원', fields: [{ key: '__vessels', label: '', type: 'textarea' }] },
|
||||||
{ title: '8. 회수·처리 현황',fields: [{ key: '__recovery', label: '', type: 'textarea' }] },
|
{ title: '8. 회수·처리 현황',fields: [{ key: '__recovery', label: '', type: 'textarea' }] },
|
||||||
|
|||||||
@ -47,9 +47,10 @@ export async function exportAsHWP(
|
|||||||
sections: { title: string; fields: { key: string; label: string }[] }[],
|
sections: { title: string; fields: { key: string; label: string }[] }[],
|
||||||
getVal: (key: string) => string,
|
getVal: (key: string) => string,
|
||||||
filename: string,
|
filename: string,
|
||||||
|
images?: { step3?: string; step6?: string; sensitiveMap?: string; sensitivityMap?: string },
|
||||||
) {
|
) {
|
||||||
const { exportAsHWPX } = await import('./hwpxExport');
|
const { exportAsHWPX } = await import('./hwpxExport');
|
||||||
await exportAsHWPX(templateLabel, meta, sections, getVal, filename);
|
await exportAsHWPX(templateLabel, meta, sections, getVal, filename, images);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewState =
|
export type ViewState =
|
||||||
@ -121,6 +122,14 @@ function formatSpreadTable(spread: OilSpillReportData['spread']): string {
|
|||||||
|
|
||||||
function formatSensitiveTable(r: OilSpillReportData): string {
|
function formatSensitiveTable(r: OilSpillReportData): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|
||||||
|
if (r.sensitiveMapImage) {
|
||||||
|
parts.push(
|
||||||
|
'<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">민감자원 분포 지도</p>' +
|
||||||
|
`<img src="${r.sensitiveMapImage}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:6px;display:block;margin-bottom:8px;" />`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (r.aquaculture?.length) {
|
if (r.aquaculture?.length) {
|
||||||
const h = `<tr><th style="${TH}">종류</th><th style="${TH}">면적</th><th style="${TH}">거리</th></tr>`
|
const h = `<tr><th style="${TH}">종류</th><th style="${TH}">면적</th><th style="${TH}">거리</th></tr>`
|
||||||
const rows = r.aquaculture.map(a => `<tr><td style="${TD}">${a.type}</td><td style="${TD}">${a.area}</td><td style="${TD}">${a.distance}</td></tr>`).join('')
|
const rows = r.aquaculture.map(a => `<tr><td style="${TD}">${a.type}</td><td style="${TD}">${a.area}</td><td style="${TD}">${a.distance}</td></tr>`).join('')
|
||||||
@ -151,6 +160,13 @@ function formatSensitiveTable(r: OilSpillReportData): string {
|
|||||||
const rows = r.habitat.map(h2 => `<tr><td style="${TD}">${h2.type}</td><td style="${TD}">${h2.area}</td></tr>`).join('')
|
const rows = r.habitat.map(h2 => `<tr><td style="${TD}">${h2.type}</td><td style="${TD}">${h2.area}</td></tr>`).join('')
|
||||||
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">서식지</p><table style="${TABLE}">${h}${rows}</table>`)
|
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">서식지</p><table style="${TABLE}">${h}${rows}</table>`)
|
||||||
}
|
}
|
||||||
|
if (r.sensitivityMapImage) {
|
||||||
|
parts.push(
|
||||||
|
'<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">통합민감도 평가 지도</p>' +
|
||||||
|
`<img src="${r.sensitivityMapImage}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:6px;display:block;margin-bottom:8px;" />`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (r.sensitivity?.length) {
|
if (r.sensitivity?.length) {
|
||||||
const h = `<tr><th style="${TH}">민감도</th><th style="${TH}">면적</th></tr>`
|
const h = `<tr><th style="${TH}">민감도</th><th style="${TH}">면적</th></tr>`
|
||||||
const rows = r.sensitivity.map(s => `<tr><td style="${TD}">${s.level}</td><td style="${TD}">${s.area}</td></tr>`).join('')
|
const rows = r.sensitivity.map(s => `<tr><td style="${TD}">${s.level}</td><td style="${TD}">${s.area}</td></tr>`).join('')
|
||||||
@ -196,6 +212,18 @@ export function buildReportGetVal(report: OilSpillReportData) {
|
|||||||
}
|
}
|
||||||
if (key === '__tide') return formatTideTable(report.tide)
|
if (key === '__tide') return formatTideTable(report.tide)
|
||||||
if (key === '__weather') return formatWeatherTable(report.weather)
|
if (key === '__weather') return formatWeatherTable(report.weather)
|
||||||
|
if (key === '__spreadMaps') {
|
||||||
|
const img3 = report.step3MapImage
|
||||||
|
const img6 = report.step6MapImage
|
||||||
|
if (!img3 && !img6) return ''
|
||||||
|
const cell = (label: string, src: string) =>
|
||||||
|
`<div style="flex:1;min-width:0"><p style="font-size:11px;font-weight:bold;color:#0891b2;margin:0 0 4px;">${label}</p>` +
|
||||||
|
`<img src="${src}" style="width:100%;max-height:260px;object-fit:contain;border:1px solid #ddd;border-radius:6px;" /></div>`
|
||||||
|
return `<div style="display:flex;gap:12px;margin-bottom:8px;">` +
|
||||||
|
(img3 ? cell('3시간 후', img3) : '') +
|
||||||
|
(img6 ? cell('6시간 후', img6) : '') +
|
||||||
|
`</div>`
|
||||||
|
}
|
||||||
if (key === '__spread') return formatSpreadTable(report.spread)
|
if (key === '__spread') return formatSpreadTable(report.spread)
|
||||||
if (key === '__sensitive') return formatSensitiveTable(report)
|
if (key === '__sensitive') return formatSensitiveTable(report)
|
||||||
if (key === '__vessels') return formatVesselsTable(report.vessels)
|
if (key === '__vessels') return formatVesselsTable(report.vessels)
|
||||||
|
|||||||
@ -60,6 +60,7 @@ export interface ApiReportListItem {
|
|||||||
sttsCd: string;
|
sttsCd: string;
|
||||||
authorId: string;
|
authorId: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
|
acdntSn?: number | null;
|
||||||
regDtm: string;
|
regDtm: string;
|
||||||
mdfcnDtm: string | null;
|
mdfcnDtm: string | null;
|
||||||
hasMapCapture?: boolean;
|
hasMapCapture?: boolean;
|
||||||
@ -75,7 +76,8 @@ export interface ApiReportSectionData {
|
|||||||
export interface ApiReportDetail extends ApiReportListItem {
|
export interface ApiReportDetail extends ApiReportListItem {
|
||||||
acdntSn: number | null;
|
acdntSn: number | null;
|
||||||
sections: ApiReportSectionData[];
|
sections: ApiReportSectionData[];
|
||||||
mapCaptureImg?: string | null;
|
step3MapImg?: string | null;
|
||||||
|
step6MapImg?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiReportListResponse {
|
export interface ApiReportListResponse {
|
||||||
@ -178,7 +180,8 @@ export async function createReportApi(input: {
|
|||||||
title: string;
|
title: string;
|
||||||
jrsdCd?: string;
|
jrsdCd?: string;
|
||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
mapCaptureImg?: string;
|
step3MapImg?: string;
|
||||||
|
step6MapImg?: string;
|
||||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||||
}): Promise<{ sn: number }> {
|
}): Promise<{ sn: number }> {
|
||||||
const res = await api.post<{ sn: number }>('/reports', input);
|
const res = await api.post<{ sn: number }>('/reports', input);
|
||||||
@ -190,7 +193,8 @@ export async function updateReportApi(sn: number, input: {
|
|||||||
jrsdCd?: string;
|
jrsdCd?: string;
|
||||||
sttsCd?: string;
|
sttsCd?: string;
|
||||||
acdntSn?: number | null;
|
acdntSn?: number | null;
|
||||||
mapCaptureImg?: string | null;
|
step3MapImg?: string | null;
|
||||||
|
step6MapImg?: string | null;
|
||||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await api.post(`/reports/${sn}/update`, input);
|
await api.post(`/reports/${sn}/update`, input);
|
||||||
@ -236,14 +240,26 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
|
|||||||
// analysis + etcEquipment 합산
|
// analysis + etcEquipment 합산
|
||||||
sections.push({ sectCd: 'analysis', sectData: { analysis: data.analysis, etcEquipment: data.etcEquipment }, sortOrd: sortOrd++ });
|
sections.push({ sectCd: 'analysis', sectData: { analysis: data.analysis, etcEquipment: data.etcEquipment }, sortOrd: sortOrd++ });
|
||||||
|
|
||||||
|
// 민감자원 지도 이미지 (섹션으로 저장)
|
||||||
|
const extData = data as OilSpillReportData & { reportSn?: number; acdntSn?: number };
|
||||||
|
if (extData.sensitiveMapImage !== undefined) {
|
||||||
|
sections.push({ sectCd: 'sensitive-map', sectData: { mapImage: extData.sensitiveMapImage }, sortOrd: sortOrd++ });
|
||||||
|
}
|
||||||
|
// 통합민감도 평가 지도 이미지 (섹션으로 저장)
|
||||||
|
if (extData.sensitivityMapImage !== undefined) {
|
||||||
|
sections.push({ sectCd: 'sensitivity-map', sectData: { mapImage: extData.sensitivityMapImage }, sortOrd: sortOrd++ });
|
||||||
|
}
|
||||||
|
|
||||||
// reportSn이 있으면 update, 없으면 create
|
// reportSn이 있으면 update, 없으면 create
|
||||||
const existingSn = (data as OilSpillReportData & { reportSn?: number }).reportSn;
|
const existingSn = extData.reportSn;
|
||||||
if (existingSn) {
|
if (existingSn) {
|
||||||
await updateReportApi(existingSn, {
|
await updateReportApi(existingSn, {
|
||||||
title: data.title || data.incident.name || '보고서',
|
title: data.title || data.incident.name || '보고서',
|
||||||
jrsdCd: data.jurisdiction,
|
jrsdCd: data.jurisdiction,
|
||||||
sttsCd,
|
sttsCd,
|
||||||
mapCaptureImg: data.capturedMapImage !== undefined ? (data.capturedMapImage || null) : undefined,
|
acdntSn: extData.acdntSn ?? null,
|
||||||
|
step3MapImg: data.step3MapImage !== undefined ? (data.step3MapImage || null) : undefined,
|
||||||
|
step6MapImg: data.step6MapImage !== undefined ? (data.step6MapImage || null) : undefined,
|
||||||
sections,
|
sections,
|
||||||
});
|
});
|
||||||
return existingSn;
|
return existingSn;
|
||||||
@ -252,10 +268,12 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
|
|||||||
const result = await createReportApi({
|
const result = await createReportApi({
|
||||||
tmplSn,
|
tmplSn,
|
||||||
ctgrSn,
|
ctgrSn,
|
||||||
|
acdntSn: extData.acdntSn,
|
||||||
title: data.title || data.incident.name || '보고서',
|
title: data.title || data.incident.name || '보고서',
|
||||||
jrsdCd: data.jurisdiction,
|
jrsdCd: data.jurisdiction,
|
||||||
sttsCd,
|
sttsCd,
|
||||||
mapCaptureImg: data.capturedMapImage || undefined,
|
step3MapImg: data.step3MapImage || undefined,
|
||||||
|
step6MapImg: data.step6MapImage || undefined,
|
||||||
sections,
|
sections,
|
||||||
});
|
});
|
||||||
return result.sn;
|
return result.sn;
|
||||||
@ -273,6 +291,7 @@ export function apiListItemToReportData(item: ApiReportListItem): OilSpillReport
|
|||||||
jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청',
|
jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청',
|
||||||
status: CODE_TO_STATUS[item.sttsCd] || '테스트',
|
status: CODE_TO_STATUS[item.sttsCd] || '테스트',
|
||||||
hasMapCapture: item.hasMapCapture,
|
hasMapCapture: item.hasMapCapture,
|
||||||
|
acdntSn: item.acdntSn ?? undefined,
|
||||||
// 목록에서는 섹션 데이터 없음 — 빈 기본값
|
// 목록에서는 섹션 데이터 없음 — 빈 기본값
|
||||||
incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' },
|
incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' },
|
||||||
tide: [], weather: [], spread: [],
|
tide: [], weather: [], spread: [],
|
||||||
@ -341,6 +360,12 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
|
|||||||
case 'result':
|
case 'result':
|
||||||
reportData.result = d as OilSpillReportData['result'];
|
reportData.result = d as OilSpillReportData['result'];
|
||||||
break;
|
break;
|
||||||
|
case 'sensitive-map':
|
||||||
|
reportData.sensitiveMapImage = (d as { mapImage?: string }).mapImage;
|
||||||
|
break;
|
||||||
|
case 'sensitivity-map':
|
||||||
|
reportData.sensitivityMapImage = (d as { mapImage?: string }).mapImage;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -350,8 +375,14 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
|
|||||||
`위도 ${parseFloat(reportData.incident.lat).toFixed(4)}, 경도 ${parseFloat(reportData.incident.lon).toFixed(4)}`;
|
`위도 ${parseFloat(reportData.incident.lat).toFixed(4)}, 경도 ${parseFloat(reportData.incident.lon).toFixed(4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detail.mapCaptureImg) {
|
if (detail.step3MapImg) {
|
||||||
reportData.capturedMapImage = detail.mapCaptureImg;
|
reportData.step3MapImage = detail.step3MapImg;
|
||||||
|
}
|
||||||
|
if (detail.step6MapImg) {
|
||||||
|
reportData.step6MapImage = detail.step6MapImg;
|
||||||
|
}
|
||||||
|
if (detail.acdntSn != null) {
|
||||||
|
(reportData as typeof reportData & { acdntSn?: number }).acdntSn = detail.acdntSn;
|
||||||
}
|
}
|
||||||
|
|
||||||
return reportData;
|
return reportData;
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type { StyleSpecification } from 'maplibre-gl'
|
|||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import type { ScatSegment } from './scatTypes'
|
import type { ScatSegment } from './scatTypes'
|
||||||
import type { ApiZoneItem } from '../services/scatApi'
|
import type { ApiZoneItem } from '../services/scatApi'
|
||||||
import { esiColor, jejuCoastCoords } from './scatConstants'
|
import { esiColor } from './scatConstants'
|
||||||
import { hexToRgba } from '@common/components/map/mapUtils'
|
import { hexToRgba } from '@common/components/map/mapUtils'
|
||||||
|
|
||||||
const BASE_STYLE: StyleSpecification = {
|
const BASE_STYLE: StyleSpecification = {
|
||||||
@ -87,12 +87,17 @@ function getZoomScale(zoom: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
|
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
|
||||||
function buildSegCoords(seg: ScatSegment, halfLenScale: number): [number, number][] {
|
// 인접 구간 좌표로 해안선 방향을 동적 계산
|
||||||
const coastIdx = seg.id % (jejuCoastCoords.length - 1)
|
function buildSegCoords(
|
||||||
const [clat1, clng1] = jejuCoastCoords[coastIdx]
|
seg: ScatSegment,
|
||||||
const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length]
|
halfLenScale: number,
|
||||||
const dlat = clat2 - clat1
|
segments: ScatSegment[],
|
||||||
const dlng = clng2 - clng1
|
): [number, number][] {
|
||||||
|
const idx = segments.indexOf(seg)
|
||||||
|
const prev = idx > 0 ? segments[idx - 1] : seg
|
||||||
|
const next = idx < segments.length - 1 ? segments[idx + 1] : seg
|
||||||
|
const dlat = next.lat - prev.lat
|
||||||
|
const dlng = next.lng - prev.lng
|
||||||
const dist = Math.sqrt(dlat * dlat + dlng * dlng)
|
const dist = Math.sqrt(dlat * dlat + dlng * dlng)
|
||||||
const nDlat = dist > 0 ? dlat / dist : 0
|
const nDlat = dist > 0 ? dlat / dist : 0
|
||||||
const nDlng = dist > 0 ? dlng / dist : 1
|
const nDlng = dist > 0 ? dlng / dist : 1
|
||||||
@ -126,21 +131,21 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
|||||||
|
|
||||||
const zs = useMemo(() => getZoomScale(zoom), [zoom])
|
const zs = useMemo(() => getZoomScale(zoom), [zoom])
|
||||||
|
|
||||||
// 제주도 해안선 레퍼런스 라인
|
// 제주도 해안선 레퍼런스 라인 — 하드코딩 제거, 추후 DB 기반 해안선으로 대체 예정
|
||||||
const coastlineLayer = useMemo(
|
// const coastlineLayer = useMemo(
|
||||||
() =>
|
// () =>
|
||||||
new PathLayer({
|
// new PathLayer({
|
||||||
id: 'jeju-coastline',
|
// id: 'jeju-coastline',
|
||||||
data: [{ path: jejuCoastCoords.map(([lat, lng]) => [lng, lat] as [number, number]) }],
|
// data: [{ path: jejuCoastCoords.map(([lat, lng]) => [lng, lat] as [number, number]) }],
|
||||||
getPath: (d: { path: [number, number][] }) => d.path,
|
// getPath: (d: { path: [number, number][] }) => d.path,
|
||||||
getColor: [6, 182, 212, 46],
|
// getColor: [6, 182, 212, 46],
|
||||||
getWidth: 1.5,
|
// getWidth: 1.5,
|
||||||
getDashArray: [8, 6],
|
// getDashArray: [8, 6],
|
||||||
dashJustified: true,
|
// dashJustified: true,
|
||||||
widthMinPixels: 1,
|
// widthMinPixels: 1,
|
||||||
}),
|
// }),
|
||||||
[],
|
// [],
|
||||||
)
|
// )
|
||||||
|
|
||||||
// 선택된 구간 글로우 레이어
|
// 선택된 구간 글로우 레이어
|
||||||
const glowLayer = useMemo(
|
const glowLayer = useMemo(
|
||||||
@ -148,7 +153,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
|||||||
new PathLayer({
|
new PathLayer({
|
||||||
id: 'scat-glow',
|
id: 'scat-glow',
|
||||||
data: [selectedSeg],
|
data: [selectedSeg],
|
||||||
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale),
|
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale, segments),
|
||||||
getColor: [34, 197, 94, 38],
|
getColor: [34, 197, 94, 38],
|
||||||
getWidth: zs.glowWidth,
|
getWidth: zs.glowWidth,
|
||||||
capRounded: true,
|
capRounded: true,
|
||||||
@ -159,7 +164,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
|||||||
getWidth: [zs.glowWidth],
|
getWidth: [zs.glowWidth],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[selectedSeg, zs.glowWidth, zs.halfLenScale],
|
[selectedSeg, segments, zs.glowWidth, zs.halfLenScale],
|
||||||
)
|
)
|
||||||
|
|
||||||
// ESI 색상 세그먼트 폴리라인
|
// ESI 색상 세그먼트 폴리라인
|
||||||
@ -168,7 +173,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
|||||||
new PathLayer({
|
new PathLayer({
|
||||||
id: 'scat-segments',
|
id: 'scat-segments',
|
||||||
data: segments,
|
data: segments,
|
||||||
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale),
|
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale, segments),
|
||||||
getColor: (d: ScatSegment) => {
|
getColor: (d: ScatSegment) => {
|
||||||
const isSelected = selectedSeg.id === d.id
|
const isSelected = selectedSeg.id === d.id
|
||||||
const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum)
|
const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum)
|
||||||
@ -234,10 +239,10 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const deckLayers: any[] = useMemo(() => {
|
const deckLayers: any[] = useMemo(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const layers: any[] = [coastlineLayer, glowLayer, segPathLayer]
|
const layers: any[] = [glowLayer, segPathLayer]
|
||||||
if (markerLayer) layers.push(markerLayer)
|
if (markerLayer) layers.push(markerLayer)
|
||||||
return layers
|
return layers
|
||||||
}, [coastlineLayer, glowLayer, segPathLayer, markerLayer])
|
}, [glowLayer, segPathLayer, markerLayer])
|
||||||
|
|
||||||
const doneCount = segments.filter(s => s.status === '완료').length
|
const doneCount = segments.filter(s => s.status === '완료').length
|
||||||
const progCount = segments.filter(s => s.status === '진행중').length
|
const progCount = segments.filter(s => s.status === '진행중').length
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Map, useControl } from '@vis.gl/react-maplibre'
|
import { Map, useControl } from '@vis.gl/react-maplibre'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'
|
import { ScatterplotLayer } from '@deck.gl/layers'
|
||||||
import type { StyleSpecification } from 'maplibre-gl'
|
import type { StyleSpecification } from 'maplibre-gl'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import type { ScatDetail } from './scatTypes'
|
import type { ScatDetail } from './scatTypes'
|
||||||
@ -50,45 +50,22 @@ function PopupMap({
|
|||||||
esi: string
|
esi: string
|
||||||
onMapLoad?: () => void
|
onMapLoad?: () => void
|
||||||
}) {
|
}) {
|
||||||
// 해안 구간 라인 (시뮬레이션) — [lng, lat] 순서
|
// 해안 구간 라인 / 조사 경로 — 하드코딩 방향이라 주석처리, 추후 실제 방향 데이터로 대체
|
||||||
const segLine: [number, number][] = [
|
// const segLine: [number, number][] = [
|
||||||
[lng - 0.004, lat - 0.002],
|
// [lng - 0.004, lat - 0.002],
|
||||||
[lng - 0.002, lat - 0.001],
|
// [lng - 0.002, lat - 0.001],
|
||||||
[lng, lat],
|
// [lng, lat],
|
||||||
[lng + 0.002, lat + 0.001],
|
// [lng + 0.002, lat + 0.001],
|
||||||
[lng + 0.004, lat + 0.002],
|
// [lng + 0.004, lat + 0.002],
|
||||||
]
|
// ]
|
||||||
|
// const surveyRoute: [number, number][] = [
|
||||||
// 조사 경로 라인
|
// [lng - 0.003, lat - 0.0015],
|
||||||
const surveyRoute: [number, number][] = [
|
// [lng - 0.001, lat - 0.0005],
|
||||||
[lng - 0.003, lat - 0.0015],
|
// [lng + 0.001, lat + 0.0005],
|
||||||
[lng - 0.001, lat - 0.0005],
|
// [lng + 0.003, lat + 0.0015],
|
||||||
[lng + 0.001, lat + 0.0005],
|
// ]
|
||||||
[lng + 0.003, lat + 0.0015],
|
|
||||||
]
|
|
||||||
|
|
||||||
const deckLayers = [
|
const deckLayers = [
|
||||||
// 조사 경로 (파란 점선)
|
|
||||||
new PathLayer({
|
|
||||||
id: 'survey-route',
|
|
||||||
data: [{ path: surveyRoute }],
|
|
||||||
getPath: (d: { path: [number, number][] }) => d.path,
|
|
||||||
getColor: [59, 130, 246, 153],
|
|
||||||
getWidth: 2,
|
|
||||||
getDashArray: [6, 4],
|
|
||||||
dashJustified: true,
|
|
||||||
widthMinPixels: 1,
|
|
||||||
}),
|
|
||||||
// 해안 구간 라인 (ESI 색상)
|
|
||||||
new PathLayer({
|
|
||||||
id: 'seg-line',
|
|
||||||
data: [{ path: segLine }],
|
|
||||||
getPath: (d: { path: [number, number][] }) => d.path,
|
|
||||||
getColor: hexToRgba(esiCol, 204),
|
|
||||||
getWidth: 5,
|
|
||||||
capRounded: true,
|
|
||||||
widthMinPixels: 3,
|
|
||||||
}),
|
|
||||||
// 접근 포인트 (노란 점)
|
// 접근 포인트 (노란 점)
|
||||||
new ScatterplotLayer({
|
new ScatterplotLayer({
|
||||||
id: 'access-point',
|
id: 'access-point',
|
||||||
|
|||||||
@ -139,7 +139,7 @@ function DetailTab({ detail }: { detail: ScatDetail }) {
|
|||||||
/* ═══ 탭 1: 현장 사진 ═══ */
|
/* ═══ 탭 1: 현장 사진 ═══ */
|
||||||
function PhotoTab({ detail }: { detail: ScatDetail }) {
|
function PhotoTab({ detail }: { detail: ScatDetail }) {
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const imgSrc = `/scat-img/${detail.code}-1.png`;
|
const imgSrc = `/scat/img/${detail.code}-1.png`;
|
||||||
|
|
||||||
if (imgError) {
|
if (imgError) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -26,9 +26,9 @@ export const statusColor: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
export const esiLevel = (n: number) => (n >= 8 ? 'h' : n >= 5 ? 'm' : 'l');
|
export const esiLevel = (n: number) => (n >= 8 ? 'h' : n >= 5 ? 'm' : 'l');
|
||||||
|
|
||||||
// ═══ 제주도 해안선 좌표 (시계방향) ═══
|
// ═══ 제주도 해안선 좌표 (시계방향) — 하드코딩 비활성화, 추후 DB 기반으로 대체 ═══
|
||||||
|
|
||||||
export const jejuCoastCoords: [number, number][] = [
|
/* export const jejuCoastCoords: [number, number][] = [
|
||||||
// 서부 (대정읍~한경면)
|
// 서부 (대정읍~한경면)
|
||||||
[33.28, 126.16],
|
[33.28, 126.16],
|
||||||
[33.26, 126.18],
|
[33.26, 126.18],
|
||||||
@ -101,4 +101,4 @@ export const jejuCoastCoords: [number, number][] = [
|
|||||||
[33.31, 126.19],
|
[33.31, 126.19],
|
||||||
[33.3, 126.175],
|
[33.3, 126.175],
|
||||||
[33.293, 126.162],
|
[33.293, 126.162],
|
||||||
];
|
]; */
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'
|
|||||||
import { useWeatherData } from '../hooks/useWeatherData'
|
import { useWeatherData } from '../hooks/useWeatherData'
|
||||||
// import { useOceanForecast } from '../hooks/useOceanForecast'
|
// import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||||
import { WeatherMapControls } from './WeatherMapControls'
|
import { WeatherMapControls } from './WeatherMapControls'
|
||||||
|
import { degreesToCardinal } from '../services/weatherUtils'
|
||||||
|
|
||||||
type TimeOffset = '0' | '3' | '6' | '9'
|
type TimeOffset = '0' | '3' | '6' | '9'
|
||||||
|
|
||||||
@ -40,13 +41,6 @@ interface WeatherStation {
|
|||||||
salinity?: number
|
salinity?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const CARDINAL_LABELS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] as const
|
|
||||||
|
|
||||||
function degreesToCardinal(deg: number): string {
|
|
||||||
const idx = Math.round(((deg % 360) + 360) % 360 / 22.5) % 16
|
|
||||||
return CARDINAL_LABELS[idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WeatherForecast {
|
interface WeatherForecast {
|
||||||
time: string
|
time: string
|
||||||
hour: string
|
hour: string
|
||||||
|
|||||||
121
frontend/src/tabs/weather/services/weatherUtils.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { getRecentObservation, OBS_STATION_CODES } from './khoaApi';
|
||||||
|
import type { WeatherSnapshot } from '@common/store/weatherSnapshotStore';
|
||||||
|
|
||||||
|
const CARDINAL_LABELS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] as const;
|
||||||
|
|
||||||
|
export function degreesToCardinal(deg: number): string {
|
||||||
|
const idx = Math.round(((deg % 360) + 360) % 360 / 22.5) % 16;
|
||||||
|
return CARDINAL_LABELS[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_STATIONS = [
|
||||||
|
{ id: 'incheon', name: '인천', location: { lat: 37.45, lon: 126.43 } },
|
||||||
|
{ id: 'ulsan', name: '울산', location: { lat: 35.52, lon: 129.38 } },
|
||||||
|
{ id: 'yeosu', name: '여수', location: { lat: 34.74, lon: 127.75 } },
|
||||||
|
{ id: 'jeju', name: '제주', location: { lat: 33.51, lon: 126.53 } },
|
||||||
|
{ id: 'pohang', name: '포항', location: { lat: 36.03, lon: 129.38 } },
|
||||||
|
{ id: 'mokpo', name: '목포', location: { lat: 34.78, lon: 126.38 } },
|
||||||
|
{ id: 'gunsan', name: '군산', location: { lat: 35.97, lon: 126.7 } },
|
||||||
|
{ id: 'sokcho', name: '속초', location: { lat: 38.21, lon: 128.59 } },
|
||||||
|
{ id: 'tongyeong', name: '통영', location: { lat: 34.83, lon: 128.43 } },
|
||||||
|
{ id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function fetchWeatherSnapshotForCoord(
|
||||||
|
lat: number,
|
||||||
|
lon: number,
|
||||||
|
): Promise<WeatherSnapshot> {
|
||||||
|
const nearest = BASE_STATIONS.reduce((best, s) => {
|
||||||
|
const d = (s.location.lat - lat) ** 2 + (s.location.lon - lon) ** 2;
|
||||||
|
const bd = (best.location.lat - lat) ** 2 + (best.location.lon - lon) ** 2;
|
||||||
|
return d < bd ? s : best;
|
||||||
|
});
|
||||||
|
|
||||||
|
const r = (n: number) => Math.round(n * 10) / 10;
|
||||||
|
|
||||||
|
const obsCode = OBS_STATION_CODES[nearest.id];
|
||||||
|
const obs = obsCode ? await getRecentObservation(obsCode) : null;
|
||||||
|
|
||||||
|
const windIcon = (spd: number) => spd > 12 ? '🌧️' : spd > 8 ? '🌦️' : spd > 5 ? '⛅' : '☀️';
|
||||||
|
const mockAstronomy = {
|
||||||
|
sunrise: '07:12', sunset: '17:58',
|
||||||
|
moonrise: '19:35', moonset: '01:50',
|
||||||
|
moonPhase: '상현달 14일', tidalRange: 6.7,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (obs) {
|
||||||
|
const windSpeed = r(obs.wind_speed ?? 8.0);
|
||||||
|
const windDir = obs.wind_dir ?? 315;
|
||||||
|
const waterTemp = r(obs.water_temp ?? 8.0);
|
||||||
|
const airTemp = r(obs.air_temp ?? waterTemp);
|
||||||
|
const pressure = Math.round(obs.air_pres ?? 1013);
|
||||||
|
const waveHeight = r(1.0 + windSpeed * 0.1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stationName: nearest.name,
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
wind: {
|
||||||
|
speed: windSpeed,
|
||||||
|
direction: windDir,
|
||||||
|
directionLabel: degreesToCardinal(windDir),
|
||||||
|
speed_1k: r(windSpeed * 0.8),
|
||||||
|
speed_3k: r(windSpeed * 1.2),
|
||||||
|
},
|
||||||
|
wave: {
|
||||||
|
height: waveHeight,
|
||||||
|
maxHeight: r(waveHeight * 1.6),
|
||||||
|
period: Math.floor(4 + windSpeed * 0.3),
|
||||||
|
direction: degreesToCardinal(windDir + 45),
|
||||||
|
},
|
||||||
|
temperature: { current: waterTemp, feelsLike: r(airTemp - windSpeed * 0.3) },
|
||||||
|
pressure,
|
||||||
|
visibility: pressure > 1010 ? 15 : 10,
|
||||||
|
salinity: 31.2,
|
||||||
|
forecast: [0, 3, 6, 9, 12].map((h, i) => ({
|
||||||
|
time: `${h}시`,
|
||||||
|
icon: windIcon(windSpeed + (i * 0.3 - 0.3)),
|
||||||
|
temperature: r(airTemp - i * 0.3),
|
||||||
|
windSpeed: r(windSpeed + (i * 0.2 - 0.2)),
|
||||||
|
})),
|
||||||
|
astronomy: mockAstronomy,
|
||||||
|
alert: windSpeed > 14 ? '풍랑주의보 예상' : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: 시드 기반 더미
|
||||||
|
const seed = nearest.location.lat * 100 + nearest.location.lon;
|
||||||
|
const windSpeed = r(6 + (seed % 7));
|
||||||
|
const windDir = [0, 45, 90, 135, 180, 225, 270, 315][Math.floor(seed) % 8];
|
||||||
|
const waveHeight = r(0.8 + (seed % 20) / 10);
|
||||||
|
const temp = r(5 + (seed % 8));
|
||||||
|
|
||||||
|
return {
|
||||||
|
stationName: nearest.name,
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
wind: {
|
||||||
|
speed: windSpeed,
|
||||||
|
direction: windDir,
|
||||||
|
directionLabel: degreesToCardinal(windDir),
|
||||||
|
speed_1k: r(windSpeed * 0.8),
|
||||||
|
speed_3k: r(windSpeed * 1.2),
|
||||||
|
},
|
||||||
|
wave: {
|
||||||
|
height: waveHeight,
|
||||||
|
maxHeight: r(waveHeight * 1.6),
|
||||||
|
period: 4 + (Math.floor(seed) % 3),
|
||||||
|
direction: degreesToCardinal(windDir + 45),
|
||||||
|
},
|
||||||
|
temperature: { current: temp, feelsLike: r(temp - windSpeed * 0.3) },
|
||||||
|
pressure: 1010 + (Math.floor(seed) % 12),
|
||||||
|
visibility: 12 + (Math.floor(seed) % 10),
|
||||||
|
salinity: 31.2,
|
||||||
|
forecast: [0, 3, 6, 9, 12].map((h, i) => ({
|
||||||
|
time: `${h}시`,
|
||||||
|
icon: windIcon(windSpeed + (i * 0.3 - 0.3)),
|
||||||
|
temperature: r(temp - i * 0.3),
|
||||||
|
windSpeed: r(windSpeed + (i * 0.2 - 0.2)),
|
||||||
|
})),
|
||||||
|
astronomy: mockAstronomy,
|
||||||
|
alert: windSpeed > 14 ? '풍랑주의보 예상' : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -28,7 +28,9 @@
|
|||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@common/*": ["src/common/*"],
|
"@common/*": ["src/common/*"],
|
||||||
"@tabs/*": ["src/tabs/*"]
|
"@tabs/*": ["src/tabs/*"],
|
||||||
|
"@pages/*": ["src/pages/*"],
|
||||||
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
'@common': path.resolve(__dirname, 'src/common'),
|
'@common': path.resolve(__dirname, 'src/common'),
|
||||||
'@tabs': path.resolve(__dirname, 'src/tabs'),
|
'@tabs': path.resolve(__dirname, 'src/tabs'),
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||