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

This commit is contained in:
jhkang 2026-03-24 18:57:51 +09:00
커밋 a55d3c18c2
96개의 변경된 파일873163개의 추가작업 그리고 628개의 파일을 삭제

파일 보기

@ -5,29 +5,29 @@
},
"permissions": {
"allow": [
"Bash(curl -s *)",
"Bash(fnm *)",
"Bash(git add *)",
"Bash(npm run *)",
"Bash(npm install *)",
"Bash(npm test *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git branch *)",
"Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git config *)",
"Bash(git diff *)",
"Bash(git fetch *)",
"Bash(git log *)",
"Bash(git merge *)",
"Bash(git pull *)",
"Bash(git fetch *)",
"Bash(git merge *)",
"Bash(git stash *)",
"Bash(git remote *)",
"Bash(git config *)",
"Bash(git rev-parse *)",
"Bash(git show *)",
"Bash(git stash *)",
"Bash(git status)",
"Bash(git tag *)",
"Bash(node *)",
"Bash(npm install *)",
"Bash(npm run *)",
"Bash(npm test *)",
"Bash(npx *)"
"Bash(curl -s *)",
"Bash(fnm *)"
],
"deny": [
"Bash(git push --force*)",
@ -83,7 +83,5 @@
]
}
]
},
"deny": [],
"allow": []
}
}
}

파일 보기

@ -1,7 +1,7 @@
{
"applied_global_version": "1.6.1",
"applied_date": "2026-03-19",
"applied_date": "2026-03-24",
"project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true
}
}

파일 보기

@ -5,6 +5,7 @@ import {
getIncident,
listIncidentPredictions,
getIncidentWeather,
saveIncidentWeather,
getIncidentMedia,
} 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 — 미디어 정보
// ============================================================

파일 보기

