- [Mapbox Vector Tiles Specification](<https://docs.mapbox.com/data/tilesets/guides/vector-tiles-standards/>)
- [Tippecanoe Documentation](<https://github.com/felt/tippecanoe>)
- [Mapbox GL JS Expressions](<https://docs.mapbox.com/mapbox-gl-js/style-spec/expressions/>)
#!/bin/bash
# Script to generate vector tiles from preprocessed GeoJSON files
# Uses tippecanoe to convert GeoJSON to Mapbox Vector Tiles (MVT)
#
# Usage:
# ./generate-vector-tiles.sh [app1] [app2] ...
# ./generate-vector-tiles.sh all
#
# Examples:
# ./generate-vector-tiles.sh web # Generate for web only
# ./generate-vector-tiles.sh web mobile # Generate for web and mobile
# ./generate-vector-tiles.sh all # Generate for all apps
set -e # Exit on error
# Change to project root
cd "$(dirname "$0")/../../../../.."
# SOURCE directory (preprocessed GeoJSON)
SOURCE_DIR="./packages/core/src/geojson/processed"
# Available apps
ALL_APPS=("web" "mobile" "solution" "admin")
# Parse arguments
TARGET_APPS=()
if [ $# -eq 0 ]; then
echo "❌ Error: No target apps specified"
echo ""
echo "Usage: $0 [app1] [app2] ... | all"
echo ""
echo "Available apps: ${ALL_APPS[*]}"
echo ""
echo "Examples:"
echo " $0 web # Generate for web only"
echo " $0 web mobile # Generate for web and mobile"
echo " $0 all # Generate for all apps"
exit 1
fi
if [ "$1" = "all" ]; then
TARGET_APPS=("${ALL_APPS[@]}")
echo "🎯 Target: All apps (${TARGET_APPS[*]})"
else
TARGET_APPS=("$@")
echo "🎯 Target: ${TARGET_APPS[*]}"
fi
# Validate target apps
for app in "${TARGET_APPS[@]}"; do
if [[ ! " ${ALL_APPS[*]} " =~ " ${app} " ]]; then
echo "❌ Error: Invalid app '$app'"
echo "Available apps: ${ALL_APPS[*]}"
exit 1
fi
done
# BASIS area files
BASIS_FILES=(
"$SOURCE_DIR/prohibitedArea.json"
"$SOURCE_DIR/restrictedArea.json"
"$SOURCE_DIR/controlArea.json"
"$SOURCE_DIR/aerodromeArea.json"
"$SOURCE_DIR/airFieldArea.json"
"$SOURCE_DIR/borderArea.json"
"$SOURCE_DIR/culturalHeritage.json"
"$SOURCE_DIR/nationalPark.json"
)
# CONTROL area files
CONTROL_FILES=(
"$SOURCE_DIR/flatDaegu.json"
"$SOURCE_DIR/flatGimpo.json"
"$SOURCE_DIR/flatIncheon.json"
"$SOURCE_DIR/flatJeju.json"
"$SOURCE_DIR/flatMuan.json"
"$SOURCE_DIR/flatSacheon.json"
"$SOURCE_DIR/flatUlsan.json"
"$SOURCE_DIR/flatYangyang.json"
"$SOURCE_DIR/flatYeosu.json"
)
echo ""
echo "🚀 Starting vector tile generation from preprocessed GeoJSON..."
echo ""
# Function to generate tiles for a specific app
generate_tiles_for_app() {
local app=$1
local basis_output="./apps/$app/public/tiles/basis"
local control_output="./apps/$app/public/tiles/control"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📦 Generating tiles for: $app"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Create output directories
mkdir -p "$basis_output"
mkdir -p "$control_output"
echo ""
echo "📋 Generating BASIS area tiles..."
echo " Output: $basis_output"
# Generate BASIS tiles
tippecanoe -zg \\
--drop-densest-as-needed \\
--no-tile-compression \\
--output-to-directory="$basis_output" \\
--force \\
"${BASIS_FILES[@]}"
if [ $? -eq 0 ]; then
echo "✅ BASIS tiles generated successfully!"
else
echo "❌ Error generating BASIS tiles for $app"
return 1
fi
echo ""
echo "📋 Generating CONTROL area tiles..."
echo " Output: $control_output"
# Generate CONTROL tiles
tippecanoe \\
-Z 6 \\
-z 16 \\
--no-simplification-of-shared-nodes \\
--no-feature-limit \\
--no-tile-size-limit \\
--no-tiny-polygon-reduction \\
--no-tile-compression \\
--output-to-directory="$control_output" \\
--force \\
"${CONTROL_FILES[@]}"
if [ $? -eq 0 ]; then
echo "✅ CONTROL tiles generated successfully!"
else
echo "❌ Error generating CONTROL tiles for $app"
return 1
fi
echo ""
echo "✅ Tiles for '$app' completed!"
echo ""
}
# Generate tiles for each target app
for app in "${TARGET_APPS[@]}"; do
generate_tiles_for_app "$app"
done
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🎉 All vector tiles generated successfully!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "📊 Summary:"
for app in "${TARGET_APPS[@]}"; do
echo ""
echo " 📦 $app:"
echo " BASIS: ./apps/$app/public/tiles/basis"
echo " CONTROL: ./apps/$app/public/tiles/control"
done
echo ""
echo "✅ Done!"
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// ES module에서 __dirname 대체
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 원본 typeConfig 로직 그대로 복원
const typeConfig = {
'0001': { color: '#FF3648' },
'0002': { color: '#FFA1AA' },
'0003': {
getProperties: pro => {
let color = '#ffc966';
let stroke = '#54797d';
let opacity = 0.3;
if (pro?.divCd) {
const divCdColors = {
원추밖: '#ffc966',
원추: '#a4edf5',
금지: '#FF3648',
공백: '#ffffff',
'50m': '#937ea3',
'60m': '#326ffc',
'70m': '#66d660'
};
color = divCdColors[pro.divCd] || color;
if (pro.divCd === '공백') opacity = 0.2;
}
if (pro?.isLine) opacity = 0;
return { color, stroke, opacity };
}
},
'0004': { color: '#A16A01' },
'0005': { color: '#AB40FF' },
'0006': { color: '#607D8B' },
'0007': { color: '#BFA77F' },
'0008': { color: '#40CF3B' }
};
const processGeoJSON = (inputPath, outputPath, typeValue) => {
console.log(`Processing: ${inputPath}`);
const geojson = JSON.parse(fs.readFileSync(inputPath, 'utf-8'));
const config = typeConfig[typeValue];
if (!geojson.features || !Array.isArray(geojson.features)) {
console.warn(`No features in ${inputPath}`);
return;
}
geojson.features = geojson.features.map(feature => {
const _config = config || typeConfig[feature.properties.type];
const styleProps = _config.getProperties
? _config.getProperties(feature.properties)
: {
color: _config.color,
...(_config.opacity && { opacity: _config.opacity })
};
return {
...feature,
properties: {
...feature.properties,
...styleProps
}
};
});
fs.writeFileSync(outputPath, JSON.stringify(geojson, null, 2));
console.log(`✅ Saved: ${outputPath}`);
};
// 디렉토리 생성
const processedDir = path.join(__dirname, '../../geojson/processed');
if (!fs.existsSync(processedDir)) {
fs.mkdirSync(processedDir, { recursive: true });
}
// BASIS 영역 처리
const basisFiles = [
{ name: 'prohibitedArea.json', type: '0001' },
{ name: 'restrictedArea.json', type: '0002' },
{ name: 'controlArea.json', type: '0002' },
{ name: 'aerodromeArea.json', type: '0004' },
{ name: 'airFieldArea.json', type: '0005' },
{ name: 'borderArea.json', type: '0006' },
{ name: 'culturalHeritage.json', type: '0007' },
{ name: 'nationalPark.json', type: '0008' }
];
console.log('🚀 Processing BASIS files...');
basisFiles.forEach(({ name, type }) => {
const inputPath = path.join(__dirname, '../../geojson/basis', name);
const outputPath = path.join(processedDir, name);
processGeoJSON(inputPath, outputPath, type);
});
// CONTROL 영역 처리 (divCd 기반 색상)
const controlFiles = [
'flatDaegu.json',
'flatGimpo.json',
'flatIncheon.json',
'flatJeju.json',
'flatMuan.json',
'flatSacheon.json',
'flatUlsan.json',
'flatYangyang.json',
'flatYeosu.json'
];
console.log('\\n🚀 Processing CONTROL files...');
controlFiles.forEach(name => {
const inputPath = path.join(__dirname, '../../geojson/control', name);
const outputPath = path.join(processedDir, name);
processGeoJSON(inputPath, outputPath, '0003');
});
console.log('\\n✅ All files processed successfully!');
import { useCallback, useEffect, useRef } from 'react';
import MapboxLanguage from '@mapbox/mapbox-gl-language';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import Cookies from 'js-cookie';
import {
BASIS_AREA_NM,
CONTROL_AREA_NM,
DARK_MAP_PATH,
MAPBOX_KEY,
MAP_STYLE,
reloadMap
} from '@kac-utm-fe/core';
import { useMapStore } from '@kac-utm-fe/hooks';
type Marker = mapboxgl.Marker & {
id: string;
type: string;
name: string;
};
interface Props {
id?: string;
isMobile?: boolean;
height?: string;
type?: string;
isAdmin?: boolean;
}
let linearAltdMarker: Marker[] = [];
export function BasisMap({
id,
isMobile,
height,
type,
isAdmin = false
}: Props) {
const mapRef = useRef(null);
const {
areas,
setMap,
addMap,
removeMap,
isDark,
setIsDark,
mapType,
setMapType
} = useMapStore();
const handleMapInit = useCallback(() => {
mapboxgl.accessToken = MAPBOX_KEY;
const _isDark = DARK_MAP_PATH.some(p =>
window?.location?.pathname?.includes(p)
);
const map = new mapboxgl.Map({
container: id ?? 'map',
style: type
? (MAP_STYLE as any)[type]
: isAdmin
? _isDark || isDark
? 'mapbox://styles/mapbox/dark-v11'
: 'mapbox://styles/mapbox/light-v11'
: _isDark && isDark
? 'mapbox://styles/mapbox/dark-v11'
: 'mapbox://styles/mapbox/light-v11',
antialias: true,
attributionControl: false,
localIdeographFontFamily: 'NotoSansKR',
center: [126.793899, 37.558839], // 서울 [126.978353, 37.566633]
zoom: 11
// minZoom: 6, // 추후 추가 필요 최대 줌 조절
// maxZoom: 18 // 추후 추가 필요 최대 줌 조절
});
// 지도 type
if (!isAdmin) setIsDark(_isDark);
setMapType(type ? (type as any) : 'normal');
// 언어
const locale = Cookies.get('NEXT_LOCALE') || 'ko';
const language = new MapboxLanguage({
defaultLanguage: locale === 'cn' ? 'zh-Hans' : locale
});
map.addControl(language);
// 초기값
map.setMaxPitch(0);
map.setBearing(0);
map.dragRotate.disable();
// 스타일 레이어 설정
map.once('load', () => {
addAirAreaLayer(map);
// 3d 빌딩 추가
const style = map.getStyle();
if (!style || !style.layers) return;
const labelLayerId = style.layers.find(
layer =>
layer.type === 'symbol' && layer.layout && layer.layout['text-field']
)?.id;
map.addLayer(
{
id: 'add-3d-buildings',
source: 'composite',
'source-layer': 'building',
filter: ['==', 'extrude', 'true'],
type: 'fill-extrusion',
minzoom: 15,
paint: {
'fill-extrusion-color': '#fff',
'fill-extrusion-height': [
'interpolate',
['linear'],
['zoom'],
15,
0,
15.05,
['get', 'height']
],
'fill-extrusion-base': [
'interpolate',
['linear'],
['zoom'],
15,
0,
15.05,
['get', 'min_height']
],
'fill-extrusion-opacity': 0.3
}
},
labelLayerId
);
// map.addLayer(
// {
// id: 'color-overlay',
// type: 'background',
// paint: {
// 'background-color': '#003366',
// 'background-opacity': 0.2
// }
// },
// 'water'
// ); // water 레이어 아래에 삽입
});
setMap(map);
addMap(id ?? 'map', map);
}, []);
useEffect(() => {
return () => {
setMap(undefined);
removeMap(id ?? 'map');
};
}, []);
useEffect(() => {
const getLinearMarker = () => {
const linearAltd: Marker[] = [];
OLS_ALTD.forEach(item => {
const el = document.createElement('div');
el.style.color = '#000000';
el.style.fontSize = '1rem';
el.innerText = item.altitude.toString();
const marker = new mapboxgl.Marker({
element: el
}).setLngLat([item.lng, item.lat]) as Marker;
marker.type = item.type;
marker.name = item?.name;
linearAltd.push(marker);
});
return linearAltd;
};
linearAltdMarker = getLinearMarker();
handleMapInit();
}, [handleMapInit]);
// 테마 변경 시 스타일만 변경 (기존 레이어 유지)
useEffect(() => {
const currentMap = useMapStore.getState().getMap(id ?? 'map');
const callback = () => {
if (mapType === 'normal') {
currentMap.setTerrain(null);
currentMap.setMaxPitch(0);
currentMap.setBearing(0);
currentMap.dragRotate.disable();
} else if (mapType === 'satellite') {
currentMap.setTerrain(null);
currentMap.setMaxPitch(85);
currentMap.dragRotate.enable();
} else if (mapType === 'terrain') {
currentMap.setTerrain({ source: 'mapbox-dem', exaggeration: 1 });
currentMap.setMaxPitch(85);
currentMap.dragRotate.enable();
}
};
const styleUrl =
MAP_STYLE[mapType === 'normal' && !isDark ? 'light' : mapType];
reloadMap(currentMap, styleUrl, callback);
}, [isDark, mapType, id]);
const addAirAreaLayer = async (map: mapboxgl.Map) => {
// Add vector tile sources
if (!map.getSource('basis-tiles-source')) {
map.addSource('basis-tiles-source', {
type: 'vector',
tiles: [`${window.location.origin}/tiles/basis/{z}/{x}/{y}.pbf`],
minzoom: 6,
maxzoom: 16
});
}
if (!map.getSource('control-tiles-source')) {
map.addSource('control-tiles-source', {
type: 'vector',
tiles: [`${window.location.origin}/tiles/control/{z}/{x}/{y}.pbf`],
minzoom: 6,
maxzoom: 16
});
}
const layerList = [
...BASIS_AREA_NM.map((item, idx) => ({
layer: item,
value: String(idx + 1).padStart(4, '0'),
type: 'vector',
source: 'basis-tiles-source'
})),
...CONTROL_AREA_NM.map(item => ({
layer: item,
value: '0003',
type: 'vector',
source: 'control-tiles-source'
})),
{ layer: 'altdLayer', value: '0000', type: 'marker', source: null }
];
layerList.forEach((item: any) => {
const { layer, value, type, source } = item;
// Remove existing layers
if (map.getLayer(layer)) map.removeLayer(layer);
if (map.getLayer(`${layer}-line`)) map.removeLayer(`${layer}-line`);
if (map.getLayer(`${layer}-pattern`)) map.removeLayer(`${layer}-pattern`);
// Handle marker layer
if (layer === 'altdLayer') {
linearAltdMarker.forEach(m => m.addTo(map));
return;
}
// Add vector tile layers
if (type === 'vector' && source) {
// Main fill layer - uses preprocessed color/stroke/opacity
map.addLayer({
id: layer,
type: 'fill',
source: source,
'source-layer': layer,
paint: {
'fill-color': ['get', 'color'],
'fill-outline-color': [
'coalesce',
['get', 'stroke'],
['get', 'color']
],
'fill-opacity': ['coalesce', ['get', 'opacity'], 0.5]
},
filter: ['==', '$type', 'Polygon']
});
// Line layer for isLine features
map.addLayer({
id: `${layer}-line`,
type: 'line',
source: source,
'source-layer': layer,
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': ['coalesce', ['get', 'lineColor'], '#555555'],
'line-width': ['coalesce', ['get', 'lineWidth'], 1],
'line-opacity': 0.5
},
filter: ['==', ['get', 'isLine'], true]
});
// Pattern layer for CONTROL areas (flatXXX)
if (layer.includes('flat')) {
const imageId = `${layer}-image`;
const layerId = `${layer}-pattern`;
registerPattern(map, layer).then(() => {
if (!map.getLayer(layerId)) {
map.addLayer({
id: layerId,
type: 'fill',
source: source,
'source-layer': layer,
paint: { 'fill-pattern': imageId },
filter: ['==', ['get', 'name'], '3km']
});
}
});
}
// Set visibility based on areas state
const visible = areas[`${value}`] ? 'visible' : 'none';
map.setLayoutProperty(layer, 'visibility', visible);
if (map.getLayer(`${layer}-line`))
map.setLayoutProperty(`${layer}-line`, 'visibility', visible);
if (map.getLayer(`${layer}-pattern`))
map.setLayoutProperty(`${layer}-pattern`, 'visibility', visible);
}
});
};
return (
<div
id={id ?? 'map'}
ref={mapRef}
className={`${isMobile ? 'w-100 h-100 rounded' : 'map-box'}`}
style={{
position: id === 'dashboard-map' ? 'relative' : 'absolute',
height: height ? height : undefined // height 값이 있으면 적용, 없으면 무시
}}
></div>
);
}
// 이미지 생성
const svnToPngWithoutBlob = (svgString: string): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image();
const svgBase64 = `data:image/svg+xml;base64,${btoa(svgString)}`;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('Canvas content 생성 실패'));
ctx.drawImage(img, 0, 0);
const pngDataUrl = canvas.toDataURL('image/png');
resolve(pngDataUrl);
};
img.onerror = () => reject(new Error('SVG 로드 실패'));
img.src = svgBase64;
});
};
// 지도에 이미지 등록
const registerPattern = async (
map: mapboxgl.Map,
layer: string
): Promise<void> => {
const dottedLayers = ['flatGimpo', 'flatIncheon'];
const imageId = `${layer}-image`;
const patternSvg = dottedLayers.includes(layer)
? SVG_PATTERN_DOTTED
: SVG_PATTERN;
const pngDataUrl = await svnToPngWithoutBlob(patternSvg);
return new Promise((resolve, reject) => {
map.loadImage(pngDataUrl, (error, image) => {
if (error || !image) return reject(error);
if (!map.hasImage(imageId)) {
map.addImage(imageId, image);
}
resolve();
});
});
};
const SVG_PATTERN = `
<svg xmlns="<http://www.w3.org/2000/svg>" width="10" height="10">
<line x1="0" y1="0" x2="10" y2="10" stroke="black" stroke-width="0.5" opacity="1" />
</svg>
`;
const SVG_PATTERN_DOTTED = `
<svg xmlns="<http://www.w3.org/2000/svg>" width="5" height="5">
<circle cx="5" cy="5" r="1.5" fill="black" opacity="0.7"/>
</svg>
`;
const OLS_ALTD = [
{
name: 'flatGimpo',
lng: 126.734619,
lat: 37.515349,
altitude: 120,
type: 'S'
},
{
name: 'flatGimpo',
lng: 126.852305,
lat: 37.601256,
altitude: 120,
type: 'S'
},
{
name: 'flatGimpo',
lng: 126.752454,
lat: 37.531588,
altitude: '45~100',
type: 'C'
},
{
name: 'flatGimpo',
lng: 126.83243,
lat: 37.585869,
altitude: '45~100',
type: 'C'
},
{
name: 'flatGimpo',
lng: 126.771088,
lat: 37.546521,
altitude: 0,
type: 'P'
},
{
name: 'flatGimpo',
lng: 126.811953,
lat: 37.569554,
altitude: 0,
type: 'P'
},
{
name: 'flatUlsan',
lng: 129.274036,
lat: 35.587881,
altitude: 150,
type: 'S'
},
{
name: 'flatUlsan',
lng: 129.429912,
lat: 35.595934,
altitude: 150,
type: 'S'
},
{
name: 'flatUlsan',
lng: 129.401967,
lat: 35.595025,
altitude: '45~100',
type: 'C'
},
{
name: 'flatUlsan',
lng: 129.301924,
lat: 35.589391,
altitude: '45~100',
type: 'C'
},
{
name: 'flatUlsan',
lng: 129.329111,
lat: 35.591185,
altitude: 0,
type: 'P'
},
{
name: 'flatUlsan',
lng: 129.374555,
lat: 35.594076,
altitude: 0,
type: 'P'
},
{
name: 'flatUlsan',
lng: 129.34585,
lat: 35.66366,
altitude: 60,
type: '60'
},
{ name: 'flatUlsan', lng: 129.3576, lat: 35.52326, altitude: 60, type: '60' },
{
name: 'flatJeju',
lng: 126.525784,
lat: 33.459273,
altitude: 100,
type: 'S'
},
{
name: 'flatJeju',
lng: 126.517175,
lat: 33.49329,
altitude: 60,
type: '60'
},
{
name: 'flatJeju',
lng: 126.47544,
lat: 33.482669,
altitude: 70,
type: '70'
},
{
name: 'flatJeju',
lng: 126.516857,
lat: 33.512528,
altitude: 50,
type: '0003'
},
{
name: 'flatJeju',
lng: 126.418334,
lat: 33.474358,
altitude: 70,
type: '0003'
},
{
name: 'flatJeju',
lng: 126.435493,
lat: 33.483659,
altitude: 60,
type: '50'
},
{
name: 'flatJeju',
lng: 126.531716,
lat: 33.508522,
altitude: 70,
type: '70'
}
];