- [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'
  }
];