From 8fafaad6c0448bc4eac9ed34774113b665b52fcc Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 19 Feb 2026 19:14:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20=E2=80=94=20API=20Explorer?= =?UTF-8?q?=20=EC=A7=80=EB=8F=84=20=EC=8A=A4=EC=BA=90=ED=8F=B4=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MapLibre GL JS 5 지도 컨테이너 (Light/Dark 테마 자동 전환) - Sidebar 접기/펼치기 레이아웃 (320px 사이드바 + 전체 높이 지도) - API 유형 선택 UI (최근 위치 / 해구별 항적 / 선박별 항적) - gisApi 클라이언트 (V1/V2 REST API 인터페이스) - 지도 상수 (한반도 중심, 항적 색상, OpenFreeMap 타일) - i18n 한/영 explorer.* 키 12개 추가 - lazy loading: ApiExplorer 청크 분리 (gzip 278KB) Co-Authored-By: Claude Opus 4.6 --- frontend/package-lock.json | 273 +++++++++++++++++++ frontend/package.json | 1 + frontend/src/App.tsx | 3 +- frontend/src/api/gisApi.ts | 47 ++++ frontend/src/components/layout/Sidebar.tsx | 43 +++ frontend/src/components/map/MapContainer.tsx | 71 +++++ frontend/src/i18n/en.ts | 13 + frontend/src/i18n/ko.ts | 13 + frontend/src/pages/ApiExplorer.tsx | 105 +++++++ frontend/src/utils/constants.ts | 25 ++ 10 files changed, 593 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/gisApi.ts create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/map/MapContainer.tsx create mode 100644 frontend/src/pages/ApiExplorer.tsx create mode 100644 frontend/src/utils/constants.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 802a6af..9a43181 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "signal-batch-dashboard", "version": "0.0.0", "dependencies": { + "maplibre-gl": "^5.18.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.0", @@ -1022,6 +1023,115 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.4.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.4.1.tgz", + "integrity": "sha512-UKhA4qv1h30XT768ccSv5NjNCX+dgfoq2qlLVmKejspPcSQTYD4SrVucgqegmYcKcmwf06wcNAa/kRd0NHWbUg==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.6.tgz", + "integrity": "sha512-rgtY3x65lrrfXycLf6/T22ZnjTg5WgIOsptOIoCaMZy4O4UAKTyZlYY0h6v8le721pTptF94U65yMDQkug+URw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.2.1.tgz", + "integrity": "sha512-IxZBGq/+9cqf2qdWlFuQ+ZfoMhWpxDUGQZ/poPHOJBvwMUT1GuxLo6HgYTou+xxtsOsjfbcjI8PZaPCtmt97rA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@maplibre/geojson-vt": "^5.0.4", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1766,6 +1876,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1803,6 +1919,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", @@ -2528,6 +2653,12 @@ "csstype": "^3.0.2" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -2928,6 +3059,24 @@ "node": ">=6.9.0" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3127,6 +3276,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3140,6 +3295,12 @@ "node": ">=6" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3486,6 +3647,43 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/maplibre-gl": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.18.0.tgz", + "integrity": "sha512-UtWxPBpHuFvEkM+5FVfcFG9ZKEWZQI6+PZkvLErr8Zs5ux+O7/KQ3JjSUvAfOlMeMgd/77qlHpOw0yHL7JU5cw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^5.0.4", + "@maplibre/maplibre-gl-style-spec": "^24.4.1", + "@maplibre/mlt": "^1.1.6", + "@maplibre/vt-pbf": "^4.2.1", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3499,6 +3697,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3506,6 +3713,12 @@ "dev": true, "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3631,6 +3844,18 @@ "node": ">=8" } }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3680,6 +3905,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3707,6 +3938,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3717,6 +3954,12 @@ "node": ">=6" } }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -3865,6 +4108,15 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/rollup": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", @@ -3910,6 +4162,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3978,6 +4236,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4035,6 +4302,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index dd2474d..bf2b6e9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "maplibre-gl": "^5.18.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 44ec5c6..06ad9d5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ const Dashboard = lazy(() => import('./pages/Dashboard.tsx')) const JobMonitor = lazy(() => import('./pages/JobMonitor.tsx')) const DataPipeline = lazy(() => import('./pages/DataPipeline.tsx')) const AreaStats = lazy(() => import('./pages/AreaStats.tsx')) +const ApiExplorer = lazy(() => import('./pages/ApiExplorer.tsx')) const BASE_URL = import.meta.env.VITE_BASE_URL || '/signal-batch' @@ -51,7 +52,7 @@ export default function App() { }>} /> }>} /> }>} /> - {/* Phase 3+ 페이지 추가 예정 */} + }>} /> diff --git a/frontend/src/api/gisApi.ts b/frontend/src/api/gisApi.ts new file mode 100644 index 0000000..0ab2d98 --- /dev/null +++ b/frontend/src/api/gisApi.ts @@ -0,0 +1,47 @@ +import { fetchJson, postJson } from './httpClient.ts' + +export interface HaeguBoundary { + haegu_no: number + haegu_name: string + boundary_wkt: string + center_lon: number + center_lat: number +} + +export interface VesselTrackResult { + mmsi: string + nationalCode: string + shipKindCode: string + geometry: number[][] + timestamps: number[] + speeds: number[] +} + +export interface RecentPosition { + mmsi: string + lat: number + lon: number + sog: number + cog: number + lastUpdate: string + vesselName?: string + shipKindCode?: string +} + +export const gisApi = { + getHaeguBoundaries(): Promise { + return fetchJson('/api/v1/haegu/boundaries') + }, + + getHaeguTracks(haeguNo: number, hours = 24): Promise { + return fetchJson(`/api/v2/tracks/haegu/${haeguNo}?hours=${hours}`) + }, + + getVesselTracks(mmsiList: string[], startTime: string, endTime: string): Promise { + return postJson('/api/v2/tracks/vessels', { mmsiList, startTime, endTime }) + }, + + getRecentPositions(minutes = 10): Promise { + return fetchJson(`/api/v1/vessels/recent-positions?minutes=${minutes}`) + }, +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..65b4ea0 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,43 @@ +import { useState, type ReactNode } from 'react' + +interface SidebarProps { + children: ReactNode + defaultOpen?: boolean + width?: number +} + +export default function Sidebar({ children, defaultOpen = true, width = 320 }: SidebarProps) { + const [open, setOpen] = useState(defaultOpen) + + return ( + <> + {/* 사이드바 패널 */} +
+ {open &&
{children}
} +
+ + {/* 토글 버튼 */} + + + ) +} diff --git a/frontend/src/components/map/MapContainer.tsx b/frontend/src/components/map/MapContainer.tsx new file mode 100644 index 0000000..f2d4fe0 --- /dev/null +++ b/frontend/src/components/map/MapContainer.tsx @@ -0,0 +1,71 @@ +import { useRef, useEffect, useState } from 'react' +import maplibregl from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' +import { useTheme } from '../../hooks/useTheme.ts' +import { + MAP_CENTER, + MAP_DEFAULT_ZOOM, + MAP_MIN_ZOOM, + MAP_MAX_ZOOM, + MAP_STYLE_LIGHT, + MAP_STYLE_DARK, +} from '../../utils/constants.ts' + +interface MapContainerProps { + onMapReady?: (map: maplibregl.Map) => void + className?: string +} + +export default function MapContainer({ onMapReady, className = '' }: MapContainerProps) { + const containerRef = useRef(null) + const mapRef = useRef(null) + const { theme } = useTheme() + const [ready, setReady] = useState(false) + + useEffect(() => { + if (!containerRef.current) return + + const map = new maplibregl.Map({ + container: containerRef.current, + style: theme === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT, + center: [MAP_CENTER.lng, MAP_CENTER.lat], + zoom: MAP_DEFAULT_ZOOM, + minZoom: MAP_MIN_ZOOM, + maxZoom: MAP_MAX_ZOOM, + attributionControl: false, + }) + + map.addControl(new maplibregl.NavigationControl(), 'top-right') + map.addControl( + new maplibregl.AttributionControl({ compact: true }), + 'bottom-right', + ) + + map.on('load', () => { + mapRef.current = map + setReady(true) + onMapReady?.(map) + }) + + return () => { + mapRef.current = null + map.remove() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + /* 테마 변경 시 스타일 교체 */ + useEffect(() => { + if (!mapRef.current || !ready) return + const style = theme === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT + mapRef.current.setStyle(style) + }, [theme, ready]) + + return ( +
+ ) +} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 70c9d86..01bc665 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -118,6 +118,19 @@ const en = { 'area.stalePositions': 'Stale Positions', 'area.checkedAt': 'Checked at', + // API Explorer + 'explorer.title': 'API Explorer', + 'explorer.apiType': 'API Type', + 'explorer.recentPositions': 'Recent Positions', + 'explorer.haeguTracks': 'Area Tracks', + 'explorer.vesselTracks': 'Vessel Tracks', + 'explorer.parameters': 'Parameters', + 'explorer.positionsDesc': 'Fetches vessels with position updates within 10 minutes.', + 'explorer.haeguDesc': 'Fetches vessel tracks within a specific area as GeoJSON.', + 'explorer.vesselDesc': 'Fetches tracks for specific vessels by MMSI list.', + 'explorer.comingSoon': 'Detailed API Demo (Coming Soon)', + 'explorer.comingSoonDesc': 'Request/Response panels, track layers, replay', + // Time Range 'range.1d': '1D', 'range.3d': '3D', diff --git a/frontend/src/i18n/ko.ts b/frontend/src/i18n/ko.ts index 66f4e87..d9f79e1 100644 --- a/frontend/src/i18n/ko.ts +++ b/frontend/src/i18n/ko.ts @@ -118,6 +118,19 @@ const ko = { 'area.stalePositions': '갱신 지연 위치', 'area.checkedAt': '검증 시각', + // API Explorer + 'explorer.title': 'API 탐색기', + 'explorer.apiType': 'API 유형', + 'explorer.recentPositions': '최근 위치', + 'explorer.haeguTracks': '해구별 항적', + 'explorer.vesselTracks': '선박별 항적', + 'explorer.parameters': '파라미터', + 'explorer.positionsDesc': '최근 10분 이내 위치 업데이트된 선박 목록을 조회합니다.', + 'explorer.haeguDesc': '특정 해구 내 선박 항적을 GeoJSON 형태로 조회합니다.', + 'explorer.vesselDesc': 'MMSI 목록으로 특정 선박의 항적을 조회합니다.', + 'explorer.comingSoon': '상세 API 시연 (향후 구현)', + 'explorer.comingSoonDesc': 'Request/Response 패널, 항적 레이어, 리플레이', + // Time Range 'range.1d': '1일', 'range.3d': '3일', diff --git a/frontend/src/pages/ApiExplorer.tsx b/frontend/src/pages/ApiExplorer.tsx new file mode 100644 index 0000000..47d7999 --- /dev/null +++ b/frontend/src/pages/ApiExplorer.tsx @@ -0,0 +1,105 @@ +import { useState, useCallback } from 'react' +import { useI18n } from '../hooks/useI18n.ts' +import MapContainer from '../components/map/MapContainer.tsx' +import Sidebar from '../components/layout/Sidebar.tsx' +import type maplibregl from 'maplibre-gl' + +type ApiMode = 'haegu' | 'vessel' | 'positions' + +export default function ApiExplorer() { + const { t } = useI18n() + const [mode, setMode] = useState('positions') + const [, setMap] = useState(null) + + const handleMapReady = useCallback((m: maplibregl.Map) => { + setMap(m) + }, []) + + return ( +
+ {/* Sidebar */} +
+ +
+ {/* 제목 */} +

{t('explorer.title')}

+ + {/* API 유형 선택 */} +
+ +
+ {([ + { value: 'positions' as const, label: t('explorer.recentPositions') }, + { value: 'haegu' as const, label: t('explorer.haeguTracks') }, + { value: 'vessel' as const, label: t('explorer.vesselTracks') }, + ] as const).map(opt => ( + + ))} +
+
+ + {/* 파라미터 영역 */} +
+
+ {t('explorer.parameters')} +
+ + {mode === 'positions' && ( +
+

GET /api/v1/vessels/recent-positions

+

{t('explorer.positionsDesc')}

+
+ )} + + {mode === 'haegu' && ( +
+

GET /api/v2/tracks/haegu/:no

+

{t('explorer.haeguDesc')}

+
+ )} + + {mode === 'vessel' && ( +
+

POST /api/v2/tracks/vessels

+

{t('explorer.vesselDesc')}

+
+ )} +
+ + {/* 향후 구현 예정 안내 */} +
+
{t('explorer.comingSoon')}
+
+ {t('explorer.comingSoonDesc')} +
+
+
+
+
+ + {/* Map area */} +
+ + + {/* 모드 표시 오버레이 */} +
+ {mode === 'positions' && t('explorer.recentPositions')} + {mode === 'haegu' && t('explorer.haeguTracks')} + {mode === 'vessel' && t('explorer.vesselTracks')} +
+
+
+ ) +} diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts new file mode 100644 index 0000000..9120d81 --- /dev/null +++ b/frontend/src/utils/constants.ts @@ -0,0 +1,25 @@ +/** 지도 기본 설정 상수 */ + +/** 한반도 중심 좌표 */ +export const MAP_CENTER = { lng: 127.5, lat: 36.0 } as const + +/** 기본 줌 레벨 */ +export const MAP_DEFAULT_ZOOM = 5.5 + +/** 지도 최소/최대 줌 */ +export const MAP_MIN_ZOOM = 2 +export const MAP_MAX_ZOOM = 18 + +/** 항적 색상 (선박 유형별) */ +export const TRACK_COLORS = { + DEFAULT: '#3b82f6', + CARGO: '#10b981', + TANKER: '#f59e0b', + PASSENGER: '#8b5cf6', + FISHING: '#06b6d4', + ABNORMAL: '#ef4444', +} as const + +/** OpenFreeMap 타일 URL (무료, 키 불필요) */ +export const MAP_STYLE_LIGHT = 'https://tiles.openfreemap.org/styles/liberty' +export const MAP_STYLE_DARK = 'https://tiles.openfreemap.org/styles/dark'