- ENC 타일 프록시 엔드포인트 추가 (style, sprite, font, globe, enc 벡터타일) - S57EncOverlay 컴포넌트 구현 (공식 style.json 기반 레이어 동적 추가/제거) - 맵 토글 라디오 버튼 방식으로 변경 (한 번에 하나만 활성화) - 언마운트 시 map.style 파괴 상태 안전 처리
261 lines
8.0 KiB
TypeScript
261 lines
8.0 KiB
TypeScript
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;
|
|
}
|