release: 2026-03-31 (6�� Ŀ��) #147
@ -1,6 +1,6 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-30",
|
||||
"applied_date": "2026-03-31",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev",
|
||||
"custom_pre_commit": true
|
||||
|
||||
@ -3,6 +3,35 @@ import { Router } from 'express';
|
||||
const router = Router();
|
||||
|
||||
const VWORLD_API_KEY = process.env.VWORLD_API_KEY || '';
|
||||
const ENC_UPSTREAM = 'https://tiles.gcnautical.com';
|
||||
|
||||
// ─── 공통 프록시 헬퍼 ───
|
||||
|
||||
async function proxyUpstream(upstreamUrl: string, res: import('express').Response, fallbackContentType = 'application/octet-stream') {
|
||||
try {
|
||||
const upstream = await fetch(upstreamUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
|
||||
});
|
||||
|
||||
if (!upstream.ok) {
|
||||
res.status(upstream.status).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || fallbackContentType;
|
||||
const cacheControl = upstream.headers.get('cache-control') || 'public, max-age=86400';
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', cacheControl);
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
res.end(Buffer.from(buffer));
|
||||
} catch {
|
||||
res.status(502).json({ error: '타일 서버 연결 실패' });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── VWorld 위성타일 ───
|
||||
|
||||
// GET /api/tiles/vworld/:z/:y/:x — VWorld WMTS 위성타일 프록시 (CORS 우회)
|
||||
// VWorld는 브라우저 직접 요청에 CORS 헤더를 반환하지 않으므로 서버에서 중계
|
||||
@ -22,28 +51,56 @@ router.get('/vworld/:z/:y/:x', async (req, res) => {
|
||||
}
|
||||
|
||||
const tileUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/${z}/${y}/${x}.jpeg`;
|
||||
await proxyUpstream(tileUrl, res, 'image/jpeg');
|
||||
});
|
||||
|
||||
try {
|
||||
const upstream = await fetch(tileUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
|
||||
});
|
||||
// ─── S-57 전자해도 (ENC) ───
|
||||
// tiles.gcnautical.com CORS 제한 우회를 위한 프록시 엔드포인트 그룹
|
||||
|
||||
if (!upstream.ok) {
|
||||
res.status(upstream.status).end();
|
||||
return;
|
||||
}
|
||||
// GET /api/tiles/enc/style — 공식 style.json 프록시
|
||||
router.get('/enc/style', async (_req, res) => {
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/style/nautical`, res, 'application/json');
|
||||
});
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || 'image/jpeg';
|
||||
const cacheControl = upstream.headers.get('cache-control') || 'public, max-age=86400';
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', cacheControl);
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
res.end(Buffer.from(buffer));
|
||||
} catch {
|
||||
res.status(502).json({ error: 'VWorld 타일 서버 연결 실패' });
|
||||
// GET /api/tiles/enc/sprite/:file — sprite JSON/PNG 프록시 (sprite.json, sprite.png, sprite@2x.json, sprite@2x.png)
|
||||
router.get('/enc/sprite/:file', async (req, res) => {
|
||||
const { file } = req.params;
|
||||
if (!/^sprite(@2x)?\.(json|png)$/.test(file)) {
|
||||
res.status(400).json({ error: '잘못된 sprite 파일명' });
|
||||
return;
|
||||
}
|
||||
const fallbackCt = file.endsWith('.png') ? 'image/png' : 'application/json';
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/sprite/${file}`, res, fallbackCt);
|
||||
});
|
||||
|
||||
// GET /api/tiles/enc/font/:fontstack/:range — glyphs(PBF) 프록시
|
||||
router.get('/enc/font/:fontstack/:range', async (req, res) => {
|
||||
const { fontstack, range } = req.params;
|
||||
if (!/^[\w\s%-]+$/.test(fontstack) || !/^\d+-\d+$/.test(range)) {
|
||||
res.status(400).json({ error: '잘못된 폰트 요청' });
|
||||
return;
|
||||
}
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/font/${fontstack}/${range}`, res, 'application/x-protobuf');
|
||||
});
|
||||
|
||||
// GET /api/tiles/enc/globe/:z/:x/:y — globe 벡터타일 프록시 (저줌 레벨용)
|
||||
router.get('/enc/globe/:z/:x/:y', async (req, res) => {
|
||||
const { z, x, y } = req.params;
|
||||
if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) {
|
||||
res.status(400).json({ error: '잘못된 타일 좌표' });
|
||||
return;
|
||||
}
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/globe/${z}/${x}/${y}`, res, 'application/x-protobuf');
|
||||
});
|
||||
|
||||
// GET /api/tiles/enc/:z/:x/:y — ENC 벡터타일 프록시 (표준 XYZ 순서)
|
||||
router.get('/enc/:z/:x/:y', async (req, res) => {
|
||||
const { z, x, y } = req.params;
|
||||
if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) {
|
||||
res.status(400).json({ error: '잘못된 타일 좌표' });
|
||||
return;
|
||||
}
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/enc/${z}/${x}/${y}`, res, 'application/x-protobuf');
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@ -17,6 +17,7 @@ import { buildMeasureLayers } from './measureLayers'
|
||||
import { MeasureOverlay } from './MeasureOverlay'
|
||||
import { useMeasureTool } from '@common/hooks/useMeasureTool'
|
||||
import { hexToRgba } from './mapUtils'
|
||||
import { S57EncOverlay } from './S57EncOverlay'
|
||||
import { useMapStore } from '@common/store/mapStore'
|
||||
|
||||
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'
|
||||
@ -264,6 +265,13 @@ const SATELLITE_3D_STYLE: StyleSpecification = {
|
||||
],
|
||||
}
|
||||
|
||||
// S-57 전자해도 전용 빈 스타일 (ENC 레이어가 자체적으로 육지/해안/수심 모두 포함)
|
||||
const ENC_EMPTY_STYLE: StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [],
|
||||
}
|
||||
|
||||
// 모델별 색상 매핑
|
||||
const MODEL_COLORS: Record<PredictionModel, string> = {
|
||||
'KOSPS': '#06b6d4',
|
||||
@ -1339,7 +1347,9 @@ export function MapView({
|
||||
])
|
||||
|
||||
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
|
||||
const currentMapStyle = mapToggles['threeD'] ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE
|
||||
const currentMapStyle = mapToggles['s57']
|
||||
? ENC_EMPTY_STYLE
|
||||
: mapToggles['threeD'] ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
@ -1369,6 +1379,9 @@ export function MapView({
|
||||
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
|
||||
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
|
||||
|
||||
{/* S-57 전자해도 오버레이 (공식 style.json 기반) */}
|
||||
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||
|
||||
{/* WMS 레이어 */}
|
||||
{wmsLayers.map(layer => (
|
||||
<Source
|
||||
|
||||
260
frontend/src/common/components/map/S57EncOverlay.tsx
Normal file
260
frontend/src/common/components/map/S57EncOverlay.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useMap } from '@vis.gl/react-maplibre';
|
||||
|
||||
const TILES_BASE = import.meta.env.VITE_API_URL?.replace(/\/api$/, '') || 'http://localhost:3001';
|
||||
const PROXY_PREFIX = `${TILES_BASE}/api/tiles/enc`;
|
||||
|
||||
const ENC_SPRITE_ID = 'enc-s57';
|
||||
const ENC_SOURCE_ID = 'enc-s57';
|
||||
const GLOBE_SOURCE_ID = 'enc-globe';
|
||||
|
||||
// sprite JSON에 정의된 아이콘 이름 캐시 (프리픽스 판별용)
|
||||
let spriteIconNames: Set<string> | null = null;
|
||||
|
||||
interface EncStyleLayer {
|
||||
id: string;
|
||||
type: string;
|
||||
source?: string;
|
||||
'source-layer'?: string;
|
||||
filter?: unknown;
|
||||
layout?: Record<string, unknown>;
|
||||
paint?: Record<string, unknown>;
|
||||
minzoom?: number;
|
||||
maxzoom?: number;
|
||||
}
|
||||
|
||||
interface EncStyle {
|
||||
layers: EncStyleLayer[];
|
||||
sources: Record<string, {
|
||||
type: string;
|
||||
tiles: string[];
|
||||
minzoom?: number;
|
||||
maxzoom?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// style.json 캐시
|
||||
let cachedStyle: EncStyle | null = null;
|
||||
async function loadEncStyle(): Promise<EncStyle> {
|
||||
if (cachedStyle) return cachedStyle;
|
||||
const res = await fetch(`${PROXY_PREFIX}/style`);
|
||||
if (!res.ok) throw new Error(`ENC style fetch failed: ${res.status}`);
|
||||
cachedStyle = await res.json();
|
||||
return cachedStyle!;
|
||||
}
|
||||
|
||||
// sprite JSON 로드 → 아이콘 이름 세트
|
||||
async function loadSpriteNames(): Promise<Set<string>> {
|
||||
if (spriteIconNames) return spriteIconNames;
|
||||
const res = await fetch(`${PROXY_PREFIX}/sprite/sprite@2x.json`);
|
||||
if (!res.ok) throw new Error(`Sprite JSON fetch failed: ${res.status}`);
|
||||
const json = await res.json();
|
||||
spriteIconNames = new Set(Object.keys(json));
|
||||
return spriteIconNames;
|
||||
}
|
||||
|
||||
// sprite 존재 여부 확인
|
||||
function hasSprite(map: maplibregl.Map, id: string): boolean {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sprites = (map.style as any)?.getSprite?.();
|
||||
return Array.isArray(sprites) && sprites.some((s: { id: string }) => s.id === id);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 아이콘/패턴 이름에 sprite 프리픽스 부착 ───
|
||||
// addSprite('enc-s57', url)로 등록하면 이미지는 'enc-s57:NAME' 으로 참조해야 함
|
||||
// style.json은 'NAME' 으로 참조하므로 변환 필요
|
||||
|
||||
const SPRITE_PREFIX = `${ENC_SPRITE_ID}:`;
|
||||
|
||||
function prefixIconValue(value: unknown, iconNames: Set<string>): unknown {
|
||||
if (typeof value === 'string') {
|
||||
// sprite에 정의된 아이콘 이름이면 프리픽스 부착
|
||||
if (iconNames.has(value)) return `${SPRITE_PREFIX}${value}`;
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
// MapLibre expression: 재귀적으로 문자열 요소 변환
|
||||
return value.map(item => prefixIconValue(item, iconNames));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function prefixLayerIcons(
|
||||
layer: EncStyleLayer,
|
||||
iconNames: Set<string>,
|
||||
): EncStyleLayer {
|
||||
const result = { ...layer };
|
||||
|
||||
// layout: icon-image
|
||||
if (result.layout?.['icon-image']) {
|
||||
result.layout = {
|
||||
...result.layout,
|
||||
'icon-image': prefixIconValue(result.layout['icon-image'], iconNames),
|
||||
};
|
||||
}
|
||||
|
||||
// paint: fill-pattern
|
||||
if (result.paint?.['fill-pattern']) {
|
||||
result.paint = {
|
||||
...result.paint,
|
||||
'fill-pattern': prefixIconValue(result.paint['fill-pattern'], iconNames),
|
||||
};
|
||||
}
|
||||
|
||||
// paint: background-pattern
|
||||
if (result.paint?.['background-pattern']) {
|
||||
result.paint = {
|
||||
...result.paint,
|
||||
'background-pattern': prefixIconValue(result.paint['background-pattern'], iconNames),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── 컴포넌트 ───
|
||||
|
||||
interface S57EncOverlayProps {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export function S57EncOverlay({ visible }: S57EncOverlayProps) {
|
||||
const { current: mapRef } = useMap();
|
||||
const addedLayersRef = useRef<string[]>([]);
|
||||
const sourcesAddedRef = useRef(false);
|
||||
const originalGlyphsRef = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef?.getMap();
|
||||
if (!map) return;
|
||||
|
||||
if (visible) {
|
||||
addEncLayers(map);
|
||||
} else {
|
||||
removeEncLayers(map);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (map) removeEncLayers(map);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, mapRef]);
|
||||
|
||||
async function addEncLayers(map: maplibregl.Map) {
|
||||
if (sourcesAddedRef.current) return;
|
||||
|
||||
try {
|
||||
const [style, iconNames] = await Promise.all([loadEncStyle(), loadSpriteNames()]);
|
||||
|
||||
// glyphs URL을 ENC 프록시로 교체 (ENC symbol 레이어용 폰트)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const styleObj = map.style as any;
|
||||
originalGlyphsRef.current = styleObj.glyphs;
|
||||
styleObj.glyphs = `${PROXY_PREFIX}/font/{fontstack}/{range}`;
|
||||
|
||||
// sprite 등록 (중복 방지)
|
||||
if (!hasSprite(map, ENC_SPRITE_ID)) {
|
||||
map.addSprite(ENC_SPRITE_ID, `${PROXY_PREFIX}/sprite/sprite`);
|
||||
}
|
||||
|
||||
// sources 등록 (타일 URL을 프록시로 치환)
|
||||
if (!map.getSource(GLOBE_SOURCE_ID)) {
|
||||
const globeSrc = style.sources['globe'];
|
||||
map.addSource(GLOBE_SOURCE_ID, {
|
||||
type: 'vector',
|
||||
tiles: [`${PROXY_PREFIX}/globe/{z}/{x}/{y}`],
|
||||
minzoom: globeSrc?.minzoom ?? 0,
|
||||
maxzoom: globeSrc?.maxzoom ?? 4,
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.getSource(ENC_SOURCE_ID)) {
|
||||
const encSrc = style.sources['enc'];
|
||||
map.addSource(ENC_SOURCE_ID, {
|
||||
type: 'vector',
|
||||
tiles: [`${PROXY_PREFIX}/{z}/{x}/{y}`],
|
||||
minzoom: encSrc?.minzoom ?? 4,
|
||||
maxzoom: encSrc?.maxzoom ?? 17,
|
||||
});
|
||||
}
|
||||
|
||||
// layers 등록 (background 포함 — ENC_EMPTY_STYLE 사용 시 배경 필요)
|
||||
const layerIds: string[] = [];
|
||||
for (const rawLayer of style.layers) {
|
||||
if (map.getLayer(rawLayer.id)) continue;
|
||||
|
||||
// 아이콘/패턴 참조에 sprite 프리픽스 부착
|
||||
const layer = prefixLayerIcons(rawLayer, iconNames);
|
||||
|
||||
// source 이름을 프록시 source ID로 매핑 (background 타입은 source 없음)
|
||||
const mappedSource = layer.source === 'globe' ? GLOBE_SOURCE_ID
|
||||
: layer.source === 'enc' ? ENC_SOURCE_ID
|
||||
: layer.source;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const layerSpec: any = {
|
||||
id: layer.id,
|
||||
type: layer.type,
|
||||
};
|
||||
if (mappedSource) layerSpec.source = mappedSource;
|
||||
if (layer['source-layer']) layerSpec['source-layer'] = layer['source-layer'];
|
||||
if (layer.filter) layerSpec.filter = layer.filter;
|
||||
if (layer.layout) layerSpec.layout = layer.layout;
|
||||
if (layer.paint) layerSpec.paint = layer.paint;
|
||||
if (layer.minzoom !== undefined) layerSpec.minzoom = layer.minzoom;
|
||||
if (layer.maxzoom !== undefined) layerSpec.maxzoom = layer.maxzoom;
|
||||
|
||||
map.addLayer(layerSpec as maplibregl.AddLayerObject);
|
||||
layerIds.push(layer.id);
|
||||
}
|
||||
|
||||
addedLayersRef.current = layerIds;
|
||||
sourcesAddedRef.current = true;
|
||||
} catch (err) {
|
||||
console.error('[S57EncOverlay] ENC 레이어 추가 실패:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function removeEncLayers(map: maplibregl.Map) {
|
||||
if (!sourcesAddedRef.current) return;
|
||||
|
||||
// 맵 스타일이 이미 파괴된 경우 (탭 전환 등 언마운트 시) ref만 정리
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!(map as any).style) {
|
||||
addedLayersRef.current = [];
|
||||
sourcesAddedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// layers 제거 (역순)
|
||||
for (const id of [...addedLayersRef.current].reverse()) {
|
||||
if (map.getLayer(id)) {
|
||||
map.removeLayer(id);
|
||||
}
|
||||
}
|
||||
addedLayersRef.current = [];
|
||||
|
||||
// sources 제거
|
||||
if (map.getSource(ENC_SOURCE_ID)) map.removeSource(ENC_SOURCE_ID);
|
||||
if (map.getSource(GLOBE_SOURCE_ID)) map.removeSource(GLOBE_SOURCE_ID);
|
||||
|
||||
// sprite 제거
|
||||
if (hasSprite(map, ENC_SPRITE_ID)) {
|
||||
map.removeSprite(ENC_SPRITE_ID);
|
||||
}
|
||||
|
||||
// glyphs URL 복원
|
||||
if (originalGlyphsRef.current !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(map.style as any).glyphs = originalGlyphsRef.current;
|
||||
}
|
||||
|
||||
sourcesAddedRef.current = false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -58,9 +58,13 @@ export const useMapStore = create<MapState>((set, get) => ({
|
||||
mapToggles: { s57: true, s101: false, threeD: false, satellite: false },
|
||||
mapTypes: DEFAULT_MAP_TYPES,
|
||||
toggleMap: (key) =>
|
||||
set((s) => ({
|
||||
mapToggles: { ...s.mapToggles, [key]: !s.mapToggles[key] },
|
||||
})),
|
||||
set((s) => {
|
||||
const isCurrentlyOn = s.mapToggles[key];
|
||||
const allOff: MapToggles = { s57: false, s101: false, threeD: false, satellite: false };
|
||||
return {
|
||||
mapToggles: isCurrentlyOn ? allOff : { ...allOff, [key]: true },
|
||||
};
|
||||
}),
|
||||
loadMapTypes: async () => {
|
||||
try {
|
||||
const res = await api.get<MapTypeItem[]>('/map-base/active')
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user