@ -254,24 +254,143 @@ export async function getIncidentWeather(acdntSn: number): Promise<WeatherInfo |
const r = rows[0] as Record<string, unknown>;
return {
locNm: r.loc_nm as string,
obsDtm: (r.obs_dtm as Date).toISOString(),
icon: r.icon as string,
temp: r.temp as string,
weatherDc: r.weather_dc as string,
wind: r.wind as string,
wave: r.wave as string,
humid: r.humid as string,
vis: r.vis as string,
sst: r.sst as string,
tide: r.tide as string,
highTide: r.high_tide as string,
lowTide: r.low_tide as string,
locNm: (r.loc_nm as string | null) ?? '-',
obsDtm: r.obs_dtm ? (r.obs_dtm as Date).toISOString() : '-',
icon: (r.icon as string | null) ?? '',
temp: (r.temp as string | null) ?? '-',
weatherDc: (r.weather_dc as string | null) ?? '-',
wind: (r.wind as string | null) ?? '-',
wave: (r.wave as string | null) ?? '-',
humid: (r.humid as string | null) ?? '-',
vis: (r.vis as string | null) ?? '-',
sst: (r.sst as string | null) ?? '-',
tide: (r.tide as string | null) ?? '-',
highTide: (r.high_tide as string | null) ?? '-',
lowTide: (r.low_tide as string | null) ?? '-',
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 {
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn,
} from './predictionService.js';
import { analyzeImageFile } from './imageAnalyzeService.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 — 사고별 역추적 목록
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
try {

파일 보기

@ -432,6 +432,8 @@ interface TrajectoryTimeStep {
particles: TrajectoryParticle[];
remaining_volume_m3: number;
weathered_volume_m3: number;
evaporation_volume_m3?: number;
dispersion_volume_m3?: number;
pollution_area_km2: number;
beached_volume_m3: number;
pollution_coast_length_m: number;
@ -453,6 +455,8 @@ interface SingleModelTrajectoryResult {
summary: {
remainingVolume: number;
weatheredVolume: number;
evaporationVolume: number;
dispersionVolume: number;
pollutionArea: number;
beachedVolume: number;
pollutionCoastLength: number;
@ -460,6 +464,8 @@ interface SingleModelTrajectoryResult {
stepSummaries: Array<{
remainingVolume: number;
weatheredVolume: number;
evaporationVolume: number;
dispersionVolume: number;
pollutionArea: number;
beachedVolume: number;
pollutionCoastLength: number;
@ -474,6 +480,8 @@ interface TrajectoryResult {
summary: {
remainingVolume: number;
weatheredVolume: number;
evaporationVolume: number;
dispersionVolume: number;
pollutionArea: number;
beachedVolume: number;
pollutionCoastLength: number;
@ -500,6 +508,8 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin
const summary = {
remainingVolume: lastStep.remaining_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,
beachedVolume: lastStep.beached_volume_m3,
pollutionCoastLength: lastStep.pollution_coast_length_m,
@ -514,6 +524,8 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin
const stepSummaries = rawResult.map((step) => ({
remainingVolume: step.remaining_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,
beachedVolume: step.beached_volume_m3,
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[]> {
const sql = `
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) => {
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({
tmplSn,
ctgrSn,
@ -101,7 +101,8 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req
jrsdCd,
sttsCd,
authorId: req.user!.sub,
mapCaptureImg,
step3MapImg,
step6MapImg,
sections,
});
res.status(201).json(result);
@ -125,8 +126,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'),
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
return;
}
const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg } = req.body;
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg }, req.user!.sub);
const { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg } = req.body;
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, step3MapImg, step6MapImg }, req.user!.sub);
res.json({ success: true });
} catch (err) {
if (err instanceof AuthError) {

파일 보기

@ -60,6 +60,7 @@ interface ReportListItem {
sttsCd: string;
authorId: string;
authorName: string;
acdntSn: number | null;
regDtm: string;
mdfcnDtm: string | null;
hasMapCapture: boolean;
@ -75,7 +76,8 @@ interface SectionData {
interface ReportDetail extends ReportListItem {
acdntSn: number | null;
sections: SectionData[];
mapCaptureImg: string | null;
step3MapImg: string | null;
step6MapImg: string | null;
}
interface ListReportsInput {
@ -102,7 +104,8 @@ interface CreateReportInput {
jrsdCd?: string;
sttsCd?: string;
authorId: string;
mapCaptureImg?: string;
step3MapImg?: string;
step6MapImg?: string;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}
@ -111,7 +114,8 @@ interface UpdateReportInput {
jrsdCd?: string;
sttsCd?: string;
acdntSn?: number | null;
mapCaptureImg?: string | null;
step3MapImg?: string | null;
step6MapImg?: string | null;
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,
r.TITLE, r.JRSD_CD, r.STTS_CD,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
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
r.ACDNT_SN, r.REG_DTM, r.MDFCN_DTM,
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
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
@ -284,6 +290,7 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
sttsCd: r.stts_cd,
authorId: r.author_id,
authorName: r.author_name || '',
acdntSn: r.acdnt_sn,
regDtm: r.reg_dtm,
mdfcnDtm: r.mdfcn_dtm,
hasMapCapture: r.has_map_capture,
@ -300,8 +307,10 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
c.CTGR_CD, c.CTGR_NM,
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
r.REG_DTM, r.MDFCN_DTM, r.MAP_CAPTURE_IMG,
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE
r.REG_DTM, r.MDFCN_DTM, r.STEP3_MAP_IMG, r.STEP6_MAP_IMG,
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
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
@ -338,7 +347,8 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
authorName: r.author_name || '',
regDtm: r.reg_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,
sections: sectRes.rows.map((s) => ({
sectCd: s.sect_cd,
@ -359,8 +369,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
await client.query('BEGIN');
const res = await client.query(
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, MAP_CAPTURE_IMG)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`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, $9)
RETURNING REPORT_SN`,
[
input.tmplSn || null,
@ -370,7 +380,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
input.jrsdCd || null,
input.sttsCd || 'DRAFT',
input.authorId,
input.mapCaptureImg || null,
input.step3MapImg || null,
input.step6MapImg || null,
]
);
const reportSn = res.rows[0].report_sn;
@ -442,9 +453,13 @@ export async function updateReport(
sets.push(`ACDNT_SN = $${idx++}`);
params.push(input.acdntSn);
}
if (input.mapCaptureImg !== undefined) {
sets.push(`MAP_CAPTURE_IMG = $${idx++}`);
params.push(input.mapCaptureImg);
if (input.step3MapImg !== undefined) {
sets.push(`STEP3_MAP_IMG = $${idx++}`);
params.push(input.step3MapImg);
}
if (input.step6MapImg !== undefined) {
sets.push(`STEP6_MAP_IMG = $${idx++}`);
params.push(input.step6MapImg);
}
params.push(reportSn);

파일 보기

@ -18,6 +18,7 @@ interface Layer {
cmn_cd_nm: string
cmn_cd_level: number
clnm: string | null
data_tbl_nm: string | null
}
// DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지)
@ -27,7 +28,21 @@ const LAYER_COLUMNS = `
LAYER_FULL_NM AS cmn_cd_full_nm,
LAYER_NM AS cmn_cd_nm,
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()
// 모든 라우트에 파라미터 살균 적용
@ -37,7 +52,10 @@ router.use(sanitizeParams)
router.get('/', async (_req, res) => {
try {
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)
res.json(enrichedLayers)
@ -50,7 +68,10 @@ router.get('/', async (_req, res) => {
router.get('/tree/all', async (_req, res) => {
try {
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)
@ -82,7 +103,10 @@ router.get('/tree/all', async (_req, res) => {
router.get('/wms/all', async (_req, res) => {
try {
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)
res.json(enrichedLayers)
@ -104,7 +128,10 @@ router.get('/level/:level', async (req, res) => {
}
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]
)
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}`)
}
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 ')}` : ''
// 데이터 쿼리 파라미터: WHERE 조건 + LIMIT + OFFSET
@ -201,19 +237,27 @@ router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) =>
const [dataResult, countResult] = await Promise.all([
wingPool.query(
`SELECT
LAYER_CD AS "layerCd",
UP_LAYER_CD AS "upLayerCd",
LAYER_FULL_NM AS "layerFullNm",
LAYER_NM AS "layerNm",
LAYER_LEVEL AS "layerLevel",
WMS_LAYER_NM AS "wmsLayerNm",
USE_YN AS "useYn",
SORT_ORD AS "sortOrd",
TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm"
FROM LAYER
${whereClause}
ORDER BY LAYER_CD
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
t.*,
p.USE_YN AS "parentUseYn"
FROM (
SELECT
LAYER_CD AS "layerCd",
UP_LAYER_CD AS "upLayerCd",
LAYER_FULL_NM AS "layerFullNm",
LAYER_NM AS "layerNm",
LAYER_LEVEL AS "layerLevel",
WMS_LAYER_NM AS "wmsLayerNm",
DATA_TBL_NM AS "dataTblNm",
USE_YN AS "useYn",
SORT_ORD AS "sortOrd",
TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm"
FROM LAYER
${whereClause}
ORDER BY LAYER_CD
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
),
wingPool.query(
@ -288,11 +332,12 @@ router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res)
layerNm?: string
layerLevel?: number
wmsLayerNm?: string
dataTblNm?: string
useYn?: string
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)) {
@ -319,20 +364,26 @@ router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res)
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 sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
const sanitizedLayerNm = sanitizeString(layerNm)
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'N')
`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, $9, 'N')
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])
@ -355,11 +406,12 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res)
layerNm?: string
layerLevel?: number
wmsLayerNm?: string
dataTblNm?: string
useYn?: string
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)) {
@ -386,22 +438,28 @@ router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res)
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 sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
const sanitizedLayerNm = sanitizeString(layerNm)
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
const sanitizedDataTblNm = dataTblNm ? sanitizeString(dataTblNm) : null
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
const { rows } = await wingPool.query(
`UPDATE LAYER
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
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) {
@ -428,6 +486,18 @@ router.post('/admin/delete', requireAuth, requireRole('ADMIN'), async (req, res)
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(
`UPDATE LAYER SET DEL_YN = 'Y' WHERE LAYER_CD = $1 AND DEL_YN = 'N'
RETURNING LAYER_CD AS "layerCd"`,

파일 보기

@ -585,6 +585,7 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
status: 'DONE' | 'ERROR'
trajectory?: ReturnType<typeof transformResult>['trajectory']
summary?: ReturnType<typeof transformResult>['summary']
stepSummaries?: ReturnType<typeof transformResult>['stepSummaries']
centerPoints?: ReturnType<typeof transformResult>['centerPoints']
windData?: ReturnType<typeof transformResult>['windData']
hydrData?: ReturnType<typeof transformResult>['hydrData']
@ -656,9 +657,9 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
WHERE PRED_EXEC_SN=$2`,
[JSON.stringify(pythonData.result), predExecSn]
)
const { trajectory, summary, centerPoints, windData, hydrData } =
const { trajectory, summary, stepSummaries, centerPoints, windData, hydrData } =
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 {
const rawResult = await runModelSync(jobId!, predExecSn, apiUrl)
const { trajectory, summary, centerPoints, windData, hydrData } = transformResult(rawResult, model)
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, centerPoints, windData, hydrData }
const { trajectory, summary, stepSummaries, centerPoints, windData, hydrData } = transformResult(rawResult, model)
return { model, execSn: predExecSn, status: 'DONE', trajectory, summary, stepSummaries, centerPoints, windData, hydrData }
} catch (syncErr) {
return { model, execSn: predExecSn, status: 'ERROR', error: (syncErr as Error).message }
}
@ -848,6 +849,8 @@ interface PythonTimeStep {
particles: PythonParticle[]
remaining_volume_m3: number
weathered_volume_m3: number
evaporation_m3?: number
dispersion_m3?: number
pollution_area_km2: number
beached_volume_m3: number
pollution_coast_length_m: number
@ -884,6 +887,8 @@ function transformResult(rawResult: PythonTimeStep[], model: string) {
const summary = {
remainingVolume: lastStep.remaining_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,
beachedVolume: lastStep.beached_volume_m3,
pollutionCoastLength: lastStep.pollution_coast_length_m,
@ -901,7 +906,16 @@ function transformResult(rawResult: PythonTimeStep[], model: string) {
? { value: step.hydr_data, grid: step.hydr_grid }
: 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

파일 보기

@ -77,7 +77,8 @@ CREATE TABLE IF NOT EXISTS REPORT (
USE_YN CHAR(1) DEFAULT 'Y',
REG_DTM TIMESTAMPTZ DEFAULT NOW(),
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'))
);

파일 보기

@ -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;

파일 보기

@ -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 '날씨 특보 문자열';

파일 보기

@ -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 '수정일시';

파일 보기

@ -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]
## [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]
### 추가
- 관리자: 지도 베이스 관리 패널, 레이어 패널 추가 및 보고서 기능 개선
- 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가)
- 관리자: 유처리제 제한구역 패널, 민감자원 레이어 패널 추가
- 항공 방제: WingAI (AI 탐지/분석) 서브탭 추가
- 항공 방제: UP42 위성 패스 조회 + 궤도 지도 표시
- 항공 방제: 위성 요청 취소 기능 추가
@ -20,6 +43,8 @@
- 항공 방제: 위성 히스토리 지도에 캘린더 + 날짜별 촬영 리스트 + 영상 오버레이
- 항공 방제: 완료 촬영 클릭 시 VWorld 위성 영상 오버레이 표시
- 항공 방제: 위성 요청 목록 더보기 → 페이징 처리로 변경
- 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선)
- 기상: 날씨 스냅샷 스토어, 유틸리티 모듈 추가
- 사고관리: UI 개선 + 오염물 배출규정 기능 추가
- Pre-SCAT 해안조사 UI 개선
- 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화)
@ -30,10 +55,14 @@
- 항공 방제: 촬영 히스토리 지도 리스트 위치 좌하단으로 이동
### 변경
- prediction/scat 파이프라인 제거 + SCAT/사고관리 UI 수정
- 기상: 지역별 기상정보 패널 글자 사이즈 조정 + 시각화 개선
- SCAT 사진을 로컬에서 서버 프록시로 전환 (scat-photos 1,127개 삭제)
- WeatherRightPanel 중복 코드 정리
### 문서
- PREDICTION-GUIDE.md 삭제
## [2026-03-18]
### 추가
@ -67,8 +96,6 @@
- KOSPS/앙상블 준비중 팝업 + 기본 모델 POSEIDON 변경
- 오염분석 원 분석 기능 — 중심점/반경 입력으로 원형 오염 면적 계산
- 오일펜스 배치 가이드 UI 개선
- 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화)
- Pre-SCAT 관할서 필터링 + 해안조사 데이터 파이프라인 구축
- 다각형/원 오염분석 + 범례 최소화 + Convex Hull 면적 계산
### 수정
@ -79,7 +106,6 @@
- 오염분석 UI 개선 — HTML 디자인 참고 반영
- 범례 UI 개선 — HTML 참고 디자인 반영
- 드론 아이콘 쿼드콥터 + 함정 MarineTraffic 삼각형 스타일
- SCAT 사진을 로컬에서 서버 프록시로 전환 (scat-photos 1,127개 삭제)
### 기타
- 프론트엔드 포트 변경(5174) + CORS 허용

파일 보기

@ -32,6 +32,7 @@
"maplibre-gl": "^5.19.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"react-window": "^2.2.7",
"socket.io-client": "^4.8.3",
"xlsx": "^0.18.5",
@ -3366,6 +3367,19 @@
"dev": true,
"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": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz",
@ -5451,6 +5465,44 @@
"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": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.7.tgz",
@ -5660,6 +5712,12 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",

파일 보기

@ -34,6 +34,7 @@
"maplibre-gl": "^5.19.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"react-window": "^2.2.7",
"socket.io-client": "^4.8.3",
"xlsx": "^0.18.5",

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

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

파일 보기

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { Routes, Route } from 'react-router-dom'
import { GoogleOAuthProvider } from '@react-oauth/google'
import type { MainTab } from '@common/types/navigation'
import { MainLayout } from '@common/components/layout/MainLayout'
@ -19,6 +20,7 @@ import { IncidentsView } from '@tabs/incidents'
import { AdminView } from '@tabs/admin'
import { ScatView } from '@tabs/scat'
import { RescueView } from '@tabs/rescue'
import { DesignPage } from '@/pages/design/DesignPage'
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''
@ -108,9 +110,14 @@ function App() {
}
return (
<MainLayout activeMainTab={activeMainTab} onMainTabChange={setActiveMainTab}>
{renderView()}
</MainLayout>
<Routes>
<Route path="/design" element={<DesignPage />} />
<Route path="*" element={
<MainLayout activeMainTab={activeMainTab} onMainTabChange={setActiveMainTab}>
{renderView()}
</MainLayout>
} />
</Routes>
)
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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 { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
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 { StyleSpecification } from 'maplibre-gl'
import type { MapLayerMouseEvent } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { layerDatabase } from '@common/services/layerService'
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 type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
@ -289,6 +289,24 @@ const PRIORITY_LABELS: Record<string, string> = {
'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> = {
'aquaculture': '#22c55e',
'beach': '#0ea5e9',
@ -342,6 +360,7 @@ interface MapViewProps {
incidentCoord: { lat: number; lon: number }
}
sensitiveResources?: SensitiveResource[]
sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null
flyToTarget?: { lng: number; lat: number; zoom?: number } | null
fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null
centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }>
@ -528,6 +547,7 @@ export function MapView({
layerBrightness = 50,
backtrackReplay,
sensitiveResources = [],
sensitiveResourceGeojson,
flyToTarget,
fitBoundsTarget,
centerPoints = [],
@ -559,6 +579,12 @@ export function MapView({
const [isPlaying, setIsPlaying] = useState(false)
const [playbackSpeed, setPlaybackSpeed] = useState(1)
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 handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
@ -569,6 +595,44 @@ export function MapView({
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
const { lng, lat } = e.lngLat
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) {
handleMeasureClick(lng, lat)
return
@ -716,7 +780,7 @@ export function MapView({
getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]),
getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230),
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,
widthMinPixels: 2,
widthMaxPixels: 6,
@ -1018,7 +1082,10 @@ export function MapView({
),
});
} else if (!info.object) {
setPopupInfo(null);
// 클릭으로 열린 팝업(어장 등)이 있으면 호버로 닫지 않음
if (!persistentPopupRef.current) {
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) ---
const visibleCenters = centerPoints.filter(p => p.time <= currentTime)
if (visibleCenters.length > 0) {
@ -1225,12 +1327,12 @@ export function MapView({
// 거리/면적 측정 레이어
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements))
return result
return result.filter(Boolean)
}, [
oilTrajectory, currentTime, selectedModels,
boomLines, isDrawingBoom, drawingPoints,
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
sensitiveResources, centerPoints, windData,
sensitiveResources, sensitiveResourceGeojson, centerPoints, windData,
showWind, showBeached, showTimeLabel, simulationStartTime,
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
])
@ -1318,7 +1420,10 @@ export function MapView({
longitude={popupInfo.longitude}
latitude={popupInfo.latitude}
anchor="bottom"
onClose={() => setPopupInfo(null)}
onClose={() => {
persistentPopupRef.current = false
setPopupInfo(null)
}}
>
<div className="text-[#333]">{popupInfo.content}</div>
</Popup>

파일 보기

@ -7,6 +7,7 @@ export interface LayerNode {
name: string
level: number
layerName: string | null
dataTblNm?: string | null
icon?: string
count?: number
defaultOn?: boolean

파일 보기

@ -13,11 +13,11 @@ export function useLayers() {
}
// 계층 구조 레이어 트리 조회 훅
// staleTime 없음 → 마운트 시 항상 최신 데이터 요청 (관리자 설정 즉시 반영)
export function useLayerTree() {
return useQuery<Layer[], Error>({
queryKey: ['layers', 'tree'],
queryFn: fetchLayerTree,
staleTime: 1000 * 60 * 5,
retry: 3,
})
}

파일 보기

@ -203,6 +203,13 @@ export interface OilReportPayload {
windSpeed: string;
waveHeight: string;
temp: string;
pressure?: string;
visibility?: string;
salinity?: string;
waveMaxHeight?: string;
wavePeriod?: string;
currentDir?: string;
currentSpeed?: string;
} | null;
spread: {
kosps: string;
@ -228,6 +235,12 @@ export interface OilReportPayload {
centerPoints: { lat: number; lon: number; time: number }[];
simulationStartTime: string;
} | null;
sensitiveResources?: Array<{
category: string;
count: number;
totalArea: number | null;
}>;
acdntSn?: number;
}
let _oilReportPayload: OilReportPayload | null = null;

파일 보기

@ -46,6 +46,7 @@ export interface LayerDTO {
cmn_cd_nm: string
cmn_cd_level: number
clnm: string | null
data_tbl_nm?: string | null
icon?: string
count?: number
children?: LayerDTO[]
@ -58,6 +59,7 @@ export interface Layer {
fullName: string
level: number
wmsLayer: string | null
dataTblNm?: string | null
icon?: string
count?: number
children?: Layer[]
@ -72,6 +74,7 @@ function convertToLayer(dto: LayerDTO): Layer {
fullName: dto.cmn_cd_full_nm,
level: dto.cmn_cd_level,
wmsLayer: dto.clnm,
dataTblNm: dto.data_tbl_nm,
icon: dto.icon,
count: dto.count,
children: dto.children ? dto.children.map(convertToLayer) : undefined,

파일 보기

@ -8,6 +8,7 @@ export interface Layer {
fullName: string
level: number
wmsLayer: string | null
dataTblNm?: string | null
icon?: string
count?: number
children?: Layer[]

파일 보기

@ -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 {
/* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */
.cctv-dark-popup .maplibregl-popup-content {

파일 보기

@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import App from './App.tsx'
@ -17,8 +18,10 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</BrowserRouter>
</StrictMode>,
)

파일 보기

@ -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 }}
>
(03, 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;

파일 보기

@ -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;

파일 보기

@ -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&quot; 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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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) &gt; 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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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>
);
};

파일 보기

@ -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>
);
};

파일 보기

@ -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>
);
};

파일 보기

@ -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 MapBasePanel from './MapBasePanel';
import LayerPanel from './LayerPanel';
import SensitiveLayerPanel from './SensitiveLayerPanel';
import DispersingZonePanel from './DispersingZonePanel';
import MonitorRealtimePanel from './MonitorRealtimePanel';
import VesselMaterialsPanel from './VesselMaterialsPanel';
/** 기존 패널이 있는 메뉴 ID 매핑 */
const PANEL_MAP: Record<string, () => JSX.Element> = {
@ -27,6 +31,11 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
'asset-upload': () => <AssetUploadPanel />,
'map-base': () => <MapBasePanel />,
'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() {

파일 보기

@ -16,6 +16,7 @@ function CleanupEquipPanel() {
const [searchTerm, setSearchTerm] = useState('');
const [regionFilter, setRegionFilter] = useState('전체');
const [typeFilter, setTypeFilter] = useState('전체');
const [equipFilter, setEquipFilter] = useState('전체');
const [currentPage, setCurrentPage] = useState(1);
const load = () => {
@ -40,12 +41,21 @@ function CleanupEquipPanel() {
return Array.from(set).sort();
}, [organizations]);
const EQUIP_FIELDS: Record<string, keyof AssetOrgCompat> = {
'방제선': 'vessel',
'유회수기': 'skimmer',
'이송펌프': 'pump',
'방제차량': 'vehicle',
'살포장치': 'sprayer',
};
const filtered = useMemo(() =>
organizations
.filter(o => regionFilter === '전체' || o.jurisdiction.includes(regionFilter))
.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)),
[organizations, regionFilter, typeFilter, searchTerm]
[organizations, regionFilter, typeFilter, equipFilter, searchTerm]
);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
@ -96,6 +106,18 @@ function CleanupEquipPanel() {
<option key={t} value={t}>{t}</option>
))}
</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
type="text"
placeholder="기관명, 주소 검색..."
@ -122,16 +144,16 @@ function CleanupEquipPanel() {
<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"></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-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>
<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>
<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 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 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 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 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>
</tr>
</thead>
@ -161,20 +183,20 @@ function CleanupEquipPanel() {
<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-text-2">
{org.vessel > 0 ? <span className="text-text-1">{org.vessel}</span> : <span className="text-text-3"></span>}
<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 ? 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 ? <span className="text-text-1">{org.skimmer}</span> : <span className="text-text-3"></span>}
<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 ? 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 ? <span className="text-text-1">{org.pump}</span> : <span className="text-text-3"></span>}
<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 ? 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 ? <span className="text-text-1">{org.vehicle}</span> : <span className="text-text-3"></span>}
<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 ? 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 ? <span className="text-text-1">{org.sprayer}</span> : <span className="text-text-3"></span>}
<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 ? 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()}
@ -186,6 +208,33 @@ function CleanupEquipPanel() {
)}
</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 && (
<div className="flex items-center justify-between px-6 py-3 border-t border-border">

파일 보기

@ -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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <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 { useQueryClient } from '@tanstack/react-query';
import { api } from '@common/services/api';
interface LayerAdminItem {
@ -11,6 +12,7 @@ interface LayerAdminItem {
useYn: string;
sortOrd: number;
regDtm: string | null;
parentUseYn: string | null;
}
interface LayerListResponse {
@ -313,6 +315,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
// ---------- LayerPanel ----------
const LayerPanel = () => {
const queryClient = useQueryClient();
const [items, setItems] = useState<LayerAdminItem[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
@ -359,10 +362,15 @@ const LayerPanel = () => {
try {
const result = await toggleLayerUse(layerCd);
setItems(prev =>
prev.map(item =>
item.layerCd === result.layerCd ? { ...item, useYn: result.useYn } : item
)
prev.map(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 {
setError('사용여부 변경에 실패했습니다.');
} finally {
@ -522,12 +530,20 @@ const LayerPanel = () => {
<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'
disabled={toggling === item.layerCd || item.parentUseYn === 'N'}
title={
item.parentUseYn === 'N'
? '상위 레이어가 비활성화되어 있어 적용되지 않습니다'
: 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-[rgba(255,255,255,0.08)] border border-border'
: item.useYn === 'Y' && item.parentUseYn === 'N'
? 'bg-primary-cyan/40'
: 'bg-[rgba(255,255,255,0.08)] border border-border'
}`}
>
<span

파일 보기

@ -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>
);
}

파일 보기

@ -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;

파일 보기

@ -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"
>
&lt;
</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"
>
&gt;
</button>
</div>
</div>
)}
</div>
);
}
export default VesselMaterialsPanel;

파일 보기

@ -54,7 +54,6 @@ export const ADMIN_MENU: AdminMenuItem[] = [
{ id: 'asset-upload', label: '자산현행화' },
{ id: 'dispersant-zone', label: '유처리제 제한구역' },
{ id: 'vessel-materials', label: '방제선 보유자재' },
{ id: 'cleanup-resource', label: '방제자원' },
],
},
],

파일 보기

@ -70,7 +70,8 @@ export function IncidentsLeftPanel({
// Weather popup
const [weatherPopupId, setWeatherPopupId] = useState<string | null>(null)
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)
useEffect(() => {
@ -79,7 +80,7 @@ export function IncidentsLeftPanel({
fetchIncidentWeather(parseInt(weatherPopupId)).then((data) => {
if (!cancelled) setWeatherInfo(data)
})
return () => { cancelled = true; setWeatherInfo(null) }
return () => { cancelled = true; setWeatherInfo(undefined) }
}, [weatherPopupId]);
useEffect(() => {
@ -361,7 +362,7 @@ export function IncidentsLeftPanel({
)}
{/* Weather Popup (fixed position) */}
{weatherPopupId && weatherInfo && (
{weatherPopupId && weatherInfo !== undefined && (
<WeatherPopup
ref={weatherRef}
data={weatherInfo}
@ -412,10 +413,11 @@ function PgBtn({ label, active, disabled, onClick }: { label: string; active?: b
WeatherPopup
*/
const WeatherPopup = forwardRef<HTMLDivElement, {
data: WeatherInfo
data: WeatherInfo | null
position: { top: number; left: number }
onClose: () => void
}>(({ data, position, onClose }, ref) => {
const forecast = data?.forecast ?? []
return (
<div ref={ref} className="fixed overflow-hidden rounded-xl border border-border bg-bg-1" style={{
zIndex: 9990, width: 280,
@ -429,8 +431,8 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
<div className="flex items-center gap-1.5">
<span className="text-sm">🌤</span>
<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-[11px] font-bold">{data?.locNm || '기상정보 없음'}</div>
<div className="text-text-3 font-mono text-[8px]">{data?.obsDtm || '-'}</div>
</div>
</div>
<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">
{/* Main weather */}
<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 className="font-bold font-mono text-[20px]">{data.temp}</div>
<div className="text-text-3 text-[9px]">{data.weatherDc}</div>
<div className="font-bold font-mono text-[20px]">{data?.temp || '-'}</div>
<div className="text-text-3 text-[9px]">{data?.weatherDc || '-'}</div>
</div>
</div>
{/* Detail grid */}
<div className="grid grid-cols-2 gap-1.5 text-[9px]">
<WxCell icon="💨" label="풍향/풍속" value={data.wind} />
<WxCell icon="🌊" label="파고" value={data.wave} />
<WxCell icon="💧" label="습도" value={data.humid} />
<WxCell icon="👁" label="시정" value={data.vis} />
<WxCell icon="🌡" label="수온" value={data.sst} />
<WxCell icon="🔄" label="조류" value={data.tide} />
<WxCell icon="💨" label="풍향/풍속" value={data?.wind} />
<WxCell icon="🌊" label="파고" value={data?.wave} />
<WxCell icon="💧" label="습도" value={data?.humid} />
<WxCell icon="👁" label="시정" value={data?.vis} />
<WxCell icon="🌡" label="수온" value={data?.sst} />
<WxCell icon="🔄" label="조류" value={data?.tide} />
</div>
{/* Tide info */}
@ -464,7 +466,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
<span className="text-xs"></span>
<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 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>
<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>
@ -480,15 +482,19 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
{/* 24h Forecast */}
<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="flex justify-between font-mono text-text-2 text-[8px]">
{data.forecast.map((f, i) => (
<div key={i} className="text-center">
<div>{f.hour}</div>
<div className="text-xs my-0.5">{f.icon}</div>
<div className="font-semibold">{f.temp}</div>
</div>
))}
</div>
{forecast.length > 0 ? (
<div className="flex justify-between font-mono text-text-2 text-[8px]">
{forecast.map((f, i) => (
<div key={i} className="text-center">
<div>{f.hour}</div>
<div className="text-xs my-0.5">{f.icon}</div>
<div className="font-semibold">{f.temp}</div>
</div>
))}
</div>
) : (
<div className="text-text-3 text-center text-[8px] py-1"> </div>
)}
</div>
{/* 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)',
}}>
<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>
@ -505,13 +511,13 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
})
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 (
<div className="flex items-center bg-bg-0 rounded gap-[6px] py-1.5 px-2">
<span className="text-[12px]">{icon}</span>
<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>
)

파일 보기

@ -1,8 +1,6 @@
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { LayerTree } from '@common/components/layer/LayerTree'
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'
interface InfoLayerSectionProps {
@ -26,29 +24,13 @@ const InfoLayerSection = ({
layerBrightness,
onLayerBrightnessChange,
}: InfoLayerSectionProps) => {
// API에서 레이어 트리 데이터 가져오기
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
const { data: layerTree, isLoading } = useLayerTree()
const [layerColors, setLayerColors] = useState<Record<string, string>>({})
// 정적 데이터를 Layer 형식으로 변환 (API 실패 시 폴백)
const staticLayers = useMemo(() => {
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
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
const effectiveLayers: Layer[] = layerTree ?? []
return (
<div className="border-b border-border">

파일 보기

@ -1,5 +1,63 @@
import { useState } from 'react'
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 InfoLayerSection from './InfoLayerSection'
import OilBoomSection from './OilBoomSection'
@ -50,6 +108,7 @@ export function LeftPanel({
onLayerOpacityChange,
layerBrightness,
onLayerBrightnessChange,
sensitiveResources = [],
onImageAnalysisResult,
}: LeftPanelProps) {
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
@ -160,7 +219,7 @@ export function LeftPanel({
</div>
<div className="flex items-baseline gap-1.5">
<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 className="flex items-baseline gap-1.5">
<span className="text-[10px] text-text-3 min-w-[52px] font-korean"></span>
@ -204,7 +263,33 @@ export function LeftPanel({
{expandedSections.impactResources && (
<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>

파일 보기

@ -9,11 +9,13 @@ import { BacktrackModal } from './BacktrackModal'
import { RecalcModal } from './RecalcModal'
import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar'
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 { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi'
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, WindPoint } from '../services/predictionApi'
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '../services/predictionApi'
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, SensitiveResourceCategory, SensitiveResourceFeatureCollection, WindPoint } from '../services/predictionApi'
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
import SimulationErrorModal from './SimulationErrorModal'
import { api } from '@common/services/api'
@ -22,6 +24,13 @@ import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
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())}`
}
// ---------------------------------------------------------------------------
// 민감자원 타입 + 데모 데이터
// ---------------------------------------------------------------------------
@ -36,20 +45,13 @@ export interface SensitiveResource {
}
export interface DisplayControls {
showCurrent: boolean; // 유향/유속
showWind: boolean; // 풍향/풍속
showBeached: boolean; // 해안부착
showTimeLabel: boolean; // 시간 표시
showCurrent: boolean; // 유향/유속
showWind: boolean; // 풍향/풍속
showBeached: 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)
// ---------------------------------------------------------------------------
@ -137,6 +139,8 @@ export function OilSpillView() {
// 민감자원
const [sensitiveResources, setSensitiveResources] = useState<SensitiveResource[]>([])
const [sensitiveResourceCategories, setSensitiveResourceCategories] = useState<SensitiveResourceCategory[]>([])
const [sensitiveResourceGeojson, setSensitiveResourceGeojson] = useState<SensitiveResourceFeatureCollection | null>(null)
// 오일펜스 배치 상태
const [boomLines, setBoomLines] = useState<BoomLine[]>([])
@ -160,6 +164,7 @@ export function OilSpillView() {
showWind: false,
showBeached: false,
showTimeLabel: false,
showSensitiveResources: false,
})
// 타임라인 플레이어 상태
@ -195,6 +200,13 @@ export function OilSpillView() {
const [summaryByModel, setSummaryByModel] = 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 [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null)
@ -215,7 +227,7 @@ export function OilSpillView() {
setOilTrajectory(demoTrajectory)
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
setBoomLines(demoBooms)
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
setSensitiveResources([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSubTab])
@ -467,7 +479,7 @@ export function OilSpillView() {
setSelectedAnalysis(analysis)
setCenterPoints([])
if (analysis.occurredAt) {
setAccidentTime(analysis.occurredAt.slice(0, 16))
setAccidentTime(toLocalDateTimeStr(analysis.occurredAt))
}
if (analysis.lon != null && analysis.lat != null) {
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
@ -517,7 +529,13 @@ export function OilSpillView() {
if (sbModel) setSummaryByModel(sbModel);
if (stepSbModel) setStepSummariesByModel(stepSbModel);
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 완료 후 재생, 그렇지 않으면 즉시 재생
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
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)
setOilTrajectory(demoTrajectory)
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
setSensitiveResources([])
setSensitiveResourceCategories([])
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
pendingPlayRef.current = true
@ -553,7 +572,7 @@ export function OilSpillView() {
setDrawingPoints(prev => [...prev, { lat, lon }])
} else if (drawAnalysisMode === 'polygon') {
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
} else {
} else if (isSelectingLocation) {
setIncidentCoord({ lon, lat })
setIsSelectingLocation(false)
}
@ -565,7 +584,7 @@ export function OilSpillView() {
setAnalysisResult(null)
}
const handleRunPolygonAnalysis = () => {
const handleRunPolygonAnalysis = async () => {
if (analysisPolygonPoints.length < 3) return
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
@ -580,7 +599,7 @@ export function OilSpillView() {
setDrawAnalysisMode(null)
}
const handleRunCircleAnalysis = () => {
const handleRunCircleAnalysis = async () => {
if (!incidentCoord) return
const radiusM = circleRadiusNm * 1852
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
@ -613,7 +632,7 @@ export function OilSpillView() {
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
setIncidentCoord({ 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)
setSpillAmount(parseFloat(result.volume.toFixed(4)))
setSpillUnit('kL')
@ -746,9 +765,10 @@ export function OilSpillView() {
const newWindDataByModel: Record<string, WindPoint[][]> = {};
const newHydrDataByModel: Record<string, (HydrDataStep | null)[]> = {};
const newSummaryByModel: Record<string, SimulationSummary> = {};
const newStepSummariesByModel: Record<string, SimulationSummary[]> = {};
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') {
errors.push(error || `${model} 분석 중 오류가 발생했습니다.`);
return;
@ -760,6 +780,7 @@ export function OilSpillView() {
newSummaryByModel[model] = summary;
if (model === 'OpenDrift' || !latestSummary) latestSummary = summary;
}
if (stepSummaries) newStepSummariesByModel[model] = stepSummaries;
if (windData) newWindDataByModel[model] = windData;
if (hydrData) newHydrDataByModel[model] = hydrData;
if (centerPoints) {
@ -788,9 +809,10 @@ export function OilSpillView() {
setWindDataByModel(newWindDataByModel);
setHydrDataByModel(newHydrDataByModel);
setSummaryByModel(newSummaryByModel);
setStepSummariesByModel(newStepSummariesByModel);
const booms = generateAIBoomLines(merged, effectiveCoord, algorithmSettings);
setBoomLines(booms);
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
setSensitiveResources([]);
setCurrentStep(0);
setIsPlaying(true);
setFlyToCoord({ lon: effectiveCoord.lon, lat: effectiveCoord.lat });
@ -800,6 +822,26 @@ export function OilSpillView() {
setSimulationError(errors.join('; '));
} else {
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) {
const msg =
@ -827,6 +869,7 @@ export function OilSpillView() {
accidentTime ||
'';
const wx = analysisDetail?.weather?.[0] ?? null;
const weatherSnapshot = useWeatherSnapshotStore.getState().snapshot;
const payload: OilReportPayload = {
incident: {
@ -854,9 +897,27 @@ export function OilSpillView() {
coastLength: simulationSummary ? `${simulationSummary.pollutionCoastLength.toFixed(2)} km` : '—',
oilType: OIL_TYPE_CODE[oilType] || oilType,
},
weather: wx
? { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp }
: null,
weather: (() => {
if (weatherSnapshot) {
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: (() => {
const fmt = (model: string) => {
const s = summaryByModel[model];
@ -884,6 +945,14 @@ export function OilSpillView() {
return [toRow('3시간', steps[3]), toRow('6시간', steps[6])];
})(),
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 ? {
center: [incidentCoord.lat, incidentCoord.lon],
zoom: 10,
@ -947,6 +1016,7 @@ export function OilSpillView() {
onLayerOpacityChange={setLayerOpacity}
layerBrightness={layerBrightness}
onLayerBrightnessChange={setLayerBrightness}
sensitiveResources={sensitiveResourceCategories}
onImageAnalysisResult={handleImageAnalysisResult}
/>
)}
@ -975,6 +1045,7 @@ export function OilSpillView() {
layerOpacity={layerOpacity}
layerBrightness={layerBrightness}
sensitiveResources={sensitiveResources}
sensitiveResourceGeojson={displayControls.showSensitiveResources ? sensitiveResourceGeojson : null}
lightMode
centerPoints={centerPoints.filter(p => visibleModels.has((p.model || 'OpenDrift') as PredictionModel))}
windData={windData}
@ -1197,6 +1268,7 @@ export function OilSpillView() {
onOpenReport={handleOpenReport}
detail={analysisDetail}
summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary}
boomBlockedVolume={boomBlockedVolume}
displayControls={displayControls}
onDisplayControlsChange={setDisplayControls}
windHydrModel={windHydrModel}
@ -1210,6 +1282,8 @@ export function OilSpillView() {
onCircleRadiusChange={setCircleRadiusNm}
analysisResult={analysisResult}
incidentCoord={incidentCoord}
centerPoints={centerPoints}
predictionTime={predictionTime}
onStartPolygonDraw={handleStartPolygonDraw}
onRunPolygonAnalysis={handleRunPolygonAnalysis}
onRunCircleAnalysis={handleRunCircleAnalysis}

파일 보기

@ -430,7 +430,9 @@ function DateTimeInput({ value, onChange }: { value: string; onChange: (v: strin
const datePart = value ? value.split('T')[0] : ''
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 [viewYear, setViewYear] = useState(parsed.getFullYear())
@ -561,9 +563,15 @@ function DateTimeInput({ value, onChange }: { value: string; onChange: (v: strin
<button
type="button"
onClick={() => {
setViewYear(todayY)
setViewMonth(todayM)
pickDate(todayD)
const now = new Date()
setViewYear(now.getFullYear())
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"
style={{

파일 보기

@ -1,6 +1,7 @@
import { useState } from 'react'
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
import { useState, useMemo } from 'react'
import type { PredictionDetail, SimulationSummary, CenterPoint } from '../services/predictionApi'
import type { DisplayControls } from './OilSpillView'
import { haversineDistance, computeBearing } from '@common/utils/geo'
interface AnalysisResult {
area: number
@ -29,6 +30,9 @@ interface RightPanelProps {
onCircleRadiusChange?: (nm: number) => void
analysisResult?: AnalysisResult | null
incidentCoord?: { lat: number; lon: number } | null
centerPoints?: CenterPoint[]
predictionTime?: number
boomBlockedVolume?: number
onStartPolygonDraw?: () => void
onRunPolygonAnalysis?: () => void
onRunCircleAnalysis?: () => void
@ -44,6 +48,10 @@ export function RightPanel({
drawAnalysisMode, analysisPolygonPoints = [],
circleRadiusNm = 5, onCircleRadiusChange,
analysisResult,
incidentCoord,
centerPoints,
predictionTime,
boomBlockedVolume = 0,
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
onCancelAnalysis, onClearAnalysis,
}: RightPanelProps) {
@ -54,6 +62,38 @@ export function RightPanel({
const [shipExpanded, setShipExpanded] = 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 (
<div className="w-[300px] min-w-[300px] bg-bg-1 border-l border-border flex flex-col">
{/* Tab Header */}
@ -81,7 +121,10 @@ export function RightPanel({
checked={displayControls?.showBeached ?? false}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
></ControlledCheckbox>
<ControlledCheckbox checked={false} onChange={() => {}} disabled>
<ControlledCheckbox
checked={displayControls?.showSensitiveResources ?? false}
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showSensitiveResources: v })}
>
</ControlledCheckbox>
<ControlledCheckbox
@ -233,23 +276,29 @@ export function RightPanel({
</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]">
<PredictionCard value="4.7 km²" label="영향 면적" color="var(--red)" />
<PredictionCard value="6.2 km" label="확산 거리" color="var(--orange)" />
<PredictionCard value="NE 42°" label="확산 방향" color="var(--cyan)" />
<PredictionCard value="0.35 m/s" label="확산 속도" color="var(--t1)" />
<PredictionCard value={spreadSummary?.area != null ? `${spreadSummary.area.toFixed(1)} km²` : '—'} label="영향 면적" color="var(--red)" />
<PredictionCard value={spreadSummary?.distance != null ? `${spreadSummary.distance.toFixed(1)} km` : '—'} label="확산 거리" color="var(--orange)" />
<PredictionCard value={spreadSummary?.directionLabel ?? '—'} label="확산 방향" color="var(--cyan)" />
<PredictionCard value={spreadSummary?.speed != null ? `${spreadSummary.speed.toFixed(2)} m/s` : '—'} label="확산 속도" color="var(--t1)" />
</div>
</Section>
{/* 유출유 풍화 상태 */}
<Section title="유출유 풍화 상태">
<div className="flex flex-col gap-[3px] text-[8px]">
<ProgressBar label="수면잔류" value={58} color="var(--blue)" />
<ProgressBar label="증발" value={22} color="var(--cyan)" />
<ProgressBar label="분산" value={12} color="var(--green)" />
<ProgressBar label="펜스차단" value={5} color="var(--boom)" />
<ProgressBar label="해안도달" value={3} color="var(--red)" />
{weatheringStatus ? (
<>
<ProgressBar label="수면잔류" value={weatheringStatus.surface} color="var(--blue)" />
<ProgressBar label="증발" value={weatheringStatus.evaporation} color="var(--cyan)" />
<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>
</Section>
@ -654,13 +703,13 @@ function PollResult({
{summary && (
<div className="flex justify-between">
<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>
)}
{summary && (
<div className="flex justify-between">
<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 className="flex justify-between">

파일 보기

@ -1,7 +1,7 @@
import type { PredictionModel } from './OilSpillView'
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
import type { Analysis } from './AnalysisListTable'
import type { ImageAnalyzeResult } from '../services/predictionApi'
import type { ImageAnalyzeResult, SensitiveResourceCategory } from '../services/predictionApi'
export interface LeftPanelProps {
selectedAnalysis?: Analysis | null
@ -49,6 +49,8 @@ export interface LeftPanelProps {
onLayerOpacityChange: (val: number) => void
layerBrightness: number
onLayerBrightnessChange: (val: number) => void
// 영향 민감자원
sensitiveResources?: SensitiveResourceCategory[]
// 이미지 분석 결과 콜백
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void
}

파일 보기

@ -168,6 +168,8 @@ export interface OilParticle {
export interface SimulationSummary {
remainingVolume: number;
weatheredVolume: number;
evaporationVolume: number;
dispersionVolume: number;
pollutionArea: number;
beachedVolume: number;
pollutionCoastLength: number;
@ -190,6 +192,7 @@ export interface RunModelSyncResult {
status: 'DONE' | 'ERROR';
trajectory?: OilParticle[];
summary?: SimulationSummary;
stepSummaries?: SimulationSummary[];
centerPoints?: CenterPoint[];
windData?: WindPoint[][];
hydrData?: (HydrDataStep | null)[];
@ -218,6 +221,73 @@ export const fetchAnalysisTrajectory = async (acdntSn: number): Promise<Trajecto
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 { fetchSensitiveResourcesGeojson, fetchPredictionParticlesGeojson, fetchSensitivityEvaluationGeojson } from '@tabs/prediction/services/predictionApi'
// ─── Data Types ─────────────────────────────────────────────
export type ReportType = '초기보고서' | '지휘부 보고' | '예측보고서' | '종합보고서' | '유출유 보고'
@ -39,7 +42,12 @@ export interface OilSpillReportData {
recovery: { shipName: string; period: string }[]
result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string }
capturedMapImage?: string;
step3MapImage?: string;
step6MapImage?: string;
hasMapCapture?: boolean;
acdntSn?: number;
sensitiveMapImage?: string;
sensitivityMapImage?: string;
}
// 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 className="flex gap-4 mb-4">
<div style={S.mapPlaceholder}> 3 </div>
<div style={S.mapPlaceholder}> 6 </div>
<div style={{ flex: 1, minWidth: 0 }}>
{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 style={S.subHeader}> </div>
<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 }) {
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 })
@ -321,9 +738,9 @@ function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing:
<div style={S.page}>
<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.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}>
<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) => (
@ -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 }) {
const setSens = (i: number, v: string) => { const s = [...data.sensitivity]; s[i] = { ...s[i], area: v }; onChange({ ...data, sensitivity: s }) }
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold"></div>
<div style={S.sectionTitle}> ( )</div>
<div style={S.mapPlaceholder}> </div>
<SensitivityMapSection data={data} editing={editing} onChange={onChange} />
<table style={S.table}>
<thead><tr><th style={S.th}></th><th style={S.th}> (km²)</th></tr></thead>
<tbody>{data.sensitivity.map((s, i) => (

파일 보기

@ -4,12 +4,24 @@ import type { OilReportPayload } from '@common/hooks/useSubMenu';
interface OilSpreadMapPanelProps {
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;
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 [isCapturing, setIsCapturing] = useState(false);
@ -18,29 +30,29 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
setIsCapturing(true);
const dataUrl = await captureRef.current();
setIsCapturing(false);
if (dataUrl) {
onCapture(dataUrl);
}
if (dataUrl) onCapture(dataUrl);
};
if (!mapData) {
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>
);
}
return (
<div className="mb-4">
{/* 지도 + 오버레이 컨테이너 — MapView 항상 마운트 유지 (deck.gl rAF race condition 방지) */}
<div className="relative w-full rounded-lg border border-border overflow-hidden" style={{ height: '620px' }}>
<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 className="relative rounded-lg border border-border overflow-hidden" style={{ height: '300px' }}>
<MapView
center={mapData.center}
zoom={mapData.zoom}
incidentCoord={{ lat: mapData.center[0], lon: mapData.center[1] }}
oilTrajectory={mapData.trajectory}
externalCurrentTime={mapData.currentStep}
externalCurrentTime={step}
centerPoints={mapData.centerPoints}
showBeached={true}
showTimeLabel={true}
@ -50,27 +62,26 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
lightMode
/>
{/* 캡처 이미지 오버레이 — 우측 상단 */}
{capturedImage && (
<div className="absolute top-3 right-3 z-10" style={{ width: '220px' }}>
{captured && (
<div className="absolute top-2 right-2 z-10" style={{ width: '180px' }}>
<div
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)' }}
>
<img src={capturedImage} alt="확산예측 지도 캡처" className="w-full block" />
<img src={captured} alt={`${label} 캡처`} className="w-full block" />
<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)' }}
>
<span className="text-[10px] font-korean font-semibold" style={{ color: '#06b6d4' }}>
<span className="text-[9px] font-korean font-semibold" style={{ color: '#06b6d4' }}>
📷
</span>
<button
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)' }}
>
</button>
</div>
</div>
@ -78,30 +89,69 @@ const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSp
)}
</div>
{/* 하단 안내 + 캡처 버튼 */}
<div className="flex items-center justify-between mt-2">
<p className="text-[10px] text-text-3 font-korean">
{capturedImage
? 'PDF 다운로드 시 캡처된 이미지가 포함됩니다.'
: '지도를 이동/확대하여 원하는 범위를 선택한 후 캡처하세요.'}
{/* 캡처 버튼 */}
<div className="flex items-center justify-between mt-1.5">
<p className="text-[9px] text-text-3 font-korean">
{captured ? 'PDF 출력 시 포함됩니다.' : '원하는 범위를 선택 후 캡처하세요.'}
</p>
<button
onClick={handleCapture}
disabled={isCapturing || !!capturedImage}
className="px-3 py-1.5 text-[11px] font-semibold rounded transition-all font-korean flex items-center gap-1.5"
disabled={isCapturing || !!captured}
className="px-2.5 py-1 text-[10px] font-semibold rounded transition-all font-korean flex items-center gap-1"
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)',
color: capturedImage ? 'rgba(6,182,212,0.5)' : '#06b6d4',
color: captured ? 'rgba(6,182,212,0.5)' : '#06b6d4',
opacity: isCapturing ? 0.6 : 1,
cursor: capturedImage ? 'default' : 'pointer',
cursor: captured ? 'default' : 'pointer',
}}
>
{capturedImage ? '✓ 캡처됨' : '📷 이 범위로 캡처'}
{captured ? '✓ 캡처됨' : '📷 캡처'}
</button>
</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;

파일 보기

@ -3,6 +3,7 @@ import {
createEmptyReport,
} from './OilSpillReportTemplate';
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu';
import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore';
import OilSpreadMapPanel from './OilSpreadMapPanel';
import { saveReport } from '../services/reportsApi';
import {
@ -32,8 +33,11 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null)
// OIL 실 데이터 (없으면 sampleOilData fallback)
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(() => {
@ -94,8 +98,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
sunset: '',
windDir: oilPayload.weather.windDir,
windSpeed: oilPayload.weather.windSpeed,
currentDir: '',
currentSpeed: '',
currentDir: oilPayload.weather.currentDir ?? '',
currentSpeed: oilPayload.weather.currentSpeed ?? '',
waveHeight: oilPayload.weather.waveHeight,
}];
}
@ -109,27 +113,24 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
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
if (oilPayload.spreadSteps) {
report.spread = oilPayload.spreadSteps;
}
// acdntSn 전달 (민감자원 지도 로드용)
if (oilPayload.acdntSn) {
(report as typeof report & { acdntSn?: number }).acdntSn = oilPayload.acdntSn;
}
} else {
report.incident.pollutant = '';
report.incident.spillAmount = '';
}
}
if (activeCat === 0 && oilMapCaptured) {
report.capturedMapImage = oilMapCaptured;
if (activeCat === 0) {
if (capturedStep3) report.step3MapImage = capturedStep3;
if (capturedStep6) report.step6MapImage = capturedStep6;
}
try {
await saveReport(report)
@ -148,20 +149,22 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
// OIL 섹션에 실 데이터 삽입
if (activeCat === 0) {
if (sec.id === 'oil-spread') {
const mapImg = oilMapCaptured
? `<img src="${oilMapCaptured}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:8px;margin-bottom:12px;" />`
: '<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>';
const spreadRows = oilPayload
? [
['KOSPS', oilPayload.spread.kosps],
['OpenDrift', oilPayload.spread.openDrift],
['POSEIDON', oilPayload.spread.poseidon],
]
: [['KOSPS', '—'], ['OpenDrift', '—'], ['POSEIDON', '—']];
const tds = spreadRows.map(r =>
`<td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>${r[0]}</b><br/>${r[1]}</td>`
).join('');
content = `${mapImg}<table style="width:100%;border-collapse:collapse;font-size:12px;"><tr>${tds}</tr></table>`;
const img3 = capturedStep3
? `<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:6px;display:flex;align-items:center;justify-content:center;color:#999;font-size:11px;">[미캡처]</div>';
const img6 = capturedStep6
? `<img src="${capturedStep6}" 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:6px;display:flex;align-items:center;justify-content:center;color:#999;font-size:11px;">[미캡처]</div>';
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>`;
const spreadStepRows = oilPayload?.spreadSteps && oilPayload.spreadSteps.length > 0
? oilPayload.spreadSteps.map(s =>
`<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>`
).join('')
: '';
const stepsTable = spreadStepRows
? `<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') {
@ -173,6 +176,17 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
: `<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 (sec.id === 'oil-pollution') {
const rows = [
@ -366,10 +380,39 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
<>
<OilSpreadMapPanel
mapData={oilPayload?.mapData ?? null}
capturedImage={oilMapCaptured}
onCapture={(dataUrl) => setOilMapCaptured(dataUrl)}
onReset={() => setOilMapCaptured(null)}
capturedStep3={capturedStep3}
capturedStep6={capturedStep6}
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">
{[
{ label: 'KOSPS', value: oilPayload?.spread.kosps || '—', color: '#06b6d4' },
@ -410,11 +453,43 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</table>
</>
)}
{sec.id === 'oil-sensitive' && (
<p className="text-[12px] text-text-3 font-korean italic">
.
</p>
)}
{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>
);
}
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' && (() => {
if (!oilPayload) {
return (
@ -448,11 +523,50 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</div>
</div>
)}
{sec.id === 'oil-tide' && (
<p className="text-[12px] text-text-3 font-korean italic">
· .
</p>
)}
{sec.id === 'oil-tide' && (() => {
const wx = oilPayload?.weather;
if (!wx) {
return (
<p className="text-[12px] text-text-3 font-korean italic">
· .
</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 대기확산 섹션들 ── */}
{sec.id === 'hns-atm' && (

파일 보기

@ -302,7 +302,12 @@ export function ReportsView() {
const getVal = buildReportGetVal(previewReport)
const meta = { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }
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]"
@ -357,38 +362,52 @@ export function ReportsView() {
{[
previewReport.incident.pollutant && `유출유종: ${previewReport.incident.pollutant}`,
previewReport.incident.spillAmount && `유출량: ${previewReport.incident.spillAmount}`,
previewReport.spread?.length > 0 && `확산예측: ${previewReport.spread.length}개 시점 데이터`,
].filter(Boolean).join('\n') || '—'}
</div>
{previewReport.capturedMapImage && (
<img
src={previewReport.capturedMapImage}
alt="확산예측 지도 캡처"
className="w-full rounded-lg border border-border mt-3"
style={{ maxHeight: '300px', objectFit: 'contain' }}
/>
{(previewReport.capturedMapImage || previewReport.step3MapImage || previewReport.step6MapImage) && (
<div className="flex flex-col gap-2 mt-3">
{previewReport.capturedMapImage && (
<img
src={previewReport.capturedMapImage}
alt="확산예측 지도"
className="w-full rounded-lg border border-border"
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>
{/* 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>

파일 보기

@ -101,7 +101,7 @@ const MANIFEST_XML =
/**
* Contents/content.hpf: Skeleton.hwpx ,
*/
function buildContentHpf(): string {
function buildContentHpf(extraManifestItems = ''): string {
const now = new Date().toISOString();
return (
'<?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="section0" href="Contents/section0.xml" media-type="application/xml"/>' +
'<opf:item id="settings" href="settings.xml" media-type="application/xml"/>' +
extraManifestItems +
'</opf:manifest>' +
'<opf:spine>' +
'<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 )
*/
@ -542,6 +567,104 @@ const CONTENT_WIDTH = 42520;
const LABEL_WIDTH = Math.round(CONTENT_WIDTH * 0.33);
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(
fields: { key: string; label: string }[],
getVal: (key: string) => string,
@ -549,6 +672,14 @@ function buildFieldTable(
const rowCnt = fields.length;
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 = '';
fields.forEach((field, rowIdx) => {
const value = getVal(field.key) || '-';
@ -604,6 +735,7 @@ function buildSection0Xml(
meta: ReportMeta,
sections: ReportSection[],
getVal: (key: string) => string,
imageBinIds?: { step3?: number; step6?: number; sensitiveMap?: number; sensitivityMap?: number },
): string {
// ID 시퀀스 초기화 (재사용 시 충돌 방지)
_idSeq = 1000000000;
@ -634,9 +766,43 @@ function buildSection0Xml(
// 섹션 제목 (11pt = charPrId 6)
body += buildPara(section.title, 6);
// 필드 테이블
if (section.fields.length > 0) {
// __spreadMaps 필드 포함 섹션: 이미지 단락 삽입 후 나머지 필드 처리
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);
// 통합민감도 평가 지도 — 테이블 뒤
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) {
lines.push(`[${section.title}]`);
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) {
lines.push(` ${field.label}: ${value}`);
} else {
@ -690,6 +859,7 @@ export async function exportAsHWPX(
sections: ReportSection[],
getVal: (key: string) => string,
filename: string,
images?: { step3?: string; step6?: string; sensitiveMap?: string; sensitivityMap?: string },
): Promise<void> {
const zip = new JSZip();
@ -703,10 +873,66 @@ export async function exportAsHWPX(
zip.file('META-INF/container.rdf', CONTAINER_RDF);
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
zip.file('Contents/content.hpf', buildContentHpf());
zip.file('Contents/header.xml', HEADER_XML);
zip.file('Contents/section0.xml', buildSection0Xml(templateLabel, meta, sections, getVal));
zip.file('Contents/content.hpf', buildContentHpf(extraManifestItems));
zip.file('Contents/header.xml', headerXml);
zip.file('Contents/section0.xml', buildSection0Xml(templateLabel, meta, sections, getVal, imageBinIds));
// Preview
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: '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: '7. 방제 자원', fields: [{ key: '__vessels', 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 }[] }[],
getVal: (key: string) => string,
filename: string,
images?: { step3?: string; step6?: string; sensitiveMap?: string; sensitivityMap?: string },
) {
const { exportAsHWPX } = await import('./hwpxExport');
await exportAsHWPX(templateLabel, meta, sections, getVal, filename);
await exportAsHWPX(templateLabel, meta, sections, getVal, filename, images);
}
export type ViewState =
@ -121,6 +122,14 @@ function formatSpreadTable(spread: OilSpillReportData['spread']): string {
function formatSensitiveTable(r: OilSpillReportData): 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) {
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('')
@ -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('')
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) {
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('')
@ -196,6 +212,18 @@ export function buildReportGetVal(report: OilSpillReportData) {
}
if (key === '__tide') return formatTideTable(report.tide)
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 === '__sensitive') return formatSensitiveTable(report)
if (key === '__vessels') return formatVesselsTable(report.vessels)

파일 보기

@ -60,6 +60,7 @@ export interface ApiReportListItem {
sttsCd: string;
authorId: string;
authorName: string;
acdntSn?: number | null;
regDtm: string;
mdfcnDtm: string | null;
hasMapCapture?: boolean;
@ -75,7 +76,8 @@ export interface ApiReportSectionData {
export interface ApiReportDetail extends ApiReportListItem {
acdntSn: number | null;
sections: ApiReportSectionData[];
mapCaptureImg?: string | null;
step3MapImg?: string | null;
step6MapImg?: string | null;
}
export interface ApiReportListResponse {
@ -178,7 +180,8 @@ export async function createReportApi(input: {
title: string;
jrsdCd?: string;
sttsCd?: string;
mapCaptureImg?: string;
step3MapImg?: string;
step6MapImg?: string;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}): Promise<{ sn: number }> {
const res = await api.post<{ sn: number }>('/reports', input);
@ -190,7 +193,8 @@ export async function updateReportApi(sn: number, input: {
jrsdCd?: string;
sttsCd?: string;
acdntSn?: number | null;
mapCaptureImg?: string | null;
step3MapImg?: string | null;
step6MapImg?: string | null;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}): Promise<void> {
await api.post(`/reports/${sn}/update`, input);
@ -236,14 +240,26 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
// analysis + etcEquipment 합산
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
const existingSn = (data as OilSpillReportData & { reportSn?: number }).reportSn;
const existingSn = extData.reportSn;
if (existingSn) {
await updateReportApi(existingSn, {
title: data.title || data.incident.name || '보고서',
jrsdCd: data.jurisdiction,
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,
});
return existingSn;
@ -252,10 +268,12 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
const result = await createReportApi({
tmplSn,
ctgrSn,
acdntSn: extData.acdntSn,
title: data.title || data.incident.name || '보고서',
jrsdCd: data.jurisdiction,
sttsCd,
mapCaptureImg: data.capturedMapImage || undefined,
step3MapImg: data.step3MapImage || undefined,
step6MapImg: data.step6MapImage || undefined,
sections,
});
return result.sn;
@ -273,6 +291,7 @@ export function apiListItemToReportData(item: ApiReportListItem): OilSpillReport
jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청',
status: CODE_TO_STATUS[item.sttsCd] || '테스트',
hasMapCapture: item.hasMapCapture,
acdntSn: item.acdntSn ?? undefined,
// 목록에서는 섹션 데이터 없음 — 빈 기본값
incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' },
tide: [], weather: [], spread: [],
@ -341,6 +360,12 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
case 'result':
reportData.result = d as OilSpillReportData['result'];
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)}`;
}
if (detail.mapCaptureImg) {
reportData.capturedMapImage = detail.mapCaptureImg;
if (detail.step3MapImg) {
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;

파일 보기

@ -6,7 +6,7 @@ import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import type { ScatSegment } from './scatTypes'
import type { ApiZoneItem } from '../services/scatApi'
import { esiColor, jejuCoastCoords } from './scatConstants'
import { esiColor } from './scatConstants'
import { hexToRgba } from '@common/components/map/mapUtils'
const BASE_STYLE: StyleSpecification = {
@ -87,12 +87,17 @@ function getZoomScale(zoom: number) {
}
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
function buildSegCoords(seg: ScatSegment, halfLenScale: number): [number, number][] {
const coastIdx = seg.id % (jejuCoastCoords.length - 1)
const [clat1, clng1] = jejuCoastCoords[coastIdx]
const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length]
const dlat = clat2 - clat1
const dlng = clng2 - clng1
// 인접 구간 좌표로 해안선 방향을 동적 계산
function buildSegCoords(
seg: ScatSegment,
halfLenScale: number,
segments: ScatSegment[],
): [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 nDlat = dist > 0 ? dlat / dist : 0
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 coastlineLayer = useMemo(
() =>
new PathLayer({
id: 'jeju-coastline',
data: [{ path: jejuCoastCoords.map(([lat, lng]) => [lng, lat] as [number, number]) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [6, 182, 212, 46],
getWidth: 1.5,
getDashArray: [8, 6],
dashJustified: true,
widthMinPixels: 1,
}),
[],
)
// 제주도 해안선 레퍼런스 라인 — 하드코딩 제거, 추후 DB 기반 해안선으로 대체 예정
// const coastlineLayer = useMemo(
// () =>
// new PathLayer({
// id: 'jeju-coastline',
// data: [{ path: jejuCoastCoords.map(([lat, lng]) => [lng, lat] as [number, number]) }],
// getPath: (d: { path: [number, number][] }) => d.path,
// getColor: [6, 182, 212, 46],
// getWidth: 1.5,
// getDashArray: [8, 6],
// dashJustified: true,
// widthMinPixels: 1,
// }),
// [],
// )
// 선택된 구간 글로우 레이어
const glowLayer = useMemo(
@ -148,7 +153,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
new PathLayer({
id: 'scat-glow',
data: [selectedSeg],
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale),
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale, segments),
getColor: [34, 197, 94, 38],
getWidth: zs.glowWidth,
capRounded: true,
@ -159,7 +164,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
getWidth: [zs.glowWidth],
},
}),
[selectedSeg, zs.glowWidth, zs.halfLenScale],
[selectedSeg, segments, zs.glowWidth, zs.halfLenScale],
)
// ESI 색상 세그먼트 폴리라인
@ -168,7 +173,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
new PathLayer({
id: 'scat-segments',
data: segments,
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale),
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale, segments),
getColor: (d: ScatSegment) => {
const isSelected = selectedSeg.id === d.id
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
const deckLayers: any[] = useMemo(() => {
// 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)
return layers
}, [coastlineLayer, glowLayer, segPathLayer, markerLayer])
}, [glowLayer, segPathLayer, markerLayer])
const doneCount = segments.filter(s => s.status === '완료').length
const progCount = segments.filter(s => s.status === '진행중').length

파일 보기

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { Map, useControl } from '@vis.gl/react-maplibre'
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 'maplibre-gl/dist/maplibre-gl.css'
import type { ScatDetail } from './scatTypes'
@ -50,45 +50,22 @@ function PopupMap({
esi: string
onMapLoad?: () => void
}) {
// 해안 구간 라인 (시뮬레이션) — [lng, lat] 순서
const segLine: [number, number][] = [
[lng - 0.004, lat - 0.002],
[lng - 0.002, lat - 0.001],
[lng, lat],
[lng + 0.002, lat + 0.001],
[lng + 0.004, lat + 0.002],
]
// 조사 경로 라인
const surveyRoute: [number, number][] = [
[lng - 0.003, lat - 0.0015],
[lng - 0.001, lat - 0.0005],
[lng + 0.001, lat + 0.0005],
[lng + 0.003, lat + 0.0015],
]
// 해안 구간 라인 / 조사 경로 — 하드코딩 방향이라 주석처리, 추후 실제 방향 데이터로 대체
// const segLine: [number, number][] = [
// [lng - 0.004, lat - 0.002],
// [lng - 0.002, lat - 0.001],
// [lng, lat],
// [lng + 0.002, lat + 0.001],
// [lng + 0.004, lat + 0.002],
// ]
// const surveyRoute: [number, number][] = [
// [lng - 0.003, lat - 0.0015],
// [lng - 0.001, lat - 0.0005],
// [lng + 0.001, lat + 0.0005],
// [lng + 0.003, lat + 0.0015],
// ]
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({
id: 'access-point',

파일 보기

@ -139,7 +139,7 @@ function DetailTab({ detail }: { detail: ScatDetail }) {
/* ═══ 탭 1: 현장 사진 ═══ */
function PhotoTab({ detail }: { detail: ScatDetail }) {
const [imgError, setImgError] = useState(false);
const imgSrc = `/scat-img/${detail.code}-1.png`;
const imgSrc = `/scat/img/${detail.code}-1.png`;
if (imgError) {
return (

파일 보기

@ -26,9 +26,9 @@ export const statusColor: Record<string, string> = {
};
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.26, 126.18],
@ -101,4 +101,4 @@ export const jejuCoastCoords: [number, number][] = [
[33.31, 126.19],
[33.3, 126.175],
[33.293, 126.162],
];
]; */

파일 보기

@ -14,6 +14,7 @@ import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'
import { useWeatherData } from '../hooks/useWeatherData'
// import { useOceanForecast } from '../hooks/useOceanForecast'
import { WeatherMapControls } from './WeatherMapControls'
import { degreesToCardinal } from '../services/weatherUtils'
type TimeOffset = '0' | '3' | '6' | '9'
@ -40,13 +41,6 @@ interface WeatherStation {
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 {
time: string
hour: string

파일 보기

@ -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": ".",
"paths": {
"@common/*": ["src/common/*"],
"@tabs/*": ["src/tabs/*"]
"@tabs/*": ["src/tabs/*"],
"@pages/*": ["src/pages/*"],
"@/*": ["src/*"]
}
},
"include": ["src"]

파일 보기

@ -38,6 +38,7 @@ export default defineConfig({
alias: {
'@common': path.resolve(__dirname, 'src/common'),
'@tabs': path.resolve(__dirname, 'src/tabs'),
'@': path.resolve(__dirname, 'src'),
},
},
build: {