feat(D 예정노선): D02 기준 지정 걷음 · 지점 마킹 = D03 노선분석 · 기준점 둘레 1 km 처음 받기 · 조용히 이어 받기 (PLAN 591-2 · 요구 452 · 453)
- D02 걷음 — 길 · 단계(PLAN 단계 둘 = 노선 분석 · 확정) · [>] · 대시보드 단계 점 · 서버 길 · 폴더 만들기 · 캐시 등록 · 글 · 가이드 폴더 - 등록 [등록] · 받은 예정노선 [보기] = D03 으로 곧바로 - D03 지점 = 긋기에 붙는 점 · 목록 칸(여섯 색 · 「지점 N」 · 🗑) · 핀 끌기 = 그 자리 노선 노드 같이 · [저장] 몸에 실음 · [초기화] = 선만 지우고 지점 남김 - 지도 우클릭 메뉴(부품 상자) — 여기서 긋기 시작(빈 땅 = 새 본선) · 갈래 시작 · 이어 긋기 · 지점 놓기 · 지점 지우기 · 긋기 끝 - 받기 — 처음 = 등록 기준점 둘레 ± 1 km(지점 · 선 없을 때) · 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 조용히 이어 받음(단추 · 막는 창 걷음 · 실패만 [다시 받기]) - 좌표계 = 파일 머리 · 없으면 기준점 경도 TM 띠 - 시험: 529_3(D02 지점 API) 지움 → 591_2 로 갈음 · 280_2 · 528 · 530_2 · 547_1 · 548 · 553_4 · 556_2 · 560_4 · 401 · 54_2 = D02 단계 · 페이지 걷음에 맞춤 · 590_1 · 530_2_analyze = 사업연도 · 기준점 한 번에 읽음 · 591_1 D04 씨앗 시험 = 590-1 이 씨앗 줄 걷음에 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -200,8 +200,7 @@ export const STATE_REGISTRY = {
|
||||
"route-draft-hold": { bucket: "draft", scope: "project" },
|
||||
/** D04 연도별 지정 업체 목록(531-3) — [저장] 때 PUT plan/assignees. */
|
||||
"d04-assignees": { bucket: "draft", scope: "project" },
|
||||
/* D02 · E 페이지 캐시(401) — 진입 · 떠남 비우기가 같은 표를 봄 · 옛 키는 한 번 옮김. */
|
||||
"d02-points": { bucket: "draft", scope: "project" },
|
||||
/* E 페이지 캐시(401) — 진입 · 떠남 비우기가 같은 표를 봄 · 옛 키는 한 번 옮김. */
|
||||
"e02-ground": { bucket: "draft", scope: "project" },
|
||||
"e03-prob": { bucket: "draft", scope: "project" },
|
||||
"e04-settings": { bucket: "draft", scope: "project" },
|
||||
|
||||
@@ -28,8 +28,8 @@ interface GroupFlow {
|
||||
const FLOWS: readonly GroupFlow[] = [
|
||||
{
|
||||
routes: PLAN_STEP_ROUTES,
|
||||
labels: ["DE_Step_PlanBase", "DE_Step_PlanAnalysis", "DE_Step_PlanConfirm"],
|
||||
icons: ["📍", "🧭", "✅"],
|
||||
labels: ["DE_Step_PlanAnalysis", "DE_Step_PlanConfirm"],
|
||||
icons: ["🧭", "✅"],
|
||||
},
|
||||
{
|
||||
routes: STAB_STEP_ROUTES,
|
||||
|
||||
@@ -20,7 +20,6 @@ export type KeyScope =
|
||||
| "drainage"
|
||||
| "section"
|
||||
| "plan"
|
||||
| "d02"
|
||||
| "b04"
|
||||
| "cad"
|
||||
| "b08"
|
||||
@@ -49,7 +48,6 @@ export const KEY_SCOPES: Record<KeyScope, { label: Text; page: string }> = {
|
||||
drainage: { label: ["B05 배수 · 3D", "B05 drainage · 3D"], page: "b05" },
|
||||
section: { label: ["B06 횡단", "B06 cross section"], page: "b05" },
|
||||
plan: { label: ["D03 노선분석", "D03 route analysis"], page: "plan" },
|
||||
d02: { label: ["D02 기준지정", "D02 base points"], page: "d02" },
|
||||
b04: { label: ["B04 지도 · 3D", "B04 map · 3D"], page: "b04" },
|
||||
cad: { label: ["CAD", "CAD"], page: "cad" },
|
||||
b08: { label: ["B08 수량 · 이정표", "B08 quantity · milestones"], page: "b08" },
|
||||
@@ -149,8 +147,8 @@ export const KEY_ACTIONS: readonly KeyAction[] = [
|
||||
A("plan.unvertex", "plan", ["꼭짓점 · 마커 오른쪽 누름"], ["지움", "Delete"], F),
|
||||
A("plan.measure", "plan", ["재기: 누름 · 누름"], ["시점 · 종점", "Start · end"], F),
|
||||
|
||||
A("d02.place", "d02", ["지도 누름"], ["지점 놓기", "Place point"], F),
|
||||
A("d02.drag", "d02", ["표식 끌기"], ["지점 옮김", "Move point"], F),
|
||||
A("plan.menu", "plan", ["오른쪽 누름"], ["지도 메뉴 — 긋기 시작 · 지점 놓기 · 갈래", "Map menu — draw · place point · branch"], F),
|
||||
A("plan.point", "plan", ["지점 핀 끌기"], ["지점 옮김(그 자리 노드 같이)", "Move point (with its node)"], F),
|
||||
|
||||
A("b04.zoom", "b04", ["휠"], ["지도 · 3D 확대 · 축소", "Map · 3D zoom"], F),
|
||||
A("b04.pan", "b04", [PAN], ["지도 · 3D 옮김", "Map · 3D pan"], F),
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
2. 시작 연도와 사업 지역을 넣음. [사업 연도 · 지역](D01/02_사업연도_지역) 참고.
|
||||
3. 프로젝트명을 확인함. [프로젝트명](D01/03_프로젝트명) 참고.
|
||||
4. [등록]을 누름. 문제가 있는 칸은 아래에 빨간 안내가 나옴. [입력 오류와 등록](D01/04_입력오류와_등록) 참고.
|
||||
5. 등록되면 바로 다음 화면인 [기준 지정](D02/00_설명서)이 열림.
|
||||
5. 등록되면 바로 다음 화면인 [노선 분석](D03/00_설명서)이 열리고 기준점 둘레 1 km 지형을 받음.
|
||||
|
||||
> 주의: 등록 직후에는 시점 · 종점이 비어 있음. 두 점을 정하고 저장해야 노선 분석 단계로 갈 수 있음.
|
||||
|
||||
|
||||
@@ -23,4 +23,4 @@
|
||||
- 카드 넷 = 예정노선 · 임도설계 · 사방설계 · 안정성평가. 사방설계는 「준비 중」 표시가 붙어 눌러도 열리지 않음.
|
||||
- 산림청이 아닌 계정은 「예정노선」 카드가 흐리게 보임. 마우스를 올리면 까닭이 말풍선으로 보이고, 눌러도 「예정노선은 산림청(시스템 관리자 · go.kr 메일)만 만들 수 있습니다.」 안내만 나옴.
|
||||
|
||||
> 참고: 이미 만든 예정노선은 대시보드 프로젝트 표에 「예정노선」 종류로 나옴. 표의 단계 막대(기준 지정 · 노선 분석 · 확정 · 내보내기)를 누르면 그 단계 화면으로 감.
|
||||
> 참고: 이미 만든 예정노선은 대시보드 프로젝트 표에 「예정노선」 종류로 나옴. 표의 단계 막대(노선 분석 · 확정 · 내보내기)를 누르면 그 단계 화면으로 감.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| --- | --- |
|
||||
| 1 | 칸을 모두 채운 뒤 [등록]을 누름. |
|
||||
| 2 | 문제가 있으면 그 칸 아래에 빨간 안내가 나오고 등록되지 않음. 고친 뒤 다시 [등록]. |
|
||||
| 3 | 문제가 없으면 프로젝트가 만들어지고 「프로젝트를 만들었습니다.」 알림과 함께 [기준 지정](D02/00_설명서) 화면이 열림. |
|
||||
| 3 | 문제가 없으면 프로젝트가 만들어지고 「프로젝트를 만들었습니다.」 알림과 함께 [노선 분석](D03/00_설명서) 화면이 열림. |
|
||||
| 4 | 등록하지 않고 나가려면 [취소]를 누름. 대시보드로 돌아감. |
|
||||
|
||||

|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
| 사업 연도 끝 | [노선 분석](D03/00_설명서)에서 구역마다 연도를 고르고 [저장]하면 가장 늦은 연도로 정해짐 |
|
||||
| 임도 종류 · 설계속도 | 받지 않음. [확정 · 내보내기](D04/00_설명서) 뒤 업체가 임도 프로젝트로 연결하면 임도설계 기본값(작업임도 · 20 km/h)으로 시작하고, 임도 프로젝트에서 바꿈 |
|
||||
| 노선 시작 · 종료 측점 | 받지 않음. 측점은 선마다 No.0 부터 새로 셈 |
|
||||
| 시점 · 종점 | [기준 지정](D02/00_설명서)에서 지도로 정함 |
|
||||
| 지점 | [노선 분석](D03/00_설명서) 지도 우클릭 「지점 놓기」 |
|
||||
|
||||
## 프로젝트명 자동 조합
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# 기준 지정
|
||||
|
||||
예정노선 1단계. 사업 전체의 시점과 종점, 두 점을 지도 위에 정하는 화면. 연도는 나누지 않음.
|
||||
|
||||
## 여는 길
|
||||
|
||||
- [예정노선 등록](D01/00_설명서)에서 [등록]을 누르면 바로 열림.
|
||||
- 대시보드 프로젝트 표에서 예정노선 줄의 단계 막대 「기준 지정」을 눌러도 열림.
|
||||
- 이미 만든 예정노선은 어느 단계 화면에서든 ☰ 에서 「기준 지정」을 골라 돌아옴. [단계 이동 메뉴](D02/06_단계_이동_메뉴) 참고.
|
||||
- 두 점 저장은 산림청만 할 수 있음. 지정받은 다른 회사는 보기만 가능함.
|
||||
|
||||

|
||||
|
||||
## 화면 구역
|
||||
|
||||
| 번호 | 구역 | 하는 일 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 「전체 시점 · 종점」 카드 | 시점 · 종점 두 줄. 📍 · 경도 · 위도 칸이 있음 |
|
||||
| 2 | [일반] [위성] 단추 | 바탕 지도를 바꿈. 고른 값은 기억됨 |
|
||||
| 3 | 지도 | 시점 · 종점 표지와 둘을 잇는 선이 보임. 지도를 눌러 점을 놓고, 표지를 끌어 옮김 |
|
||||
| 4 | 단추 줄 | [?] 가이드 · [<] 이전 · [>] 다음 · [저장] |
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. 화면이 열리면 시점과 종점 두 줄이 있음. 비어 있는 첫 줄이 놓을 점으로 강조됨. [시점 · 종점 표](D02/01_시점_종점_표) 참고.
|
||||
2. 지도에서 시점이 될 자리를 누름. 다음으로 종점 자리를 누름. [지도에서 지점 놓기](D02/02_지도에서_지점_놓기) 참고.
|
||||
3. 놓은 뒤 고치려면 표지를 끌어 옮기거나 경도 · 위도 칸에 값을 직접 넣음. [좌표 직접 입력](D02/03_좌표_직접_입력) 참고.
|
||||
4. 지형이 헷갈리면 [위성]으로 바꿔 봄. [일반 · 위성 지도](D02/04_일반_위성) 참고.
|
||||
5. [저장] 또는 [>]를 누름. [단추와 저장](D02/05_단추와_저장) 참고.
|
||||
6. 노선 분석 화면이 열리면 이 두 점 사이를 직접 그어 감. [노선 분석](D03/00_설명서) 참고.
|
||||
|
||||
> 주의: 서버에는 [저장]이나 [>]를 눌러 저장하거나, 다른 단계로 옮겨 갈 때 저장되어야 반영됨. 화면 안에서 점을 놓기만 해서는 서버에 남지 않음.
|
||||
|
||||
> 참고: 연도는 이 화면에서 나누지 않음. 노선 분석에서 그은 선에 구역을 나누고 구역마다 연도를 고름.
|
||||
@@ -1,32 +0,0 @@
|
||||
# 시점 · 종점 표
|
||||
|
||||
왼쪽 패널의 「전체 시점 · 종점」 카드. 점마다 줄 하나로 이름 · 📍 · 경도 · 위도를 보여 줌.
|
||||
|
||||
## 여는 길
|
||||
|
||||
- 기준 지정 화면 왼쪽 패널 맨 위의 「전체 시점 · 종점」 카드. 제목 오른쪽 ▼ 를 누르면 접고 펼침.
|
||||
|
||||

|
||||
|
||||
그림 번호 — 1 안내 글 · 2 시점 줄 · 3 📍 단추 · 4 경도 칸 · 5 위도 칸 · 6 저장 좌표계 줄
|
||||
|
||||
## 단계
|
||||
|
||||
| 단계 | 할 일 |
|
||||
| --- | --- |
|
||||
| 1 | 두 줄 중 「시점」이 노선 전체의 출발점, 「종점」이 도착점. |
|
||||
| 2 | 줄 왼쪽 띠로 지금 놓을 점인지 확인. 📍 를 누른 줄이 강조됨. |
|
||||
| 3 | 값을 고치는 방법은 [지도에서 지점 놓기](D02/02_지도에서_지점_놓기) · [좌표 직접 입력](D02/03_좌표_직접_입력) 참고. |
|
||||
|
||||
## 보이는 것
|
||||
|
||||
| 줄 이름 | 뜻 |
|
||||
| --- | --- |
|
||||
| 시점 | 노선 전체의 출발점. 노선 분석에서 첫 점을 이 자리에서 시작함 |
|
||||
| 종점 | 노선 전체의 도착점. 노선 분석에서 이 점 가까이 가면 선이 종점에 붙음 |
|
||||
|
||||
- 표 아래 한 줄 = 저장 상태. 저장 전이면 「아직 저장 안 함」, 저장했으면 「저장 좌표계 EPSG:번호」와 시점의 X · Y(m).
|
||||
- 산림청이 아닌 계정은 📍 단추가 안 보이고 칸이 잠겨 있음. 보기만 가능.
|
||||
- 옛 프로젝트처럼 두 점이 비어 있어도 연도별 지점이 있었다면 첫 연도 시점과 마지막 연도 종점을 이어 두 점으로 접어 보여 줌.
|
||||
|
||||
> 참고: 칸마다 뜻과 규칙은 [칸 뜻](D02/10_칸_뜻)에 있음. 좌표계가 정해지는 방법은 [쓰인 방법과 근거](D02/20_쓰인_방법과_근거) 참고.
|
||||
@@ -1,39 +0,0 @@
|
||||
# 지도에서 지점 놓기
|
||||
|
||||
지도를 눌러 시점 · 종점을 놓고, 놓은 표지를 끌어 옮기는 방법.
|
||||
|
||||
## 여는 길
|
||||
|
||||
- 기준 지정 화면 가운데 지도와 왼쪽 「전체 시점 · 종점」 카드의 📍 단추.
|
||||
|
||||

|
||||
|
||||
그림 번호 — 1 지도 위 안내 글
|
||||
|
||||
## 단계
|
||||
|
||||
| 단계 | 할 일 |
|
||||
| --- | --- |
|
||||
| 1 | 놓을 점을 고름. 화면이 열리면 비어 있는 첫 줄이 자동으로 골라져 강조됨. 다른 줄에 놓으려면 그 줄의 📍 를 누름. |
|
||||
| 2 | 지도에서 놓을 자리를 누름. 그 자리에 표지가 놓이고 표의 경도 · 위도가 채워짐. |
|
||||
| 3 | 놓고 나면 다음 빈 점이 자동으로 놓을 점이 됨. |
|
||||
| 4 | 놓은 뒤 고치려면 표지를 마우스로 끌어 옮김. 표의 값이 따라 바뀜. |
|
||||
| 5 | 지도는 빈 곳을 끌어 옮기고 휠로 확대 · 축소함. |
|
||||
|
||||

|
||||
|
||||
그림 번호 — 1 📍 단추 · 2 강조된 줄(놓을 점)
|
||||
|
||||

|
||||
|
||||
그림 번호 — 1 「지도에 놓을 지점 고르기」 안내
|
||||
|
||||
## 보이는 것
|
||||
|
||||
- 두 점이 모두 놓이면 둘 사이에 선이 이어짐. 표지 위에는 「시점」 · 「종점」 이름이 붙음.
|
||||
- 놓을 점을 고르지 않고 지도를 누르면 「지도에 놓을 지점 고르기」 안내가 나오고 아무것도 놓이지 않음.
|
||||
- 점이 하나도 없으면 우리나라 가운데쯤이 먼저 보임. 처음 점을 놓으면 그 점에 맞춰 보이고, 그 뒤로는 사용자가 맞춘 보기(중심 · 확대)를 그대로 둠.
|
||||
- 지도는 카카오맵을 씀.
|
||||
- 산림청이 아닌 계정은 지도를 눌러도 놓이지 않고 표지도 끌 수 없음. 지도 위 안내 글이 「보기 전용 — 지점은 산림청만 고칩니다.」로 바뀜.
|
||||
|
||||
> 주의: 놓은 점은 [저장]이나 [>]를 누르거나 다른 단계로 옮겨 가기 전까지 서버에 남지 않음. [단추와 저장](D02/05_단추와_저장) 참고.
|
||||
@@ -1,28 +0,0 @@
|
||||
# 좌표 직접 입력
|
||||
|
||||
다른 프로그램에서 구한 좌표를 표의 경도 · 위도 칸에 직접 넣는 방법.
|
||||
|
||||
## 여는 길
|
||||
|
||||
- 왼쪽 「전체 시점 · 종점」 카드의 줄마다 있는 경도 · 위도 두 칸.
|
||||
|
||||
## 단계
|
||||
|
||||
| 단계 | 할 일 |
|
||||
| --- | --- |
|
||||
| 1 | 고칠 줄의 경도 칸에 값을 넣음. |
|
||||
| 2 | 위도 칸에도 값을 넣고 칸 밖을 누르거나 Tab 키를 누름. |
|
||||
| 3 | 두 값이 범위 안이면 점이 옮겨지고 지도 표지가 따라 움직임. |
|
||||
| 4 | 범위 밖이면 두 칸이 빨갛게 표시되고 반영되지 않음. 값을 고쳐 다시 넣음. |
|
||||
|
||||

|
||||
|
||||
그림 번호 — 1 경도 칸(범위 밖 값 200을 넣은 모습) · 2 위도 칸
|
||||
|
||||
## 보이는 것
|
||||
|
||||
- 경도는 120 ~ 135, 위도는 30 ~ 45 범위만 받음.
|
||||
- 두 칸이 모두 범위 안일 때 반영됨. 한 칸만 넣으면 반영되지 않음.
|
||||
- 반영될 때 소수 7 자리(약 1 cm)로 반올림됨.
|
||||
|
||||
> 주의: 좌표는 경도 · 위도(WGS84)로 넣음. 평면좌표(X · Y)를 그대로 넣으면 범위 밖으로 처리됨.
|
||||
@@ -1,27 +0,0 @@
|
||||
# 일반 · 위성 지도
|
||||
|
||||
지도 오른쪽 위 [일반] [위성] 단추로 바탕 지도를 바꾸는 방법.
|
||||
|
||||
## 여는 길
|
||||
|
||||
- 기준 지정 화면 지도 오른쪽 위 두 칸짜리 단추.
|
||||
|
||||

|
||||
|
||||
그림 번호 — 1 [일반] [위성] 단추(위성을 고른 모습)
|
||||
|
||||
## 단계
|
||||
|
||||
| 단계 | 할 일 |
|
||||
| --- | --- |
|
||||
| 1 | [위성]을 누르면 위성 사진 위에 지명이 겹쳐 보이는 지도로 바뀜. |
|
||||
| 2 | 도로 · 하천 이름을 보고 싶으면 [일반]을 누름. |
|
||||
| 3 | 바꿔도 놓은 점 · 선은 그대로 있음. |
|
||||
|
||||
## 보이는 것
|
||||
|
||||
- 고른 값은 이 컴퓨터의 화면 취향으로 기억되어, 다음에 열어도 같은 지도로 열림.
|
||||
- 두 지도 모두 카카오맵이며, 위성은 지명이 함께 나오는 종류임.
|
||||
- 바탕 지도는 점을 놓는 데 도움을 주는 용도이며 저장되는 값과는 상관 없음.
|
||||
|
||||
> 참고: 노선 분석 화면의 바탕은 이 지도가 아니라 받은 위성 사진과 등고선임. [노선 분석](D03/00_설명서) 참고.
|
||||
@@ -1,31 +0,0 @@
|
||||
# 단추와 저장
|
||||
|
||||
왼쪽 패널 맨 아래 단추 줄 — [?] · [<] · [>] · [저장]과 저장 때 일어나는 일.
|
||||
|
||||
## 여는 길
|
||||
|
||||
- 기준 지정 화면 왼쪽 패널 맨 아래.
|
||||
|
||||

|
||||
|
||||
그림 번호 — 1 [?] 가이드 · 2 [<] 이전 단계(첫 단계라 흐림) · 3 [>] 다음 단계 · 4 [저장]
|
||||
|
||||
## 단계
|
||||
|
||||
| 단계 | 할 일 |
|
||||
| --- | --- |
|
||||
| 1 | 시점과 종점을 모두 채움. |
|
||||
| 2 | [저장]을 누름. 두 점이 서버에 저장되고 「지점을 저장했습니다.」 알림이 뜸. |
|
||||
| 3 | 바로 다음 단계로 가려면 [>]를 누름. 바뀐 것이 있으면 저장한 뒤 [노선 분석](D03/00_설명서) 화면이 열림. |
|
||||
| 4 | 저장하지 못하면(예: 점이 비어 있음) 이 화면에 머묾. |
|
||||
|
||||
## 보이는 것
|
||||
|
||||
- 저장하면 표 아래 한 줄이 「저장 좌표계 EPSG:번호」와 시점의 X · Y(m)로 바뀜.
|
||||
- 다른 단계로 옮길 때(단계 아이콘 · [<] · [>] · 머리 ☰ 메뉴 · 브라우저 뒤로) 고친 것이 있으면 묻지 않고 저장한 뒤 옮겨 감. 저장이 실패하면 옮기지 않고 머묾.
|
||||
- 산림청이 아닌 계정은 [저장]이 안 보이고, [>]는 저장 없이 그냥 다음 화면으로 감.
|
||||
- 이미 노선 분석에서 그은 선은 두 점을 옮겨 저장해도 지우지 않음.
|
||||
|
||||
> 주의: 두 점의 평균 경도가 126° 나 128° 선을 넘게 옮기면 저장 좌표계가 바뀜. 자세한 내용은 [쓰인 방법과 근거](D02/20_쓰인_방법과_근거) 참고.
|
||||
|
||||
> 참고: 연도 범위는 이 화면에서 다루지 않음. 노선 분석에서 구역마다 연도를 고름.
|
||||
@@ -1,27 +0,0 @@
|
||||
# 단계 이동 메뉴
|
||||
|
||||
☰ 로 진행 단계를 열어 대시보드나 다른 단계 화면으로 옮겨 가는 방법.
|
||||
|
||||
## 여는 길
|
||||
|
||||
- 화면 왼쪽 위의 ☰ 를 누름.
|
||||
|
||||

|
||||
|
||||
그림 번호 — 1 진행 단계 목록(대시보드 · 기준 지정 · 노선 분석 · 확정 · 내보내기)
|
||||
|
||||
## 단계
|
||||
|
||||
| 단계 | 할 일 |
|
||||
| --- | --- |
|
||||
| 1 | ☰ 를 누르면 왼쪽에 메뉴가 열림. |
|
||||
| 2 | 가야 할 단계를 누름. 지금 단계는 강조되어 있음. |
|
||||
| 3 | 메뉴 밖 어두운 곳을 누르면 닫힘. |
|
||||
|
||||
## 보이는 것
|
||||
|
||||
- 맨 위 「대시보드」 = 프로젝트 목록 화면으로 돌아감.
|
||||
- 단계는 기준 지정 → 노선 분석 → 확정 · 내보내기 순.
|
||||
- 메뉴 아래에는 홈페이지 바로가기 · 언어 · 테마 · 로그아웃이 같이 있음.
|
||||
|
||||
> 참고: 기준 지정에서 점을 고친 채 메뉴로 다른 단계에 가도 묻지 않고 저장한 뒤 옮겨 감. 저장이 실패하면 이 화면에 머묾.
|
||||
@@ -1,30 +0,0 @@
|
||||
# 칸 뜻
|
||||
|
||||

|
||||
|
||||
그림 번호는 [시점 · 종점 표](D02/01_시점_종점_표)와 같음.
|
||||
|
||||
## 왼쪽 「전체 시점 · 종점」 카드
|
||||
| 칸 | 뜻 | 규칙 |
|
||||
| --- | --- | --- |
|
||||
| 이름 | 「시점」 · 「종점」 | 고정. 줄은 두 개뿐 |
|
||||
| 📍 | 지도 누름이 놓을 점으로 고름 | 고른 줄이 강조됨 |
|
||||
| 경도 | 동서 위치(도) | 120 ~ 135 · 소수 가능 |
|
||||
| 위도 | 남북 위치(도) | 30 ~ 45 · 소수 가능 |
|
||||
|
||||
- 경도 · 위도 중 하나만 넣으면 반영되지 않음. 두 칸이 모두 범위 안일 때 반영됨.
|
||||
- 반영될 때 소수 7 자리(약 1 cm)로 반올림됨.
|
||||
- 범위 밖 값은 두 칸이 모두 빨갛게 표시되고 반영되지 않음.
|
||||
|
||||
## 지도 위
|
||||
- 표지 하나 = 점 하나. 마우스로 끌어 옮길 수 있음.
|
||||
- 시점과 종점이 모두 놓이면 둘을 잇는 선 하나가 보임. 노선 분석에서 그을 실제 선이 아니라 두 점 사이를 보여 주는 참고선임.
|
||||
- 점이 하나도 없으면 우리나라 가운데쯤이 먼저 보임. 점을 놓으면 처음 한 번만 그 점에 맞춰 보이고, 그 뒤로는 사용자가 맞춘 보기(중심 · 확대)를 그대로 둠.
|
||||
- 지도 위 안내문 = 「지도를 눌러 지점을 놓습니다.」
|
||||
- 오른쪽 위 [일반] [위성] = 바탕 지도 바꿈. [일반 · 위성 지도](D02/04_일반_위성) 참고.
|
||||
|
||||
## 표 아래 한 줄
|
||||
| 표시 | 뜻 |
|
||||
| --- | --- |
|
||||
| 아직 저장 안 함 | 서버에 두 점이 한 번도 저장되지 않음 |
|
||||
| 저장 좌표계 EPSG:번호 · X · Y | 저장된 좌표계와 시점의 평면좌표(m, 소수 3 자리) |
|
||||
@@ -1,31 +0,0 @@
|
||||
# 쓰인 방법과 근거
|
||||
|
||||
## 좌표계 처리
|
||||
- 사용자가 넣는 값은 모두 경도 · 위도(WGS84)임. 지도(카카오맵)도 이 값으로 그려짐.
|
||||
- 저장할 때 서버가 두 점의 **평균 경도**를 보고 한국 평면좌표(TM) 띠 하나를 고른 뒤, 두 점을 그 좌표계의 X(동쪽) · Y(북쪽) 미터 값으로 바꿔 저장함.
|
||||
- 노선 분석의 지형 받기 · 선 길이 · 기울기는 이 평면좌표를 씀. 사용자가 변환을 따로 할 필요는 없음.
|
||||
|
||||
| 평균 경도 | 좌표계 | 이름 |
|
||||
| --- | --- | --- |
|
||||
| 126° 미만 | EPSG:5185 | 서부 |
|
||||
| 126° 이상 128° 미만 | EPSG:5186 | 중부 |
|
||||
| 128° 이상 | EPSG:5187 | 동부 |
|
||||
|
||||
- 한 프로젝트는 항상 같은 좌표계 한 벌을 씀.
|
||||
- 동해(5188)는 쓰지 않음.
|
||||
- 내보내기 때의 좌표계(5179)는 확정 · 내보내기 단계에서 다룸. 이 화면에서는 쓰지 않음.
|
||||
|
||||
## 저장 규칙
|
||||
- 두 점은 프로젝트 한 칸(`plan_base_json`)에 한꺼번에 저장됨. 연도별로 나누어 저장하지 않음.
|
||||
- 저장은 산림청 계정만 가능함(시스템 관리자 또는 `.go.kr` 메일). 그 밖의 계정이 저장하면 「예정노선 지점은 산림청만 고칠 수 있습니다.」로 막힘.
|
||||
- 두 점을 옮겨도 이미 그은 선 · 갈래 · 구역은 지우지 않음.
|
||||
- 두 점이 비어 있는 옛 예정노선(연도별 지점으로 만든 것)은 첫 연도 시점과 마지막 연도 종점을 이어 두 점으로 보여 줌. 저장하면 이 두 점이 정본이 됨.
|
||||
|
||||
## 화면 이동 때 저장
|
||||
- 단계 아이콘 · [<] · [>] · 머리 ☰ 메뉴 · 브라우저 뒤로로 화면을 떠날 때 고친 것이 있으면 저장한 뒤 이동함. 저장이 실패하면 이동하지 않고 머묾.
|
||||
- 저장하지 않고 고친 값은 같은 브라우저 탭 안에서는 화면을 다시 열어도 남아 있음. 서버에 저장된 값이 그사이 바뀌었으면(노선 분석에서 시점을 고친 경우) 서버 값을 우선함.
|
||||
|
||||
## 한계
|
||||
- 좌표 입력은 경위도만 받음. 평면좌표(X · Y)를 직접 넣는 칸은 없음.
|
||||
- 지도 위 위치는 카카오맵 화면 기준이며, 정밀한 측량 값이 필요하면 좌표 입력으로 넣을 것.
|
||||
- 지형 받기와 선 긋기에 관한 설명은 [노선 분석](D03/00_설명서)을 볼 것.
|
||||
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 110 KiB |
@@ -1,11 +0,0 @@
|
||||
[
|
||||
{"name":"D02_00_overview","route":"#/d02-plan-base","project":"plan","before":[{"wait":".d02-points__row"},{"wait":".ui-kakao-map__box"},{"wait_ms":3500}],"settle_ms":1500,"clip":null,"callouts":[{"n":1,"sel":".d02-points"},{"n":2,"sel":".d02-map__type","pos":"bl"},{"n":3,"sel":".d02-map","pos":"br"},{"n":4,"sel":".ui-sidebar-actions","pos":"tr"}]},
|
||||
{"name":"D02_01_points_table","route":"#/d02-plan-base","project":"plan","before":[{"wait":".d02-points__row"},{"wait":".ui-kakao-map__box"},{"wait_ms":3500}],"settle_ms":1500,"clip":".d02-panel .ui-sidebar-card","callouts":[{"n":1,"sel":".d02-panel p.d02-info:first-of-type"},{"n":2,"sel":".d02-points__row:nth-child(1)","pos":"tr"},{"n":3,"sel":".d02-points__row:nth-child(1) button","pos":"bl"},{"n":4,"sel":".d02-points__row:nth-child(1) input[data-axis=lon]","pos":"br"},{"n":5,"sel":".d02-points__row:nth-child(1) input[data-axis=lat]","pos":"br"},{"n":6,"sel":".d02-points ~ p.d02-info"}]},
|
||||
{"name":"D02_02_map","route":"#/d02-plan-base","project":"plan","before":[{"wait":".d02-points__row"},{"wait":".ui-kakao-map__box"},{"wait_ms":3500}],"settle_ms":1500,"clip":".d02-map","callouts":[{"n":1,"sel":".ui-kakao-map__hint"}]},
|
||||
{"name":"D02_03_pick_point","route":"#/d02-plan-base","project":"plan","before":[{"wait":".d02-points__row"},{"wait":".ui-kakao-map__box"},{"wait_ms":3500},{"click":".d02-points__row:nth-child(2) button"},{"wait":".d02-points__row:nth-child(2).is-active"},{"wait_ms":300}],"settle_ms":1500,"clip":".d02-panel .ui-sidebar-card","callouts":[{"n":1,"sel":".d02-points__row:nth-child(2) button"},{"n":2,"sel":".d02-points__row:nth-child(2)"}]},
|
||||
{"name":"D02_04_map_toast","route":"#/d02-plan-base","project":"plan","before":[{"wait":".d02-points__row"},{"wait":".ui-kakao-map__box"},{"wait_ms":3500},{"click":".ui-kakao-map__box"},{"wait":"[class*=toast]"},{"wait_ms":400}],"settle_ms":1500,"clip":null,"callouts":[{"n":1,"sel":"[class*=toast]","pos":"tr"}]},
|
||||
{"name":"D02_05_coordinate_error","route":"#/d02-plan-base","project":"plan","before":[{"wait":".d02-points__row"},{"wait":".ui-kakao-map__box"},{"wait_ms":3500},{"fill":".d02-points__row:nth-child(1) input[data-axis=lon]","value":"200"},{"press":"Tab","sel":".d02-points__row:nth-child(1) input[data-axis=lon]"},{"wait":".d02-points__row:nth-child(1) input.is-error"},{"wait_ms":300}],"settle_ms":1500,"clip":".d02-panel .ui-sidebar-card","callouts":[{"n":1,"sel":".d02-points__row:nth-child(1) input[data-axis=lon]"},{"n":2,"sel":".d02-points__row:nth-child(1) input[data-axis=lat]"}]},
|
||||
{"name":"D02_06_buttons","route":"#/d02-plan-base","project":"plan","before":[{"wait":".d02-points__row"},{"wait":".ui-kakao-map__box"},{"wait_ms":3500}],"settle_ms":1500,"clip":".ui-sidebar-actions","callouts":[{"n":1,"sel":".ui-sidebar-actions > button:nth-child(1)"},{"n":2,"sel":".ui-sidebar-actions > button:nth-child(2)"},{"n":3,"sel":".ui-sidebar-actions > button:nth-child(3)"},{"n":4,"sel":".ui-sidebar-actions > button:nth-child(4)"}]},
|
||||
{"name":"D02_07_steps_menu","route":"#/d02-plan-base","project":"plan","before":[{"wait":".d02-points__row"},{"click":".app-burger"},{"wait":".app-drawer__steps .ui-workflow-layout__step"},{"wait_ms":700}],"settle_ms":1500,"clip":null,"callouts":[{"n":1,"sel":".app-drawer__steps","pos":"tr"}]},
|
||||
{"name":"D02_08_map_type","route":"#/d02-plan-base","project":"plan","before":[{"wait":".d02-points__row"},{"wait":".ui-kakao-map__box"},{"wait_ms":3500},{"click":".d02-map__type button:nth-child(2)"},{"wait_ms":3500}],"settle_ms":1500,"clip":".d02-map__type","callouts":[{"n":1,"sel":".d02-map__type","pos":"bl"}],"clip_pad":240}
|
||||
]
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
## 여는 길
|
||||
|
||||
- [기준 지정](D02/00_설명서) 화면에서 [>]를 누르면 두 점을 저장하고 이 화면이 열림.
|
||||
- [예정노선 등록](D01/00_설명서)에서 [등록]을 누르면 이 화면이 열림(옛 기준 지정 단계는 걷힘).
|
||||
- 대시보드 프로젝트 표의 단계 막대 「노선 분석」을 누르거나, ☰ 에서 「노선 분석」을 골라도 열림.
|
||||
- 시점 · 종점이 비어 있으면 「시점 · 종점이 비었습니다 — 1단계(기준 지정)에서 정하세요.」 안내만 나오고 진행이 안 됨.
|
||||
- 지점은 지도 우클릭 「지점 놓기」로 놓음 · 핀을 끌면 그 자리 노선 점도 같이 옮김 · 지점 없이 긋기 = 우클릭 「여기서 긋기 시작」.
|
||||
- 긋기 · 지형 받기 · 저장은 산림청만 할 수 있음. 그 밖의 계정은 「보기만 — 산림청 계정에서 긋습니다.」로 보기만 가능함.
|
||||
|
||||

|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
## 보이는 것
|
||||
|
||||
- 받을 범위 = 기준 지정의 시점 · 종점을 감싸는 상자에 여유를 더한 것. 여유 처음 값은 350 m 이고 300 ~ 5000 m 까지 쓸 수 있음. 이미 받은 범위가 있으면 그것과 합쳐서 받음.
|
||||
- 받을 범위 = 처음엔 등록 기준점 둘레 1 km · 그 뒤 지점 · 선 · 지도 보기가 받은 범위 밖으로 가면 조용히 이어 받음(지점 · 선을 감싸는 상자에 여유를 더한 것). 여유 처음 값은 350 m 이고 300 ~ 5000 m 까지 쓸 수 있음. 이미 받은 범위가 있으면 그것과 합쳐서 받음.
|
||||
- 요약 한 줄 = 「여유 350 m · 도엽 2매(새로 2) · 지형 칸 약 2.1백만」. 도엽은 수치지형도 조각이며 「새로」는 아직 못 받은 조각 수.
|
||||
- 받기에는 수치지형도(도엽)와 위성 사진이 들어 있음. 위성 사진을 못 받아도 받기가 멈추지 않음. 위성이 없으면 어두운 바탕에 시점 · 종점과 선만 보임.
|
||||
- 지형 받기는 서버 뒤에서 도는 일이라, 화면을 닫았다 다시 열어도 도는 중이면 이어서 진행률을 보여 줌.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
| 단계 | 할 일 |
|
||||
| --- | --- |
|
||||
| 1 | [본선 긋기]를 누름. 단추 글자가 [긋기 끝]으로 바뀌고 선 색이 짙어짐. 처음에는 기준 지정의 시점에서 시작함. |
|
||||
| 1 | [본선 긋기]를 누름. 단추 글자가 [긋기 끝]으로 바뀌고 선 색이 짙어짐. 지점 · 선 끝 · 선 위에서 시작함(빈 땅은 우클릭 「여기서 긋기 시작」). |
|
||||
| 2 | 마우스를 움직이면 마지막 점에서 마우스까지 점선이 따라오고, 옆에 길이(m)와 기울기(%)가 보임. |
|
||||
| 3 | 놓을 자리에서 누름. 그 자리에 점이 찍히고 선이 이어짐. 같은 방법으로 점을 이어 감. |
|
||||
| 4 | 종점 가까이 가서 누르면 종점에 붙으며 본선이 끝남. 아래 「보이는 것」 참고. |
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||

|
||||
|
||||
그림 번호 — 1 [?] 가이드 · 2 [<] 이전 단계(기준 지정) · 3 [>] 다음 단계(확정 · 내보내기) · 4 [저장] · 5 [초기화]
|
||||
그림 번호 — 1 [?] 가이드 · 2 [<] 이전 단계(첫 단계라 흐림) · 3 [>] 다음 단계(확정 · 내보내기) · 4 [저장] · 5 [초기화]
|
||||
|
||||
## 단계
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
- 저장하면 구역 연도로 연도별 노선이 만들어지고, 사업 연도 끝은 구역 연도 중 가장 늦은 해가 됨. 서버가 저장된 선으로 다시 셈하므로 화면 값을 그대로 받아 적지 않음.
|
||||
- 저장에 실패하면 「저장하지 못했습니다 — 까닭」이 뜨고 그은 것은 그대로 남음. 구역에 업체를 이미 지정했다면 그 구역을 없애는 저장은 막힘.
|
||||
- 단계 아이콘 · [<] · [>] · 머리 ☰ 메뉴 · 브라우저 뒤로로 떠날 때도 바뀐 것이 있으면 저장한 뒤 옮겨 감. 저장이 실패하면 이 화면에 머묾.
|
||||
- [초기화] 확인 창: 「그은 선 · 갈래 · 구역을 모두 지우고 시점 · 종점만 남깁니다. 저장된 노선도 바뀝니다.」 [초기화]를 누르면 시점 · 종점만 남은 빈 선으로 저장되고 「시점 · 종점만 남겼습니다.」 알림이 뜸. [취소]면 아무것도 바뀌지 않음.
|
||||
- [초기화] 확인 창: 「그은 선 · 갈래 · 구역을 모두 지웁니다(지점은 남김). 저장된 노선도 바뀝니다.」 [초기화]를 누르면 지점만 남은 빈 노선으로 저장되고 「선을 지웠습니다(지점은 남김).」 알림이 뜸. [취소]면 아무것도 바뀌지 않음.
|
||||
- 산림청이 아닌 계정은 [저장] · [초기화]가 보이지 않음.
|
||||
|
||||
> 주의: [초기화]는 서버에 저장된 노선까지 비움. 이미 업체를 지정한 구역이 있으면 막힐 수 있음.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 선을 만드는 방법
|
||||
|
||||
- 선은 자동으로 찾지 않고 사용자가 점을 찍어 직선으로 이음. 본선은 기준 지정의 시점에서 시작하고 종점에서 끝나며, 갈래는 기존 선의 점에서 갈라짐.
|
||||
- 선은 자동으로 찾지 않고 사용자가 점을 찍어 직선으로 이음. 본선은 지점 · 빈 땅에서 시작하고, 갈래는 기존 선의 점에서 갈라짐.
|
||||
- 구역 마크는 선 위 거리(m)로 기록됨. 마크 사이 구간이 구역(조각)이고, 구역마다 연도를 가짐. 구역 이름은 「선 이름 + 측점 범위」임.
|
||||
- 측점은 선마다 20 m 간격으로 0+0 부터 셈. 갈래도 0+0 부터 따로 셈.
|
||||
- 선의 높이는 저장하지 않고, 필요할 때 받은 지형에서 읽음.
|
||||
|
||||
@@ -22,7 +22,7 @@ from .B01_Dashboard_Summary import project_summaries
|
||||
router = APIRouter(prefix="/api/dashboard/overview", tags=["B01_Dashboard"])
|
||||
|
||||
#: 종류별 단계 수 — 화면 단계 막대(`group_workflow.ts` · `WORKFLOW_STEP_ROUTES`)와 같음
|
||||
STAGE_TOTAL = {"ROAD": 7, "PLAN": 3, "STABILITY": 6}
|
||||
STAGE_TOTAL = {"ROAD": 7, "PLAN": 2, "STABILITY": 6}
|
||||
#: 손대지 않은 날이 이만큼 넘으면 「오래 멈춤」
|
||||
STALE_DAYS = 14
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ THUMB_POINTS = 100
|
||||
# 단계 이름 = 화면 단계 막대 글(ui_template_locale_common · _de) 한국어 · 모르는 종류 = 임도
|
||||
STAGE_NAMES: dict[str, tuple[str, ...]] = {
|
||||
"ROAD": ("파일입력", "전처리", "노선설계", "횡단설계", "상세설계", "수량산출", "원가계산"),
|
||||
"PLAN": ("기준 지정", "노선 분석", "확정 · 내보내기"),
|
||||
"PLAN": ("노선 분석", "확정 · 내보내기"),
|
||||
"STABILITY": ("지반 조건", "2D 해석", "3D 해석", "암반 해석", "광역 위험", "보고서"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_View_Assigned.ts
|
||||
* 세부 페이지 「받은 예정노선」(549-1) — 산림청이 우리 회사에 지정한 예정노선 연도 표.
|
||||
* 0 건이어도 메뉴 · 페이지 보임 · 줄 끝 ⋯ = 보기(D02) · 연결(모달)
|
||||
* 0 건이어도 메뉴 · 페이지 보임 · 줄 끝 ⋯ = 보기(D03) · 연결(모달)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
@@ -43,7 +43,7 @@ function stateCell(plan: AssignedPlan): HTMLElement {
|
||||
}
|
||||
|
||||
function planRow(plan: AssignedPlan): Cell[] {
|
||||
const view = (): void => void goToWorkflowStage(plan.project_id, ROUTES.D02_PLAN_BASE);
|
||||
const view = (): void => void goToWorkflowStage(plan.project_id, ROUTES.D03_PLAN_ANALYSIS);
|
||||
return [
|
||||
nameCell(plan.project_name),
|
||||
plan.piece_label ? `${plan.year} · ${plan.piece_label}` : String(plan.year),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* · 사업연도 끝 · 임도 종류 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본) · 설계속도 없음(591-1).
|
||||
* 기준점(591-1 · 요구 452 · 453) = 카카오 지도(공용 editMap · 마우스 설정 따름) 누름 · 끌기로 한 점 →
|
||||
* GET /api/map/address(VWorld · 출처 표기) → 주소 글 · 사업 지역 「시군구 읍면」 채움(손으로 고친 뒤엔 안 덮음)
|
||||
* [등록] = POST /api/d01/project(site = 경위도 · 주소는 서버가 다시 찾음) → 현재 프로젝트 · D02 로.
|
||||
* [등록] = POST /api/d01/project(site = 경위도 · 주소는 서버가 다시 찾음) → 현재 프로젝트 · D03 으로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
|
||||
@@ -155,7 +155,7 @@ export function renderD01PlanRegister(root: HTMLElement): void {
|
||||
region: region.input.value.trim(),
|
||||
site: { lon: at[0], lat: at[1] },
|
||||
},
|
||||
ROUTES.D02_PLAN_BASE,
|
||||
ROUTES.D03_PLAN_ANALYSIS,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
"""D 예정노선 1단계 기준 지정 — 지점 목록 API
|
||||
(요구서 389 3장 1 · 440 1 · 445 A2 · 452 · PLAN 560-2 · 579-1 · 585-1 · 590-1).
|
||||
|
||||
- `GET /api/projects/{id}/plan/points` — {epsg, points: [점]} · 점 = {lon, lat, x, y}
|
||||
- `PUT …/plan/points` — {points: [{lon, lat}]} → TM 셈 → 설계 내용 파일 `points`
|
||||
(`D03_PlanAnalysis_Store` · 좌표계 = 파일 머리 · 없으면 점 평균 경도의 TM 띠)
|
||||
- 시점 · 종점 구분 없음 — 옛 꼴(DB `plan_base_json` · 연도 행 접기)은 저장 모듈의 옮김 몫.
|
||||
- 고침 = 산림청만(지정 회사 GET 은 공용 판정 `require_project_access` 몫).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from common_util.common_util_auth import is_forest_service, require_company
|
||||
from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon, tm_to_lonlat
|
||||
from common_util.common_util_save_guard import project_lock
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["D02 Plan Base"])
|
||||
|
||||
MAX_POINTS = 40 # 지점 상한(옛 시점 20 + 종점 20)
|
||||
|
||||
|
||||
class LonLat(BaseModel):
|
||||
lon: float = Field(..., ge=120, le=135)
|
||||
lat: float = Field(..., ge=30, le=45)
|
||||
|
||||
|
||||
class BaseRequest(BaseModel):
|
||||
points: list[LonLat] = Field(default_factory=list, max_length=MAX_POINTS)
|
||||
|
||||
|
||||
def _point(x: Any, y: Any, epsg: Any) -> dict[str, float] | None:
|
||||
if x is None or y is None or epsg is None:
|
||||
return None
|
||||
lon, lat = tm_to_lonlat(float(x), float(y), int(epsg))
|
||||
return {"lon": lon, "lat": lat, "x": float(x), "y": float(y)}
|
||||
|
||||
|
||||
def base_view(plan: dict[str, Any]) -> dict[str, Any]:
|
||||
"""{epsg, points} — 설계 내용의 지점(TM) + 경위도 · 지점이 없으면 epsg None."""
|
||||
epsg = plan.get("epsg")
|
||||
points = [p for p in (_point(i.get("x"), i.get("y"), epsg) for i in plan["points"]) if p]
|
||||
return {"epsg": epsg if points else None, "points": points}
|
||||
|
||||
|
||||
async def load_base(project_id: UUID) -> dict[str, Any]:
|
||||
"""지점 목록 — D03 이 읽는 길."""
|
||||
_, plan = await store.load_plan(project_id)
|
||||
return base_view(plan)
|
||||
|
||||
|
||||
def stored_base(payload: BaseRequest, epsg: int | None = None) -> dict[str, Any]:
|
||||
"""PUT 몸 → 파일 꼴(TM m) — 좌표계 = `epsg`(저장된 값) · 없으면 점 평균 경도의 TM 띠."""
|
||||
given = payload.points
|
||||
if not given:
|
||||
epsg = None
|
||||
elif epsg is None:
|
||||
epsg = tm_epsg_for_lon(sum(p.lon for p in given) / len(given))
|
||||
points = [dict(zip(("x", "y"), lonlat_to_tm(p.lon, p.lat, epsg))) for p in given]
|
||||
return {"epsg": epsg, "points": points}
|
||||
|
||||
|
||||
@router.get("/{project_id}/plan/points", response_model=None)
|
||||
async def get_plan_points(project_id: UUID) -> dict[str, Any]:
|
||||
return {"status": "success", **await load_base(project_id)}
|
||||
|
||||
|
||||
@router.put("/{project_id}/plan/points", response_model=None)
|
||||
async def put_plan_points(
|
||||
project_id: UUID,
|
||||
payload: BaseRequest,
|
||||
session: dict[str, Any] = Depends(require_company),
|
||||
) -> dict[str, Any]:
|
||||
if not is_forest_service(session):
|
||||
raise HTTPException(status_code=403, detail="예정노선 지점은 산림청만 고칠 수 있습니다.")
|
||||
async with project_lock(str(project_id)):
|
||||
root, plan = await store.load_plan(project_id) # 없음 · 예정노선 아님 거절
|
||||
base = stored_base(payload, plan["epsg"])
|
||||
epsg = plan["epsg"] if plan["lines"] else base["epsg"] # 선이 있으면 좌표계 그대로
|
||||
plan = {**plan, "epsg": epsg, "points": base["points"]}
|
||||
store.save_plan(root, plan)
|
||||
return {"status": "success", **base_view(plan)}
|
||||
@@ -1,314 +0,0 @@
|
||||
/* =============================================================================
|
||||
* D02_PlanBase_UI_Page.ts
|
||||
* D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · 440 1 · 445 A2 · PLAN 560-2 · 579-2 · 585-1) — 메인 = 카카오맵 ·
|
||||
* 좌 = 지점 목록 하나(최소 한 줄 · 줄마다 🗑 · [+ 지점] · 지점마다 차례 색 `pointColor` = 노선분석 핀과 같음) ·
|
||||
* 점 잇는 선 없음.
|
||||
* 조작(끌기 · 지도 누름 · 경위도 입력) = 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈)
|
||||
* · 떠남(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) = 바뀐 것 있으면 공용 물음 [취소][폐기][저장](572-3)
|
||||
* · 자동저장 없음(CLAUDE.md 5장) · 캐시는 그때 서버 값(base)이 지금과 같을 때만 씀(D03 이 고친 값 우선).
|
||||
* 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · 떠남 = 그냥.
|
||||
* 캐시 = 등록표 `d02-points`(프로젝트 칸 · 떠남 비우기에 걸림).
|
||||
* 첫 불러오기가 실패해도 지도 · 표는 세움(빈 화면 없음).
|
||||
* 지도 [일반][위성](560-3) = 카카오 HYBRID · 고른 값 = 화면 취향(`aislo:pref:`) 기억.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./D02_PlanBase_UI_Style.css";
|
||||
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
|
||||
import {
|
||||
createButton,
|
||||
createIconButton,
|
||||
createInputField,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { editMap, type MapPin, type MapType } from "@ui/ui_template_kakao_map";
|
||||
import { pointColor } from "@ui/ui_template_palette";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { t } from "@ui/ui_template_locale";
|
||||
import {
|
||||
clearState,
|
||||
readByKey,
|
||||
readState,
|
||||
writeByKey,
|
||||
writeState,
|
||||
} from "../A00_Common/b_page_state";
|
||||
import { currentProjectId } from "../A00_Common/b_project_switch";
|
||||
import { mountGroupStep } from "../A00_Common/group_workflow";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
|
||||
interface LonLat {
|
||||
lon: number;
|
||||
lat: number;
|
||||
}
|
||||
interface ServerPoint extends LonLat {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
interface PointsView {
|
||||
epsg: number | null;
|
||||
points: ServerPoint[];
|
||||
}
|
||||
type Points = Array<LonLat | null>;
|
||||
|
||||
const KOREA_CENTER: [number, number] = [127.8, 36.3];
|
||||
const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm
|
||||
const PREF_MAP_TYPE = "aislo:pref:d02.map.type";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
|
||||
if (!response.ok)
|
||||
throw new Error(typeof data.detail === "string" ? data.detail : `HTTP ${response.status}`);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/** 서버 목록 → 표 줄(빈 목록 = 빈 줄 하나) */
|
||||
function boundaryPoints(view: PointsView | null): Points {
|
||||
const list = view?.points ?? [];
|
||||
return list.length
|
||||
? list.map((p) => ({ lon: +p.lon.toFixed(DIGITS), lat: +p.lat.toFixed(DIGITS) }))
|
||||
: [null];
|
||||
}
|
||||
|
||||
export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
const projectId = currentProjectId();
|
||||
const mapHost = el("div", { className: "d02-map" });
|
||||
const table = el("div", { className: "d02-points" });
|
||||
const info = createText("", "muted");
|
||||
info.classList.add("d02-info");
|
||||
const card = createPanelCard(t("D02_Points_Title"), false);
|
||||
const pointsHint = createText(t("D02_Points_Hint"), "muted");
|
||||
pointsHint.classList.add("d02-info");
|
||||
card.append(pointsHint, table, info);
|
||||
const save = createButton({
|
||||
label: t("D02_Save"),
|
||||
variant: "filled",
|
||||
onClick: () => void store(),
|
||||
});
|
||||
const actions = el("div", { className: "ui-sidebar-actions", children: [save] });
|
||||
const left = el("div", { className: "ui-sidebar-panel d02-panel", children: [card, actions] });
|
||||
mountGroupStep(root, ROUTES.D02_PLAN_BASE, {
|
||||
mainContent: mapHost,
|
||||
leftPanel: left,
|
||||
dirty: () => editable && dirty(),
|
||||
save: () => store(),
|
||||
discard: () => clearState("d02-points", projectId),
|
||||
});
|
||||
|
||||
let view: PointsView | null = null;
|
||||
let points: Points = boundaryPoints(null);
|
||||
let active: number | null = null; // 지도 누름이 놓을 지점 차례
|
||||
let editable = false; // 산림청만 고침
|
||||
|
||||
const pointName = (i: number): string => `${t("D02_Point")} ${i + 1}`;
|
||||
const firstEmpty = (): number | null => {
|
||||
const i = points.findIndex((p) => !p);
|
||||
return i >= 0 ? i : null;
|
||||
};
|
||||
const serverPoints = (): Points => boundaryPoints(view);
|
||||
const dirty = (): boolean => JSON.stringify(points) !== JSON.stringify(serverPoints());
|
||||
const remember = (): void =>
|
||||
writeState("d02-points", { base: serverPoints(), points }, projectId);
|
||||
|
||||
const map = editMap({
|
||||
hint: t("D02_Map_Hint"),
|
||||
center: () => KOREA_CENTER,
|
||||
onMove: (key, lon, lat) => place(Number(key), { lon, lat }),
|
||||
onClick: (lon, lat) => {
|
||||
if (!editable) return;
|
||||
if (active === null) return showToast(t("D02_Pick_Point"), "info");
|
||||
place(active, { lon, lat });
|
||||
},
|
||||
});
|
||||
const mapType = (readByKey(PREF_MAP_TYPE) === "HYBRID" ? "HYBRID" : "ROADMAP") as MapType;
|
||||
map.setType(mapType);
|
||||
const typePick = createSegment({
|
||||
options: [
|
||||
{ value: "ROADMAP", label: t("D02_Map_Normal") },
|
||||
{ value: "HYBRID", label: t("D02_Map_Satellite") },
|
||||
],
|
||||
value: mapType,
|
||||
ariaLabel: t("D02_Map_Type"),
|
||||
onChange: (value) => {
|
||||
map.setType(value as MapType);
|
||||
writeByKey(PREF_MAP_TYPE, value);
|
||||
},
|
||||
});
|
||||
typePick.root.classList.add("d02-map__type");
|
||||
mapHost.append(map.root, typePick.root);
|
||||
|
||||
function place(i: number, at: LonLat): void {
|
||||
points[i] = { lon: +at.lon.toFixed(DIGITS), lat: +at.lat.toFixed(DIGITS) };
|
||||
if (i === active) active = firstEmpty(); // 놓은 지점 = 다음 빈 지점으로
|
||||
remember();
|
||||
paint(String(i));
|
||||
}
|
||||
|
||||
/** [+ 지점] — 빈 줄을 더하고 지도 누름이 거기 놓음. */
|
||||
function addRow(): void {
|
||||
points.push(null);
|
||||
active = points.length - 1;
|
||||
remember();
|
||||
paint();
|
||||
}
|
||||
|
||||
/** 🗑 — 줄을 지움(마지막 한 줄이면 비움 · 최소 한 줄 · 뒤 번호 · 색이 한 칸씩 당겨짐). */
|
||||
function removeRow(i: number): void {
|
||||
if (points.length > 1) points.splice(i, 1);
|
||||
else points[0] = null;
|
||||
active = firstEmpty();
|
||||
remember();
|
||||
paint();
|
||||
}
|
||||
|
||||
/** focus = 고친 지점(보기 밖이면 그리로) · 빈 값 = 보기 그대로 · 점 잇는 선 없음(요구 440 1). */
|
||||
function drawMap(focus = ""): void {
|
||||
const pins: MapPin[] = points.flatMap((p, i) =>
|
||||
p
|
||||
? [
|
||||
{
|
||||
key: String(i),
|
||||
name: pointName(i),
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
movable: editable,
|
||||
color: pointColor(i),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
map.set(pins, [], undefined, focus);
|
||||
}
|
||||
|
||||
function pointRow(p: LonLat | null, i: number): HTMLElement {
|
||||
const lon = createInputField({ type: "number", placeholder: t("D02_Lon"), size: 24 }).input;
|
||||
const lat = createInputField({ type: "number", placeholder: t("D02_Lat"), size: 24 }).input;
|
||||
lon.classList.add("d02-points__input");
|
||||
lat.classList.add("d02-points__input");
|
||||
lon.step = lat.step = "any";
|
||||
lon.value = p ? String(p.lon) : "";
|
||||
lat.value = p ? String(p.lat) : "";
|
||||
lon.disabled = lat.disabled = !editable;
|
||||
lon.dataset.point = lat.dataset.point = String(i);
|
||||
lon.dataset.axis = "lon";
|
||||
lat.dataset.axis = "lat";
|
||||
const typed = (): void => {
|
||||
const at = { lon: Number.parseFloat(lon.value), lat: Number.parseFloat(lat.value) };
|
||||
const ok = at.lon >= 120 && at.lon <= 135 && at.lat >= 30 && at.lat <= 45;
|
||||
for (const box of [lon, lat]) {
|
||||
box.classList.toggle("is-error", !ok);
|
||||
if (ok) box.removeAttribute("aria-invalid");
|
||||
else box.setAttribute("aria-invalid", "true");
|
||||
}
|
||||
if (ok) place(i, at);
|
||||
};
|
||||
lon.addEventListener("change", typed);
|
||||
lat.addEventListener("change", typed);
|
||||
const pick = createButton({
|
||||
label: "📍",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
active = i;
|
||||
drawTable();
|
||||
},
|
||||
});
|
||||
pick.title = pick.ariaLabel = t("D02_Pick_Point");
|
||||
pick.hidden = !editable;
|
||||
const drop = createIconButton({
|
||||
icon: "trash",
|
||||
title: t("D02_Remove_Point"),
|
||||
size: 24,
|
||||
onClick: () => removeRow(i),
|
||||
});
|
||||
drop.hidden = !editable;
|
||||
const row = el("div", {
|
||||
className: `d02-points__row${active === i ? " is-active" : ""}`,
|
||||
children: [
|
||||
el("span", { className: "d02-points__name", text: pointName(i) }),
|
||||
pick,
|
||||
lon,
|
||||
lat,
|
||||
drop,
|
||||
],
|
||||
});
|
||||
row.style.borderLeftColor = pointColor(i);
|
||||
return row;
|
||||
}
|
||||
|
||||
function drawTable(): void {
|
||||
const add = createButton({
|
||||
label: t("D02_Add_Point"),
|
||||
iconStart: "plus",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => addRow(),
|
||||
});
|
||||
add.hidden = !editable;
|
||||
table.replaceChildren(...points.map((p, i) => pointRow(p, i)), el("div", { children: [add] }));
|
||||
info.textContent = view?.epsg
|
||||
? `${t("D02_Point")} ${view.points.length} · ${t("D02_Saved_Crs")} EPSG:${view.epsg}`
|
||||
: t("D02_Not_Saved");
|
||||
}
|
||||
|
||||
function paint(focus = ""): void {
|
||||
drawTable();
|
||||
drawMap(focus);
|
||||
}
|
||||
|
||||
function adopt(next: PointsView): void {
|
||||
view = next;
|
||||
points = boundaryPoints(next);
|
||||
}
|
||||
|
||||
async function store(): Promise<boolean> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const body = { points: points.filter(Boolean) };
|
||||
adopt(
|
||||
await request<PointsView>(`/projects/${projectId}/plan/points`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
clearState("d02-points", projectId);
|
||||
active = firstEmpty();
|
||||
paint();
|
||||
showToast(t("D02_Saved"), "success");
|
||||
return true;
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error), "error");
|
||||
return false;
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
editable = !!(await fetchSessionUser())?.is_forest_service;
|
||||
save.hidden = !editable;
|
||||
if (!editable) {
|
||||
const mapHint = map.root.querySelector<HTMLElement>("[data-kakao-map-hint]");
|
||||
pointsHint.textContent = t("D02_View_Only");
|
||||
if (mapHint) mapHint.textContent = t("D02_View_Only");
|
||||
}
|
||||
adopt(await request<PointsView>(`/projects/${projectId}/plan/points`));
|
||||
const cached = readState<{ base?: Points; points?: Points }>("d02-points", projectId);
|
||||
const sameBase = JSON.stringify(cached?.base) === JSON.stringify(points);
|
||||
// 옛 캐시(시점 · 종점 두 목록)는 꼴이 달라 버림
|
||||
if (editable && sameBase && Array.isArray(cached?.points) && cached.points.length)
|
||||
points = cached.points;
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error), "error");
|
||||
}
|
||||
active = firstEmpty();
|
||||
paint();
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/* D02 기준 지정(529-3 · 560-2 · 579-2 · 585-1) — 메인 지도 = 본문 높이 가득 · 좌 지점 목록(줄 왼 띠 = 지점 색) */
|
||||
.d02-map {
|
||||
position: relative;
|
||||
height: calc(100dvh - var(--app-header-h));
|
||||
}
|
||||
|
||||
/* 560-3 바탕 [일반][위성] — 지도 위 오른쪽 위 */
|
||||
.d02-map__type {
|
||||
position: absolute;
|
||||
top: var(--spacing-8);
|
||||
right: var(--spacing-8);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.d02-map .ui-kakao-map,
|
||||
.d02-map .ui-kakao-map__box {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.d02-points {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.d02-points__row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr 1fr auto;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 4px 6px;
|
||||
border-left: 4px solid var(--draw-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.d02-points__row.is-active {
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
|
||||
.d02-points__name {
|
||||
grid-column: 1 / -1;
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.d02-points__input {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/* =============================================================================
|
||||
* D02_PlanBase_Route.ts
|
||||
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = D02 기준 지정(529-3)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import type { DraftRoute } from "../../A00_Common/router";
|
||||
|
||||
export const ROUTE: DraftRoute = {
|
||||
path: ROUTES.D02_PLAN_BASE,
|
||||
workflow: true,
|
||||
project: true,
|
||||
footerless: true,
|
||||
load: async () => (await import("../D02_PlanBase_UI_Page")).renderD02PlanBase,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_Api.ts
|
||||
* D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 ·
|
||||
* 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 응답에 D02 시점 · 종점) ·
|
||||
* 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 지점 · 기준점 함께 591-2) ·
|
||||
* 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림 · 581 — 음영기복 · 보호구역 · 띠 · 산림입지도).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -19,10 +19,12 @@ export interface BasePoint {
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** D02 정본 — 지점 목록 하나(작업 좌표계 m + 경위도 · 585-1). */
|
||||
/** 지점(긋기에 붙는 점 · 작업 좌표계 m + 경위도) · 등록 기준점(처음 받는 기준 · 591-2) ·
|
||||
* epsg = 파일 머리 · 없으면 기준점 경도 TM 띠. */
|
||||
export interface PlanBaseView {
|
||||
epsg: number | null;
|
||||
points: BasePoint[];
|
||||
site: { lon: number; lat: number; address: string | null } | null;
|
||||
}
|
||||
|
||||
export interface PlanJob {
|
||||
@@ -48,13 +50,14 @@ export interface TerrainExtent {
|
||||
/** 그은 선 서버 꼴(562-2) — 마크 = 조각 경계 · 조각 없는 구역 = 연도 없음. */
|
||||
export interface ServerNetwork {
|
||||
epsg: number;
|
||||
/** 지점(PUT 몸만 · 빈 값 = 저장된 지점 그대로). */
|
||||
points?: Vertex[];
|
||||
lines: PlanLine[];
|
||||
marks: Array<{ line: string; at_m: number }>;
|
||||
pieces: PlanPiece[];
|
||||
}
|
||||
|
||||
export interface PlanNetworkView {
|
||||
/** D02 정본 전체 시점 · 종점. */
|
||||
base: PlanBaseView;
|
||||
network: ServerNetwork | null;
|
||||
derived: {
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
길이 · 측점 · 높이는 저장하지 않음(늘 다시 셈 · 높이는 지형에서). 측점 = 선마다 No.0 부터.
|
||||
조각 = 선의 마크 사이 구간(구역 × 갈래) · 업체 지정은 조각 단위(`plan_route_assignees.piece_id`).
|
||||
지점 목록(`base`) = 같은 파일의 `points`(D02 `base_view`).
|
||||
지점(`points` · 긋기에 붙는 점) = 같은 파일 · [저장] 몸에 실림(빈 값 = 그대로) ·
|
||||
기준점(`projects.site_*`) = 처음 자료 받는 기준일 뿐(노선 · 붙음 무관 · PLAN 591-2).
|
||||
계산 자리 = ① 파이썬·TS 짝(`D03_PlanAnalysis_Draw`) — 화면은 그리는 즉시 · [저장]은 여기서 다시 셈.
|
||||
"""
|
||||
|
||||
@@ -24,9 +25,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from common_util.common_util_auth import is_forest_service, require_company
|
||||
from common_util.common_util_crs import tm_epsg_for_lon, tm_to_lonlat
|
||||
from common_util.common_util_save_guard import project_lock
|
||||
from config.config_db import get_db_pool
|
||||
from D02_PlanBase import D02_PlanBase_Router as plan_base
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Draw as draw
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
|
||||
|
||||
@@ -36,6 +37,7 @@ ID = r"^[A-Za-z0-9_-]{1,64}$"
|
||||
SAME_M = 0.001 # 1 mm 안 = 같은 점 · 같은 마크
|
||||
PIECE_TOL_M = 0.01 # 조각 끝 = 마크 경계(1 cm)
|
||||
STATION_M = 20.0 # 측점 간격(지식DB 설계제원_총괄)
|
||||
MAX_POINTS = 40 # 지점 상한(옛 기준 지정 시점 20 + 종점 20)
|
||||
|
||||
|
||||
class Branch(BaseModel):
|
||||
@@ -64,6 +66,8 @@ class Piece(BaseModel):
|
||||
|
||||
class Network(BaseModel):
|
||||
epsg: int
|
||||
# 지점(작업 좌표계 m) — 빈 값(None) = 저장된 지점 그대로
|
||||
points: list[tuple[float, float]] | None = Field(default=None, max_length=MAX_POINTS)
|
||||
lines: list[Line] = []
|
||||
marks: list[Mark] = []
|
||||
pieces: list[Piece] = []
|
||||
@@ -73,6 +77,11 @@ class NetworkError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _check_points(points: list[tuple[float, float]] | None) -> None:
|
||||
if points is not None and not all(math.isfinite(v) for p in points for v in p):
|
||||
raise HTTPException(status_code=400, detail="지점 좌표가 수가 아닙니다.")
|
||||
|
||||
|
||||
def _check(ok: bool, message: str) -> None:
|
||||
if not ok:
|
||||
raise NetworkError(message)
|
||||
@@ -249,30 +258,61 @@ def legacy_network(rows: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
}
|
||||
|
||||
|
||||
def _view(plan: dict[str, Any], year_start: int | None) -> dict[str, Any]:
|
||||
def work_epsg(plan: dict[str, Any], site: dict[str, Any] | None) -> int | None:
|
||||
"""작업 좌표계 — 파일 머리 · 없으면 기준점 경도의 TM 띠(첫 [저장] · 지형 받기 때 굳음)."""
|
||||
if plan.get("epsg") is not None:
|
||||
return int(plan["epsg"])
|
||||
return tm_epsg_for_lon(site["lon"]) if site else None
|
||||
|
||||
|
||||
def base_view(plan: dict[str, Any], site: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""{epsg, points: [{lon, lat, x, y}], site} — 지점(TM) + 경위도 · 기준점(경위도 · 주소)."""
|
||||
epsg = work_epsg(plan, site)
|
||||
points = [
|
||||
{"lon": lon, "lat": lat, "x": float(p["x"]), "y": float(p["y"])}
|
||||
for p in plan["points"]
|
||||
if epsg is not None
|
||||
for lon, lat in [tm_to_lonlat(float(p["x"]), float(p["y"]), epsg)]
|
||||
]
|
||||
return {"epsg": epsg, "points": points, "site": site}
|
||||
|
||||
|
||||
def _view(plan: dict[str, Any], project: dict[str, Any]) -> dict[str, Any]:
|
||||
network = {key: plan[key] for key in ("epsg", "lines", "marks", "pieces")}
|
||||
return {
|
||||
"status": "success",
|
||||
"base": plan_base.base_view(plan),
|
||||
"year_start": year_start,
|
||||
"base": base_view(plan, project["site"]),
|
||||
"year_start": project["year_start"],
|
||||
"network": network if plan["lines"] else None,
|
||||
"derived": derive(network) if plan["lines"] else None,
|
||||
"legacy": False,
|
||||
}
|
||||
|
||||
|
||||
async def _year_start(project_id: UUID) -> int | None:
|
||||
async def project_info(project_id: UUID) -> dict[str, Any]:
|
||||
"""{year_start, site: {lon, lat, address} | None} — 사업연도 시작 · 등록 기준점(591-1)."""
|
||||
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute("SELECT project_year FROM projects WHERE id = %s", (str(project_id),))
|
||||
await cursor.execute(
|
||||
"SELECT project_year, site_lon, site_lat, site_address FROM projects WHERE id = %s",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
if not row:
|
||||
return {"year_start": None, "site": None}
|
||||
year, lon, lat, address = row
|
||||
site = (
|
||||
{"lon": float(lon), "lat": float(lat), "address": address}
|
||||
if lon is not None and lat is not None
|
||||
else None
|
||||
)
|
||||
return {"year_start": year, "site": site}
|
||||
|
||||
|
||||
@router.get("/{project_id}/plan/network", response_model=None)
|
||||
async def get_plan_network(project_id: UUID) -> dict[str, Any]:
|
||||
_, plan = await store.load_plan(project_id)
|
||||
try:
|
||||
return _view(plan, await _year_start(project_id))
|
||||
return _view(plan, await project_info(project_id))
|
||||
except NetworkError as error:
|
||||
raise HTTPException(status_code=409, detail=f"저장된 노선이 틀립니다 — {error}") from error
|
||||
|
||||
@@ -327,16 +367,20 @@ async def put_plan_network(
|
||||
if not is_forest_service(session):
|
||||
raise HTTPException(status_code=403, detail="예정노선은 산림청만 고칠 수 있습니다.")
|
||||
network = payload.model_dump()
|
||||
points = network.pop("points")
|
||||
async with project_lock(str(project_id)):
|
||||
root, plan = await store.load_plan(project_id) # 없음 404 · 예정노선 아님 400
|
||||
if plan["epsg"] is not None and plan["epsg"] != network["epsg"]:
|
||||
raise HTTPException(status_code=400, detail="좌표계가 기준 지정과 다릅니다.")
|
||||
year_start = await _year_start(project_id)
|
||||
raise HTTPException(status_code=400, detail="좌표계가 저장된 노선과 다릅니다.")
|
||||
project = await project_info(project_id)
|
||||
try:
|
||||
derived = derive(network, year_start)
|
||||
derived = derive(network, project["year_start"])
|
||||
except NetworkError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
_check_points(points)
|
||||
plan = {**plan, **network}
|
||||
if points is not None:
|
||||
plan["points"] = [{"x": float(x), "y": float(y)} for x, y in points]
|
||||
async with get_db_pool().acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
@@ -348,4 +392,4 @@ async def put_plan_network(
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return {**_view(plan, year_start), "derived": derived}
|
||||
return {**_view(plan, project), "derived": derived}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
`…/forest-roads`(GET · POST 올리기) · `…/landslide-zones` — 지도 겹
|
||||
(PLAN 569-1 · 581-1 · 581-2 · 584-1 · 584-3)
|
||||
|
||||
범위 = 지점(D02 정본 `load_base`) + 그은 점(`extra`) 상자 + 여유.
|
||||
범위 = 기준점 둘레(`SITE_RADIUS_M`) + 지점 + 그은 점(`extra` · 지도 보기 귀) 상자 + 여유 ∪ 받은 범위
|
||||
(PLAN 591-2 · 기준점 = 처음 받는 기준일 뿐).
|
||||
노선 정본(손 그은 선)은
|
||||
`D03_PlanAnalysis_Network`(PLAN 562-2 · 옛 추천 엔진 · 꺾임점 R 걷음).
|
||||
"""
|
||||
@@ -27,14 +28,16 @@ from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFil
|
||||
from pydantic import BaseModel
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from common_util.common_util_crs import lonlat_to_tm
|
||||
from common_util.common_util_http_cache import cached_file_response
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
from D02_PlanBase import D02_PlanBase_Router as base
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_ForestRoad as forest_road
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Landslide as landslide
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_LandslideZone as landslide_zone
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Network as network
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero_order
|
||||
|
||||
@@ -55,16 +58,18 @@ class TerrainBody(BaseModel):
|
||||
|
||||
|
||||
async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, float]]]:
|
||||
"""(실경로, 좌표계, [지점들]) — D02 정본(빈 칸이면 옛 연도 행 접음)."""
|
||||
plan = await base.load_base(project_id)
|
||||
if not plan["points"]:
|
||||
raise HTTPException(status_code=400, detail="지점이 없습니다 — 1단계에서 지정하세요.")
|
||||
async with get_db_pool().acquire() as connection:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
if not stored:
|
||||
raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.")
|
||||
"""(실경로, 좌표계, [지점들]) — 설계 내용 파일 · 지점 · 선이 없으면 등록 기준점 둘레 두 귀."""
|
||||
root, plan = await store.load_plan(project_id) # 없음 404 · 예정노선 아님 400
|
||||
site = (await network.project_info(project_id))["site"]
|
||||
epsg = network.work_epsg(plan, site)
|
||||
if epsg is None:
|
||||
raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.")
|
||||
ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]]
|
||||
return Path(resolve_stored_project_path(stored)), int(plan["epsg"]), ends
|
||||
if site and not ends and not plan["lines"]: # 처음 = 기준점 둘레 · 그 뒤는 받은 범위가 덮음
|
||||
ends = terrain.site_corners(*lonlat_to_tm(site["lon"], site["lat"], epsg))
|
||||
if not ends:
|
||||
raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.")
|
||||
return root, epsg, ends
|
||||
|
||||
|
||||
def _query_xy(extra: str | None) -> list[tuple[float, float]]:
|
||||
|
||||
@@ -50,6 +50,8 @@ logger = logging.getLogger(__name__)
|
||||
# 여유 처음 값 = 옛 연도 구간 DEM 폭(탐색 복도 300 m + 횡단 표본 50 m) · 바닥 = B 절취 여유
|
||||
DEFAULT_MARGIN_M = 350.0
|
||||
MAX_MARGIN_M = 5000.0
|
||||
# 처음 받는 범위 = 등록 기준점 둘레 ± 1 km(요구 453 · PLAN 591-2)
|
||||
SITE_RADIUS_M = 1000.0
|
||||
STAGE = Path("B04_PreProcess")
|
||||
PLAN_MODELS = "models_plan"
|
||||
TERRAIN_META = "plan_terrain.json"
|
||||
@@ -82,6 +84,12 @@ def received_box(project_root: Path) -> Box | None:
|
||||
return x0, y0, x1, y1
|
||||
|
||||
|
||||
def site_corners(x: float, y: float) -> list[XY]:
|
||||
"""기준점 둘레 상자 두 귀 — 처음 여유를 더하면 ± `SITE_RADIUS_M`."""
|
||||
r = SITE_RADIUS_M - DEFAULT_MARGIN_M
|
||||
return [(x - r, y - r), (x + r, y + r)]
|
||||
|
||||
|
||||
def wanted_box(points: list[XY], margin_m: float, have: Box | None) -> Box:
|
||||
"""받을 범위 = 점 상자 + 여유 ∪ 받은 범위."""
|
||||
xs, ys = [p[0] for p in points], [p[1] for p in points]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* D03_PlanAnalysis_UI_Draw.ts
|
||||
* D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 580 · 요구서 389 3장 8~10 · 440 3~7) — 점을 찍어 직선으로 이음.
|
||||
*
|
||||
* 우클릭 메뉴(591-2 · `D03_PlanAnalysis_UI_Points`) = 이 자리에서 시작 — 빈 땅도 새 본선(지점 없이 긋기).
|
||||
* [긋기] 하나 — 첫 누름 자리로 시작: 지점 = 새 본선(측점 No.0 = 긋기 시작한 지점 · 반대는 [방향 바꾸기]) ·
|
||||
* 선이 이미 놓인 지점 = 그 점의 갈래 · 선 꼬리 = 이어 긋기 · 갈래 없는 선 머리 = 뒤집어 이어 긋고 끝낼 때
|
||||
* 되돌림 · 선 위 · 꺾임점 = 갈래. 다른 지점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 —
|
||||
@@ -57,6 +58,7 @@ import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */
|
||||
export const DRAW_DEFAULT_LENGTH_M = 20;
|
||||
export const DRAW_DEFAULT_SLOPE_PCT = 9;
|
||||
const START_HINT = "지점 · 선 끝 · 선 위를 눌러 시작합니다 — 빈 땅은 우클릭 「여기서 긋기 시작」.";
|
||||
|
||||
export type DrawConstraint = "free" | "length" | "slope" | "both";
|
||||
export type DrawMode = "idle" | "draw";
|
||||
@@ -74,7 +76,7 @@ export interface DrawPreview {
|
||||
}
|
||||
|
||||
export interface PlanDrawParams {
|
||||
/** 기준 지점(D02 지점 목록 · 585-1). */
|
||||
/** 지점(긋기에 붙는 점 · 591-2). */
|
||||
base: () => Vertex[];
|
||||
network: () => PlanNetwork;
|
||||
heightAt: () => (x: number, y: number) => number;
|
||||
@@ -89,6 +91,9 @@ export interface PlanDraw {
|
||||
panel: HTMLElement;
|
||||
mode: () => DrawMode;
|
||||
setMode: (mode: DrawMode) => void;
|
||||
/** 우클릭 메뉴(591-2) — 이 자리에서 긋기를 시작하면 무엇이 되나 · 시작(빈 땅도 새 본선). */
|
||||
aim: (px: number, py: number, map: ScreenMap) => "tip" | "point" | "line" | "empty";
|
||||
startAt: (px: number, py: number, map: ScreenMap) => boolean;
|
||||
setEditable: (editable: boolean) => void;
|
||||
/** 키 — 받았으면 참(Enter · Esc · Backspace). */
|
||||
key: (event: KeyboardEvent) => boolean;
|
||||
@@ -197,9 +202,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
? "보기만 — 산림청 계정에서 긋습니다."
|
||||
: mode === "draw"
|
||||
? active === null
|
||||
? "지점 · 선 끝 · 선 위를 눌러 시작합니다."
|
||||
? START_HINT
|
||||
: "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기"
|
||||
: "[긋기]로 지점 · 선에서 긋습니다 · 선 누름 = 고름 · 「연도 마커」를 선 위로 끌면 구역을 나눕니다.";
|
||||
: "[긋기]로 지점 · 선에서 긋습니다 · 지도 우클릭 = 긋기 시작 · 지점 놓기 · 선 누름 = 고름 · 「연도 마커」를 선 위로 끌면 구역을 나눕니다.";
|
||||
}
|
||||
fieldsState();
|
||||
|
||||
@@ -311,8 +316,16 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
params.onMode();
|
||||
}
|
||||
|
||||
/** [긋기] 첫 누름 — 선 끝 이어 긋기 · 지점 · 선 위 갈래. 참 = 시작함. */
|
||||
function begin(px: number, py: number, map: ScreenMap): boolean {
|
||||
/** 누른 자리에서 긋기를 시작하면 — 선 끝(이어 긋기) · 지점 · 선 위(갈래) · 빈 땅. */
|
||||
function aim(
|
||||
px: number,
|
||||
py: number,
|
||||
map: ScreenMap,
|
||||
):
|
||||
| { kind: "tip"; line: number; head: boolean }
|
||||
| { kind: "point"; point: Vertex }
|
||||
| { kind: "line"; hit: NonNullable<ReturnType<typeof lineAt>> }
|
||||
| { kind: "empty"; point: Vertex } {
|
||||
const network = params.network();
|
||||
const points = params.base();
|
||||
const gap = (point: Vertex): number => screenGap(map, point, px, py);
|
||||
@@ -320,7 +333,6 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
items.filter((item) => item.gap <= SNAP_PX).sort((a, b) => a.gap - b.gap)[0];
|
||||
const onBase = (list: Vertex[], point: Vertex): boolean =>
|
||||
list.some((p) => samePoint(p, point));
|
||||
strokeStart = network;
|
||||
// 1) 선 끝 — 꼬리 · 갈래 없는 선 머리(지점에 놓인 끝 빼고 — 지점 누름 = 새 선 · 갈래)
|
||||
const tips: Array<{ line: number; head: boolean; gap: number }> = [];
|
||||
network.lines.forEach((line, i) => {
|
||||
@@ -332,22 +344,35 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
tips.push({ line: i, head: true, gap: gap(head) });
|
||||
});
|
||||
const tip = nearest(tips);
|
||||
if (tip) {
|
||||
if (tip.head) {
|
||||
const turned = reverseLine(network, tip.line);
|
||||
if (tip) return { kind: "tip", line: tip.line, head: tip.head };
|
||||
// 2) 지점 3) 선 위 · 꺾임점
|
||||
const base = nearest(points.map((point) => ({ point, gap: gap(point) })));
|
||||
if (base) return { kind: "point", point: base.point };
|
||||
const hit = lineAt(network, px, py, map);
|
||||
return hit ? { kind: "line", hit } : { kind: "empty", point: map.toMetric(px, py) };
|
||||
}
|
||||
|
||||
/** [긋기] 첫 누름 — 선 끝 이어 긋기 · 지점 · 선 위 갈래 · `free` = 빈 땅도 새 본선(우클릭 메뉴 591-2).
|
||||
* 참 = 시작함. */
|
||||
function begin(px: number, py: number, map: ScreenMap, free = false): boolean {
|
||||
const network = params.network();
|
||||
const target = aim(px, py, map);
|
||||
strokeStart = network;
|
||||
if (target.kind === "tip") {
|
||||
if (target.head) {
|
||||
const turned = reverseLine(network, target.line);
|
||||
if (typeof turned === "string") {
|
||||
showToast(turned, "info");
|
||||
return false;
|
||||
}
|
||||
params.commit(turned);
|
||||
}
|
||||
[active, flip] = [tip.line, tip.head];
|
||||
[active, flip] = [target.line, target.head];
|
||||
return true;
|
||||
}
|
||||
// 2) 지점 — 이미 선이 놓인 지점 = 그 점의 갈래 · 아니면 새 본선(No.0 = 이 지점)
|
||||
const base = nearest(points.map((point) => ({ point, gap: gap(point) })));
|
||||
if (base) {
|
||||
const { point } = base;
|
||||
// 지점 — 이미 선이 놓인 지점 = 그 점의 갈래 · 아니면 새 본선(No.0 = 이 지점)
|
||||
if (target.kind === "point") {
|
||||
const { point } = target;
|
||||
const owner = network.lines.findIndex(
|
||||
(line) => line.vertices.length > 1 && line.vertices.some((v) => samePoint(v, point)),
|
||||
);
|
||||
@@ -358,9 +383,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
[active, flip] = [params.network().lines.length - 1, false];
|
||||
return true;
|
||||
}
|
||||
// 3) 선 위 · 꺾임점 = 갈래
|
||||
const hit = lineAt(network, px, py, map);
|
||||
if (hit) {
|
||||
// 선 위 · 꺾임점 = 갈래
|
||||
if (target.kind === "line") {
|
||||
const { hit } = target;
|
||||
let next = network;
|
||||
let vertex = hit.vertex;
|
||||
if (vertex === null) {
|
||||
@@ -371,8 +396,13 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
[active, flip] = [params.network().lines.length - 1, false];
|
||||
return true;
|
||||
}
|
||||
if (free) {
|
||||
params.commit(newLine(network, target.point));
|
||||
[active, flip] = [params.network().lines.length - 1, false];
|
||||
return true;
|
||||
}
|
||||
strokeStart = null;
|
||||
showToast("지점 · 선 끝 · 선 위를 눌러 시작합니다.", "info");
|
||||
showToast(START_HINT, "info");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -439,6 +469,15 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
panel,
|
||||
mode: () => mode,
|
||||
setMode,
|
||||
aim: (px, py, map) => aim(px, py, map).kind,
|
||||
startAt(px, py, map) {
|
||||
if (!editable) return false;
|
||||
setMode("draw");
|
||||
const started = begin(px, py, map, true);
|
||||
panelState();
|
||||
params.onMode();
|
||||
return started;
|
||||
},
|
||||
setEditable(next) {
|
||||
editable = next;
|
||||
if (!editable) setMode("idle");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* D03 손 긋기 겹 그림 · 집기(PLAN 562-3 · 580) — `D03_PlanAnalysis_UI_Draw`(긋기) ·
|
||||
* `D03_PlanAnalysis_UI_Edit`(고르기 · 고치기)가 같이 씀. 그림 = 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 ·
|
||||
* 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리.
|
||||
* 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 기준지정과 같은 `pointColor(i)`.
|
||||
* 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pointColor } from "@ui/ui_template_palette";
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* Delete = 고른 마크 지움 · 고른 구간이 선 통째면 그 선 지움.
|
||||
* 연도 구역 마커(585-3) = 도구줄 「연도 마커」를 선 위로 끌어 놓음 = 그 자리 구간을 둘로(해 그대로 · 구역만 나눔 ·
|
||||
* 해는 고른 뒤 속성 판) · 끄는 동안 마름모 미리보기(못 놓음 = 경고 색) · 놓인 마크를 선 밖으로 끌어냄 = 지움(합침).
|
||||
* 잠금 = 지점에 놓인 점(1단계 몫) · 갈래 첫 점(붙은 선 점을 끌면 같이 감).
|
||||
* 잠금 = 지점에 놓인 점(지점 핀을 끌면 같이 감 · 오른쪽 누름 = 지도 메뉴 591-2) · 갈래 첫 점(붙은 선 점을 끌면 같이 감).
|
||||
* 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 · 넓이 = 화면 기억(저장 안 함).
|
||||
* 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 `select` · `setScope` · 동작 셋을 부름).
|
||||
* ========================================================================== */
|
||||
@@ -54,7 +54,7 @@ const NEXT_SCOPE: Record<PlanScope, PlanScope> = { piece: "line", line: "year",
|
||||
|
||||
export interface PlanEditParams {
|
||||
network: () => PlanNetwork;
|
||||
/** 기준 지점(D02 · 585-1). */
|
||||
/** 지점(긋기에 붙는 점 · 591-2). */
|
||||
base: () => Vertex[];
|
||||
commit: (next: PlanNetwork) => void;
|
||||
/** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */
|
||||
@@ -249,7 +249,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
||||
function lockOf(network: PlanNetwork, line: number, vertex: number): string | null {
|
||||
const point = network.lines[line].vertices[vertex];
|
||||
return params.base().some((p) => samePoint(p, point))
|
||||
? "지점 자리 점은 1단계(기준 지정)에서 옮깁니다."
|
||||
? "지점 자리 점은 지점 핀을 끌어 옮깁니다."
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -391,7 +391,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
||||
sync();
|
||||
return true;
|
||||
}
|
||||
if (!vertex) return false;
|
||||
if (!vertex || lockOf(network, vertex.line, vertex.vertex)) return false; // 지점 = 지도 메뉴
|
||||
const next = removeVertex(network, vertex.line, vertex.vertex);
|
||||
if (typeof next === "string") showToast(next, "info");
|
||||
else params.commit(next);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Page.ts
|
||||
* D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · 440 · PLAN 562-3 · 563-1 · 579-3 · 580) — B05~B09 틀(`mountGroupStep`).
|
||||
* D 예정노선 1단계 노선분석(요구서 389 3장 6~13 · 440 · 452 · PLAN 562-3 · 563-1 · 579-3 · 580 · 591-2) — B05~B09 틀(`mountGroupStep`).
|
||||
*
|
||||
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯.
|
||||
* 좌 = 지형(들어올 때 받은 범위가 지점을 못 덮으면 바로 받기 · [범위 밖 더 받기]는 지점 · 선이
|
||||
* 받은 범위 밖일 때만) · 그리기([긋기] · 제약 칸 · 연도 = 도구줄 「연도 마커」 끌어 놓기 585-3) ·
|
||||
* 좌 = 지형(조용히 받기 — 처음 = 등록 기준점 둘레 1 km · 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 ·
|
||||
* 단추 · 막는 창 없음 · 진행 = 이 칸 작은 막대 · 실패만 [다시 받기]) · 지점(목록 · 핀 · 우클릭 메뉴 =
|
||||
* `D03_PlanAnalysis_UI_Points`) · 그리기([긋기] · 제약 칸 · 연도 = 도구줄 「연도 마커」 끌어 놓기 585-3) ·
|
||||
* 구역 · 연도(해 · 선 · 구간 줄 = 고르기 · 고른 줄 밝힘) · [저장] [초기화].
|
||||
* 고른 구간 속성(연도 · 넓이 · 방향 · 지움) = 지도 위 속성 판 `D03_PlanAnalysis_UI_Props`(585-2).
|
||||
* 지점 = D02 지점 목록(핀 · 늘 맨 위 · 585-1) · 고르기 · 고치기 = `D03_PlanAnalysis_UI_Edit`.
|
||||
* 지점 = 긋기에 붙는 점(핀 · 늘 맨 위) · 기준점 = 처음 받는 기준일 뿐(노선 무관) · 고르기 · 고치기 = `D03_PlanAnalysis_UI_Edit`.
|
||||
*
|
||||
* 데이터 3층(CLAUDE.md 5장) — 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 2, network}) · [저장] =
|
||||
* PUT plan/network(서버가 길이 · 측점 · 조각 · 연도 행을 다시 셈 · 응답으로 바꿈) · [초기화] = 빈 선 PUT.
|
||||
* 데이터 3층(CLAUDE.md 5장) — 지점 · 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 3, network, points}) · [저장] =
|
||||
* PUT plan/network(지점 함께 · 서버가 길이 · 측점 · 조각 · 연도 행을 다시 셈 · 응답으로 바꿈) · [초기화] = 빈 선 PUT(지점 남김).
|
||||
* 되돌리기 · 다시하기 = 이 화면 스택(걸음 = `commit` 한 번). 화면 전체 잠금 = [저장] · [초기화]만.
|
||||
* 공유 회사(산림청 아님) = 보기만 — 긋기 · 받기 · [저장] 없음.
|
||||
* ========================================================================== */
|
||||
@@ -18,9 +19,7 @@
|
||||
import "./D03_PlanAnalysis_UI_Style.css";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { editMap } from "@ui/ui_template_kakao_map";
|
||||
import { askConfirm, openModal } from "@ui/ui_template_modal";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import { createProgressBar } from "@ui/ui_template_progress";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import {
|
||||
@@ -45,15 +44,18 @@ import {
|
||||
type PlanJob,
|
||||
type PlanNetworkView,
|
||||
type PlanSurface,
|
||||
type TerrainExtent,
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
|
||||
import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw";
|
||||
import { pointLayer } from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import { createPlanEdit } from "./D03_PlanAnalysis_UI_Edit";
|
||||
import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers";
|
||||
import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
|
||||
import {
|
||||
createPlanMap,
|
||||
PLAN_STATION_INTERVAL_M,
|
||||
type PlanMapLayer,
|
||||
} from "./D03_PlanAnalysis_UI_Map";
|
||||
import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure";
|
||||
import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points";
|
||||
import { createPlanProps } from "./D03_PlanAnalysis_UI_Props";
|
||||
import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw";
|
||||
import {
|
||||
@@ -65,16 +67,28 @@ import {
|
||||
type PlanNetwork,
|
||||
type Vertex,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { lonLatToTm, tmParams } from "./logic/D03_PlanAnalysis_Tm";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
/** 지도 보기 가운데(보기의 반)가 받은 범위 밖이면 이어 받음 — 그 상자가 이보다 넓으면(멀리 줄여 봄) 안 받음(m). */
|
||||
const VIEW_FETCH_MAX_M = 2000;
|
||||
/** 지도 보기 바뀐 뒤 이만큼 멈추면 범위를 봄(ms). */
|
||||
const VIEW_SETTLE_MS = 800;
|
||||
/** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */
|
||||
const SITE_RADIUS_M = 1000;
|
||||
/** 되돌리기 쌓는 수. */
|
||||
const UNDO_MAX = 100;
|
||||
/** 캐시 꼴 판 — 옛 꼴(지점 · 갈래 고름)은 안 읽음. */
|
||||
const DRAFT_VERSION = 2;
|
||||
/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름)은 안 읽음. */
|
||||
const DRAFT_VERSION = 3;
|
||||
|
||||
interface Draft {
|
||||
version: number;
|
||||
/** 한 걸음 = 지점 + 노선(되돌리기 · 캐시 · 고침 판정). */
|
||||
interface Snap {
|
||||
network: PlanNetwork;
|
||||
points: Vertex[];
|
||||
}
|
||||
|
||||
interface Draft extends Snap {
|
||||
version: number;
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
|
||||
@@ -84,15 +98,20 @@ const errorText = (error: unknown): string =>
|
||||
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const projectId = currentProjectId();
|
||||
let editable = false;
|
||||
let points: Vertex[] = []; // 기준 지점(D02 · 585-1)
|
||||
let points: Vertex[] = []; // 지점(긋기에 붙는 점 · 591-2)
|
||||
let epsg = 0;
|
||||
let yearStart = new Date().getFullYear(); // 새 선 처음 해
|
||||
let network: PlanNetwork = emptyNetwork();
|
||||
let savedJson = JSON.stringify(network); // 못 읽은 채 떠남 = 고침 없음
|
||||
let undoStack: PlanNetwork[] = [];
|
||||
let redoStack: PlanNetwork[] = [];
|
||||
let savedJson = JSON.stringify({ network, points }); // 못 읽은 채 떠남 = 고침 없음
|
||||
let undoStack: Snap[] = [];
|
||||
let redoStack: Snap[] = [];
|
||||
let surface: PlanSurface | null = null;
|
||||
let have: [number, number, number, number] | null = null;
|
||||
let margin = 0; // 서버 받기 여유(m)
|
||||
let viewCore: Vertex[] = []; // 지도 보기 가운데 상자 두 귀(넓으면 빈 것)
|
||||
let viewTimer = 0;
|
||||
let lastTried = ""; // 같은 받기를 거듭하지 않음
|
||||
let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음)
|
||||
let busy = false;
|
||||
let failed: string | null = null;
|
||||
let measuring = false;
|
||||
@@ -129,7 +148,44 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
commit,
|
||||
});
|
||||
const measure = createPlanMeasure(() => z);
|
||||
const pins = pointLayer(() => points);
|
||||
const marks = createPlanPoints({
|
||||
points: () => points,
|
||||
network: () => network,
|
||||
epsg: () => epsg,
|
||||
draw,
|
||||
commit: (nextPoints, next) => step({ network: next, points: nextPoints }),
|
||||
show: (nextPoints, next) => {
|
||||
[points, network] = [nextPoints, next];
|
||||
map.redraw();
|
||||
},
|
||||
idle: () => draw.mode() === "idle" && !measuring,
|
||||
editable: () => editable,
|
||||
});
|
||||
// 지도 보기 가운데 상자 — 그릴 때마다 보이는 화면 가운데 반을 작업 좌표로(바뀌면 잠시 뒤 범위 봄)
|
||||
const viewWatch: PlanMapLayer = {
|
||||
draw(context, screen) {
|
||||
const [w, h] = [context.canvas.clientWidth, context.canvas.clientHeight];
|
||||
const corners = [
|
||||
[w / 4, h / 4],
|
||||
[(3 * w) / 4, h / 4],
|
||||
[(3 * w) / 4, (3 * h) / 4],
|
||||
[w / 4, (3 * h) / 4],
|
||||
].map(([x, y]) => screen.toMetric(...screen.unrotate(x, y)));
|
||||
const xs = corners.map((c) => c[0]);
|
||||
const ys = corners.map((c) => c[1]);
|
||||
const box: Vertex[] = [
|
||||
[Math.min(...xs), Math.min(...ys)],
|
||||
[Math.max(...xs), Math.max(...ys)],
|
||||
];
|
||||
const wide =
|
||||
box[1][0] - box[0][0] > VIEW_FETCH_MAX_M || box[1][1] - box[0][1] > VIEW_FETCH_MAX_M;
|
||||
const next = wide || !w ? [] : box;
|
||||
if (JSON.stringify(next) === JSON.stringify(viewCore)) return;
|
||||
viewCore = next;
|
||||
clearTimeout(viewTimer);
|
||||
viewTimer = window.setTimeout(() => void ensureCovered(), VIEW_SETTLE_MS);
|
||||
},
|
||||
};
|
||||
const map = createPlanMap({
|
||||
onUndo: undo,
|
||||
onRedo: redo,
|
||||
@@ -150,20 +206,19 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
terrainText.classList.add("d03-plan__hint");
|
||||
const bar = createProgressBar();
|
||||
bar.root.hidden = true;
|
||||
const extentButton = createButton({
|
||||
label: "지형 범위 보기",
|
||||
const retryButton = createButton({
|
||||
label: "다시 받기",
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
onClick: () => void openExtent([]),
|
||||
});
|
||||
const moreButton = createButton({
|
||||
label: "범위 밖 더 받기",
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
onClick: () => void openExtent(allVertices()),
|
||||
onClick: () => {
|
||||
[failed, lastTried, ready] = [null, "", true];
|
||||
void ensureCovered();
|
||||
},
|
||||
});
|
||||
const terrainCard = createPanelCard("지형", false);
|
||||
terrainCard.append(terrainText, bar.root, extentButton, moreButton);
|
||||
terrainCard.append(terrainText, bar.root, retryButton);
|
||||
const pointCard = createPanelCard("지점", false);
|
||||
pointCard.append(marks.panel);
|
||||
const drawCard = createPanelCard("그리기", false);
|
||||
drawCard.append(draw.panel);
|
||||
const pieceCard = createPanelCard("구역 · 연도", false);
|
||||
@@ -179,7 +234,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
actions.append(saveButton, resetButton);
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "ui-sidebar-panel d03-plan__panel";
|
||||
panel.append(terrainCard, drawCard, pieceCard, actions);
|
||||
panel.append(terrainCard, pointCard, drawCard, pieceCard, actions);
|
||||
attachCollapsible(panel);
|
||||
// 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3)
|
||||
mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, {
|
||||
@@ -192,26 +247,32 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout");
|
||||
|
||||
const allVertices = (): Vertex[] => network.lines.flatMap((line) => line.vertices);
|
||||
const dirty = (): boolean => JSON.stringify(network) !== savedJson;
|
||||
const snap = (): Snap => ({ network, points });
|
||||
const dirty = (): boolean => JSON.stringify(snap()) !== savedJson;
|
||||
const keepDraft = (): void =>
|
||||
dirty()
|
||||
? writeState("d03-plan", { version: DRAFT_VERSION, network } satisfies Draft, projectId)
|
||||
? writeState("d03-plan", { version: DRAFT_VERSION, ...snap() } satisfies Draft, projectId)
|
||||
: clearState("d03-plan", projectId);
|
||||
|
||||
/** 한 걸음 — 조각을 맞춰 넣고 되돌리기에 쌓음 · 캐시. */
|
||||
function commit(next: PlanNetwork): void {
|
||||
undoStack = [...undoStack.slice(1 - UNDO_MAX), network];
|
||||
/** 한 걸음 — 조각을 맞춰 넣고 되돌리기에 쌓음 · 캐시 · 받은 범위 밖이면 조용히 이어 받음. */
|
||||
function step(next: Snap): void {
|
||||
undoStack = [...undoStack.slice(1 - UNDO_MAX), snap()];
|
||||
redoStack = [];
|
||||
network = normalizePieces(next, yearStart);
|
||||
[network, points] = [normalizePieces(next.network, yearStart), next.points];
|
||||
keepDraft();
|
||||
refresh();
|
||||
void ensureCovered();
|
||||
}
|
||||
|
||||
function commit(next: PlanNetwork): void {
|
||||
step({ network: next, points });
|
||||
}
|
||||
|
||||
function undo(): void {
|
||||
const last = undoStack.pop();
|
||||
if (!last) return;
|
||||
redoStack.push(network);
|
||||
network = last;
|
||||
redoStack.push(snap());
|
||||
({ network, points } = last);
|
||||
keepDraft();
|
||||
refresh();
|
||||
}
|
||||
@@ -219,8 +280,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
function redo(): void {
|
||||
const next = redoStack.pop();
|
||||
if (!next) return;
|
||||
undoStack.push(network);
|
||||
network = next;
|
||||
undoStack.push(snap());
|
||||
({ network, points } = next);
|
||||
keepDraft();
|
||||
refresh();
|
||||
}
|
||||
@@ -230,22 +291,42 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
measuring = on;
|
||||
measure.clear();
|
||||
if (on) draw.setMode("idle");
|
||||
map.setLayers([
|
||||
...backdrop.layers,
|
||||
draw.layer,
|
||||
edit.layer,
|
||||
...(measuring ? [measure.layer] : []),
|
||||
pins,
|
||||
]);
|
||||
map.setLayers(layerList());
|
||||
refresh();
|
||||
}
|
||||
|
||||
/** 지점 · 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */
|
||||
const outside = (): boolean =>
|
||||
/** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림 · 우클릭 메뉴(`marks.pins`)는 맨 끝(맨 위). */
|
||||
const layerList = (): PlanMapLayer[] => [
|
||||
...backdrop.layers,
|
||||
draw.layer,
|
||||
marks.input,
|
||||
edit.layer,
|
||||
...(measuring ? [measure.layer] : []),
|
||||
viewWatch,
|
||||
marks.pins,
|
||||
];
|
||||
|
||||
/** 점들이 받은 범위 안인가 — `pad` = 둘레 여유(m). */
|
||||
const inside = (list: Vertex[], pad: number): boolean =>
|
||||
!!have &&
|
||||
[...points, ...allVertices()].some(
|
||||
([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3],
|
||||
list.every(
|
||||
([x, y]) =>
|
||||
x - pad >= have![0] && x + pad <= have![2] && y - pad >= have![1] && y + pad <= have![3],
|
||||
);
|
||||
const outside = (): boolean => !!have && !inside([...points, ...allVertices()], 0);
|
||||
|
||||
/** 조용히 받기(요구 453) — 지점 · 선(+ 여유) · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 ·
|
||||
* 단추 · 막는 창 없음 · 실패 = [다시 받기] · 같은 요청은 한 번만. */
|
||||
async function ensureCovered(): Promise<void> {
|
||||
if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return;
|
||||
const need = [...points, ...allVertices()];
|
||||
if (have && inside(need, margin) && inside(viewCore, 0)) return;
|
||||
const extra = [...need, ...viewCore];
|
||||
const key = JSON.stringify([have, extra]);
|
||||
if (key === lastTried) return;
|
||||
lastTried = key;
|
||||
await download(extra);
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
draw.sync();
|
||||
@@ -259,105 +340,32 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
});
|
||||
map.redraw();
|
||||
props.render();
|
||||
extentButton.hidden = !editable || busy || !!surface;
|
||||
moreButton.hidden = !editable || busy || !surface || !outside();
|
||||
marks.render();
|
||||
retryButton.hidden = !editable || busy || !failed;
|
||||
if (!busy)
|
||||
terrainText.textContent = failed
|
||||
? `지형 받기 실패 — ${failed}`
|
||||
: !surface
|
||||
? "받은 지형 없음 — 범위를 보고 받으세요(미리보기 길이 · 기울기는 지형을 받은 뒤)."
|
||||
? editable
|
||||
? "받은 지형 없음 — 기준점 둘레를 받습니다(길이 · 기울기는 지형을 받은 뒤)."
|
||||
: "받은 지형 없음"
|
||||
: outside()
|
||||
? "지점 또는 그은 선이 받은 지형 밖입니다 — 더 받아야 기울기를 셉니다."
|
||||
? "지점 또는 그은 선이 받은 지형 밖 — 이어 받습니다."
|
||||
: "받은 지형 안";
|
||||
if (failed && editable && !busy) extentButton.hidden = false;
|
||||
extentButton.querySelector(".ui-btn__label")!.textContent = failed
|
||||
? "다시 받기"
|
||||
: "지형 범위 보기";
|
||||
saveButton.hidden = resetButton.hidden = !editable;
|
||||
saveButton.disabled = busy || !dirty();
|
||||
}
|
||||
|
||||
/** 받기 전 범위를 카카오 지도에 보임 → [이 범위 받기]. */
|
||||
async function openExtent(extra: Vertex[]): Promise<void> {
|
||||
let extent: TerrainExtent;
|
||||
async function download(extra: Vertex[]): Promise<void> {
|
||||
busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음)
|
||||
try {
|
||||
extent = await fetchTerrainExtent(projectId, null, extra);
|
||||
} catch (error) {
|
||||
return void showToast(`받을 범위를 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
}
|
||||
const info = createText("", "muted");
|
||||
const kakao = editMap({
|
||||
onMove: () => undefined,
|
||||
hint: "받을 범위(빨강) · 받은 범위(초록) · 도엽(옅게)",
|
||||
});
|
||||
const show = (view: TerrainExtent): void => {
|
||||
const ring = (points: Array<[number, number]>): Array<[number, number]> =>
|
||||
[...points, points[0]].map(([lon, lat]) => [lat, lon]);
|
||||
const lines = [
|
||||
ring(view.box),
|
||||
...(view.have ? [ring(view.have)] : []),
|
||||
...view.sheets.map((s) => ring(s.ring)),
|
||||
];
|
||||
const colors = [
|
||||
"#E60046",
|
||||
...(view.have ? ["#16a34a"] : []),
|
||||
...view.sheets.map(() => "#94a3b8"),
|
||||
];
|
||||
kakao.set([], lines, colors);
|
||||
const newSheets = view.sheets.filter((sheet) => !sheet.stored).length;
|
||||
info.textContent =
|
||||
`여유 ${view.margin_m.toFixed(0)} m · 도엽 ${view.sheets.length}매(새로 ${newSheets}) · ` +
|
||||
`지형 칸 약 ${(view.cells / 1e6).toFixed(1)}백만` +
|
||||
(view.covered ? " · 이미 받은 범위 안" : "");
|
||||
};
|
||||
const margin = createInputField({
|
||||
label: "여유",
|
||||
type: "number",
|
||||
value: extent.margin_m.toFixed(0),
|
||||
unit: "m",
|
||||
size: 24,
|
||||
});
|
||||
const applyMargin = createButton({
|
||||
label: "다시 보기",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () =>
|
||||
void fetchTerrainExtent(projectId, Number(margin.input.value) || null, extra)
|
||||
.then((view) => show((extent = view)))
|
||||
.catch((error) => showToast(errorText(error), "error")),
|
||||
});
|
||||
const row = document.createElement("div");
|
||||
row.className = "d03-plan__row";
|
||||
row.append(margin.root, applyMargin);
|
||||
let close = (): void => undefined;
|
||||
const go = createButton({
|
||||
label: "이 범위 받기",
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
close();
|
||||
void download(extent.margin_m, extra);
|
||||
},
|
||||
});
|
||||
({ close } = openModal({
|
||||
title: "지형 받기 — 범위 미리보기",
|
||||
closeLabel: "닫기",
|
||||
width: "960px",
|
||||
dialogClass: "d03-plan__extent-modal",
|
||||
actions: [go],
|
||||
mount: (body) => {
|
||||
body.classList.add("d03-plan__extent");
|
||||
body.append(row, info, kakao.root);
|
||||
show(extent);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async function download(marginM: number, extra: Vertex[]): Promise<void> {
|
||||
try {
|
||||
await startTerrain(projectId, marginM, extra);
|
||||
await startTerrain(projectId, null, extra);
|
||||
} catch (error) {
|
||||
// 이미 도는 일(다른 창)이면 그것을 지켜봄
|
||||
if (!/이미 돌고/.test(errorText(error))) return void showToast(errorText(error), "error");
|
||||
if (!/이미 돌고/.test(errorText(error))) {
|
||||
busy = false;
|
||||
return void showToast(errorText(error), "error");
|
||||
}
|
||||
}
|
||||
await follow();
|
||||
}
|
||||
@@ -369,6 +377,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
failed = job.error;
|
||||
await Promise.all([loadSurface(), backdrop.load()]);
|
||||
refresh();
|
||||
await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기
|
||||
}
|
||||
|
||||
/** 지형 받기를 끝날 때까지 지켜봄 — 진행은 지형 칸에. */
|
||||
@@ -401,14 +410,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
async function loadSurface(): Promise<void> {
|
||||
try {
|
||||
surface = await fetchPlanSurface(projectId);
|
||||
const extent = surface ? await fetchTerrainExtent(projectId, null, []) : null;
|
||||
have = extent?.have_m ?? null;
|
||||
const extent = await fetchTerrainExtent(projectId, null, []);
|
||||
[have, margin] = [extent.have_m, extent.margin_m];
|
||||
} catch (error) {
|
||||
showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** 서버 응답 → 지금 값 · 저장본(빈 선 = 시점만). */
|
||||
/** 서버 응답 → 지금 값 · 저장본(지점 · 선). */
|
||||
function takeServer(view: PlanNetworkView): void {
|
||||
if (view.year_start) yearStart = view.year_start;
|
||||
const server = view.network;
|
||||
@@ -416,12 +425,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
server && server.lines.length
|
||||
? fromServer(server.lines, server.marks, server.pieces, yearStart)
|
||||
: emptyNetwork();
|
||||
if (server?.epsg) epsg = server.epsg;
|
||||
savedJson = JSON.stringify(network);
|
||||
points = view.base.points.map((p): Vertex => [p.x, p.y]);
|
||||
if (view.base.epsg) epsg = view.base.epsg;
|
||||
savedJson = JSON.stringify(snap());
|
||||
}
|
||||
|
||||
function serverBody(): Parameters<typeof savePlanNetwork>[1] {
|
||||
return { epsg, lines: network.lines, marks: marksOf(network), pieces: network.pieces };
|
||||
const { lines, pieces } = network;
|
||||
return { epsg, points, lines, marks: marksOf(network), pieces };
|
||||
}
|
||||
|
||||
/** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */
|
||||
@@ -446,12 +457,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** [초기화] = 시점 · 종점만 남은 빈 선을 정본에. */
|
||||
/** [초기화] = 선만 지운 빈 노선을 정본에 · 지점은 남김(요구 453). */
|
||||
async function reset(): Promise<void> {
|
||||
const ok = await askConfirm({
|
||||
title: "초기화",
|
||||
message:
|
||||
"그은 선 · 갈래 · 구역을 모두 지우고 시점 · 종점만 남깁니다. 저장된 노선도 바뀝니다.",
|
||||
message: "그은 선 · 갈래 · 구역을 모두 지웁니다(지점은 남김). 저장된 노선도 바뀝니다.",
|
||||
okLabel: "초기화",
|
||||
danger: true,
|
||||
});
|
||||
@@ -459,12 +469,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
draw.setMode("idle");
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
takeServer(await savePlanNetwork(projectId, { epsg, lines: [], marks: [], pieces: [] }));
|
||||
const body = { epsg, points, lines: [], marks: [], pieces: [] };
|
||||
takeServer(await savePlanNetwork(projectId, body));
|
||||
clearState("d03-plan", projectId);
|
||||
undoStack = [];
|
||||
redoStack = [];
|
||||
refresh();
|
||||
showToast("시점 · 종점만 남겼습니다.", "success");
|
||||
showToast("선을 지웠습니다(지점은 남김).", "success");
|
||||
} catch (error) {
|
||||
showToast(`초기화하지 못했습니다 — ${errorText(error)}`, "error");
|
||||
} finally {
|
||||
@@ -489,6 +500,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
(window as unknown as { __d03Plan?: unknown }).__d03Plan = {
|
||||
network: () => network,
|
||||
base: () => points,
|
||||
have: () => have,
|
||||
busy: () => busy,
|
||||
lineNames: () => lineNames(network.lines),
|
||||
screenOf: map.screenOf,
|
||||
screenOfLonLat: map.screenOfLonLat,
|
||||
@@ -528,33 +541,47 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
map.setLayers([...backdrop.layers, draw.layer, edit.layer, pins]);
|
||||
map.setLayers(layerList());
|
||||
marks.mount(map.root);
|
||||
|
||||
try {
|
||||
editable = !!(await fetchSessionUser())?.is_forest_service;
|
||||
draw.setEditable(editable);
|
||||
const view = await fetchPlanNetwork(projectId);
|
||||
const base = view.base;
|
||||
if (!base.points.length || !base.epsg) {
|
||||
terrainText.textContent = "지점이 비었습니다 — 1단계(기준 지정)에서 정하세요.";
|
||||
const { epsg: workEpsg, site } = view.base;
|
||||
if (!workEpsg) {
|
||||
terrainText.textContent = "기준점이 없습니다 — 예정노선 등록에서 정하세요.";
|
||||
return;
|
||||
}
|
||||
points = base.points.map((p): Vertex => [p.x, p.y]);
|
||||
epsg = base.epsg;
|
||||
epsg = workEpsg;
|
||||
takeServer(view);
|
||||
const draft = readState<Draft>("d03-plan", projectId);
|
||||
if (draft?.version === DRAFT_VERSION && draft.network?.lines?.length) network = draft.network;
|
||||
map.update({ fit: [...points, ...allVertices()], epsg });
|
||||
if (draft?.version === DRAFT_VERSION && draft.network && Array.isArray(draft.points))
|
||||
({ network, points } = draft);
|
||||
const tm = tmParams(epsg);
|
||||
const siteXy = site && tm ? lonLatToTm(tm, site.lon, site.lat) : null;
|
||||
const around: Vertex[] = siteXy
|
||||
? [
|
||||
[siteXy[0] - SITE_RADIUS_M, siteXy[1] - SITE_RADIUS_M],
|
||||
[siteXy[0] + SITE_RADIUS_M, siteXy[1] + SITE_RADIUS_M],
|
||||
]
|
||||
: [];
|
||||
const drawn = [...points, ...allVertices()];
|
||||
map.update({ fit: drawn.length ? drawn : around, epsg });
|
||||
refresh();
|
||||
await loadSurface();
|
||||
refresh();
|
||||
await backdrop.load();
|
||||
const job = (await fetchPlanProgress(projectId)).terrain;
|
||||
if (job.running) await follow();
|
||||
else if (editable && !job.error) {
|
||||
// 들어올 때 받은 범위가 시점 · 종점을 못 덮으면 바로 받기(요구 441 1 · 실패한 뒤엔 [다시 받기])
|
||||
if (job.running) {
|
||||
ready = true;
|
||||
await follow();
|
||||
} else if (editable && !job.error) {
|
||||
// 들어올 때 받은 범위가 기준점 둘레 · 지점을 못 덮으면 바로 받기(서버가 셈 · 실패 뒤엔 [다시 받기])
|
||||
const extent = await fetchTerrainExtent(projectId, null, []);
|
||||
if (!extent.covered) await download(extent.margin_m, []);
|
||||
ready = true;
|
||||
if (!extent.covered) await download([]);
|
||||
else await ensureCovered();
|
||||
}
|
||||
} catch (error) {
|
||||
terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Points.ts
|
||||
* D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로.
|
||||
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
|
||||
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
|
||||
* 지도 우클릭 = 메뉴(부품 상자 `createMapContextMenu`) — 핀: 여기서 긋기 · 지움 / 그 밖: 긋기 시작(빈 땅 = 새 본선 ·
|
||||
* 선 위 = 갈래 · 선 끝 = 이어 긋기) · 지점 놓기(꺾임점 둘레면 그 점에) · 긋는 중 = 긋기 끝.
|
||||
* 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 우클릭 메뉴 · 겹 맨 끝 = 맨 위 ·
|
||||
* 꺾임점 · 마크 오른쪽 누름 = 고르기 겹이 먼저 지움).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createIconButton } from "@ui/ui_template_button";
|
||||
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { createListRow } from "@ui/ui_template_list_row";
|
||||
import { pointColor } from "@ui/ui_template_palette";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { moveNodesAt, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
|
||||
import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
|
||||
import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
|
||||
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
|
||||
const MAX_POINTS = 40;
|
||||
/** 핀 머리 = 꼭지 위 px(`pointLayer` 그림과 같음). */
|
||||
const PIN_HEAD_PX = 18;
|
||||
|
||||
export interface PlanPointsParams {
|
||||
points: () => Vertex[];
|
||||
network: () => PlanNetwork;
|
||||
epsg: () => number;
|
||||
draw: PlanDraw;
|
||||
/** 한 걸음 — 지점 · 노선 함께. */
|
||||
commit: (points: Vertex[], network: PlanNetwork) => void;
|
||||
/** 끌기 중 — 화면만. */
|
||||
show: (points: Vertex[], network: PlanNetwork) => void;
|
||||
/** 쉴 때(긋기 · 재기 아님)만 핀을 끎. */
|
||||
idle: () => boolean;
|
||||
editable: () => boolean;
|
||||
}
|
||||
|
||||
export interface PlanPoints {
|
||||
/** 좌측 「지점」 칸 몸. */
|
||||
panel: HTMLElement;
|
||||
input: PlanMapLayer;
|
||||
pins: PlanMapLayer;
|
||||
render: () => void;
|
||||
/** 메뉴를 띄울 지도 그릇(우클릭 자리 = 그릇 기준). */
|
||||
mount: (viewport: HTMLElement) => void;
|
||||
}
|
||||
|
||||
export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
||||
const list = document.createElement("div");
|
||||
list.className = "d03-plan__points";
|
||||
const hint = createText("", "muted");
|
||||
hint.classList.add("d03-plan__hint");
|
||||
const panel = document.createElement("div");
|
||||
panel.append(list, hint);
|
||||
const menu = createMapContextMenu("d03-plan");
|
||||
let menuAt: [number, number] = [0, 0];
|
||||
let drag: {
|
||||
index: number;
|
||||
points: Vertex[];
|
||||
network: PlanNetwork;
|
||||
last: [Vertex[], PlanNetwork] | null;
|
||||
} | null = null;
|
||||
|
||||
const name = (i: number): string => `지점 ${i + 1}`;
|
||||
|
||||
/** 핀 집기 — 꼭지 · 머리(보이는 화면 위쪽) 둘레 · 뒤 지점(위에 그려짐) 먼저. */
|
||||
function pinAt(px: number, py: number, map: ScreenMap): number | null {
|
||||
const [cx, cy] = map.rerotate(px, py);
|
||||
let best: number | null = null;
|
||||
let bestGap = SNAP_PX;
|
||||
params.points().forEach((point, i) => {
|
||||
const [sx, sy] = map.rerotate(...map.toScreen(point));
|
||||
const gap = Math.min(
|
||||
Math.hypot(cx - sx, cy - sy),
|
||||
Math.hypot(cx - sx, cy - sy + PIN_HEAD_PX),
|
||||
);
|
||||
if (gap <= bestGap) [best, bestGap] = [i, gap];
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
function remove(i: number): void {
|
||||
params.commit(
|
||||
params.points().filter((_, k) => k !== i),
|
||||
params.network(),
|
||||
);
|
||||
}
|
||||
|
||||
/** 지점 놓기 — 꺾임점 둘레(SNAP_PX)면 그 점 자리(노드에 붙음). */
|
||||
function place(px: number, py: number, map: ScreenMap): void {
|
||||
if (params.points().length >= MAX_POINTS) return;
|
||||
const node = params
|
||||
.network()
|
||||
.lines.flatMap((line) => line.vertices)
|
||||
.map((point) => ({ point, gap: screenGap(map, point, px, py) }))
|
||||
.filter((item) => item.gap <= SNAP_PX)
|
||||
.sort((a, b) => a.gap - b.gap)[0];
|
||||
const at = node?.point ?? map.toMetric(px, py);
|
||||
params.commit([...params.points(), [at[0], at[1]]], params.network());
|
||||
}
|
||||
|
||||
function openMenu(px: number, py: number, map: ScreenMap): boolean {
|
||||
if (!params.editable()) return false;
|
||||
const draw = params.draw;
|
||||
const pin = pinAt(px, py, map);
|
||||
const items: MapContextMenuItem[] = [];
|
||||
if (draw.mode() === "draw" && draw.stroke())
|
||||
items.push(["긋기 끝", () => draw.setMode("idle")]);
|
||||
else if (pin !== null) {
|
||||
items.push([`${name(pin)}에서 긋기 시작`, () => draw.startAt(px, py, map)]);
|
||||
items.push([`${name(pin)} 지우기`, () => remove(pin)]);
|
||||
} else {
|
||||
const label = {
|
||||
tip: "이어 긋기",
|
||||
point: "지점에서 긋기 시작",
|
||||
line: "갈래 시작",
|
||||
empty: "여기서 긋기 시작",
|
||||
}[draw.aim(px, py, map)];
|
||||
items.push([label, () => draw.startAt(px, py, map)]);
|
||||
}
|
||||
if (pin === null && params.points().length < MAX_POINTS)
|
||||
items.push(["지점 놓기", () => place(px, py, map)]);
|
||||
menu.open(menuAt[0], menuAt[1], items);
|
||||
return true;
|
||||
}
|
||||
|
||||
const pins = pointLayer(params.points);
|
||||
pins.press = (px, py, map, button) => (button === 2 ? openMenu(px, py, map) : false);
|
||||
|
||||
const input: PlanMapLayer = {
|
||||
draw: () => undefined,
|
||||
press(px, py, map, button) {
|
||||
if (button !== 0 || !params.editable() || !params.idle()) return false;
|
||||
const index = pinAt(px, py, map);
|
||||
if (index === null) return false;
|
||||
drag = { index, points: params.points(), network: params.network(), last: null };
|
||||
return true;
|
||||
},
|
||||
hover(px, py, map) {
|
||||
if (!drag) return;
|
||||
const to = map.toMetric(px, py);
|
||||
const from = drag.points[drag.index];
|
||||
const points = drag.points.map((p, k): Vertex => (k === drag!.index ? to : p));
|
||||
drag.last = [points, moveNodesAt(drag.network, from, to)];
|
||||
params.show(...drag.last);
|
||||
},
|
||||
release() {
|
||||
const done = drag;
|
||||
drag = null;
|
||||
if (!done?.last) return;
|
||||
params.show(done.points, done.network);
|
||||
params.commit(...done.last);
|
||||
},
|
||||
};
|
||||
|
||||
function render(): void {
|
||||
const tm = tmParams(params.epsg());
|
||||
const editable = params.editable();
|
||||
list.replaceChildren(
|
||||
...params.points().map((point, i) => {
|
||||
const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null;
|
||||
const drop = createIconButton({
|
||||
icon: "trash",
|
||||
title: `${name(i)} 지우기`,
|
||||
size: 24,
|
||||
onClick: () => remove(i),
|
||||
});
|
||||
drop.hidden = !editable;
|
||||
return createListRow({
|
||||
label: name(i),
|
||||
sub: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : undefined,
|
||||
stripe: pointColor(i),
|
||||
size: 28,
|
||||
trailing: drop,
|
||||
});
|
||||
}),
|
||||
);
|
||||
hint.textContent = !editable
|
||||
? params.points().length
|
||||
? "보기만 — 지점은 산림청이 놓습니다."
|
||||
: "지점 없음"
|
||||
: params.points().length
|
||||
? "핀을 끌면 그 자리 노선 점도 같이 옮깁니다 · 지도 우클릭 「지점 놓기」로 더함."
|
||||
: "지도 우클릭 「지점 놓기」 — 긋기가 붙는 점(없어도 우클릭으로 긋기 시작).";
|
||||
}
|
||||
|
||||
return {
|
||||
panel,
|
||||
input,
|
||||
pins,
|
||||
render,
|
||||
mount(viewport) {
|
||||
viewport.append(menu.element);
|
||||
viewport.addEventListener(
|
||||
"contextmenu",
|
||||
(event) => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
menuAt = [event.clientX - rect.left, event.clientY - rect.top];
|
||||
},
|
||||
true,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -366,6 +366,17 @@ export function moveVertex(
|
||||
return { lines, pieces };
|
||||
}
|
||||
|
||||
/** 지점 핀 옮김(591-2) — `from` 자리 꺾임점을 모두 `to` 로(붙은 갈래 첫 점은 `moveVertex` 가 같이). */
|
||||
export function moveNodesAt(network: PlanNetwork, from: Vertex, to: Vertex): PlanNetwork {
|
||||
let next = network;
|
||||
network.lines.forEach((line, i) =>
|
||||
line.vertices.forEach((point, v) => {
|
||||
if (!(v === 0 && line.branch) && samePoint(point, from)) next = moveVertex(next, i, v, to);
|
||||
}),
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 꺾임점 지움 — 갈래 첫 점 · 갈래가 붙은 점 · 점 둘뿐인 선은 못 지움(까닭 글) · 뒤 갈래 차례 −1 ·
|
||||
* 마크는 합친 구간 안 비율. */
|
||||
export function removeVertex(
|
||||
|
||||
@@ -18,7 +18,7 @@ from common_util.common_util_workflow_state import initialize_project_stages
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
KIND_STAGE_DIRS = {
|
||||
"PLAN": ("D02_PlanBase", "D03_PlanAnalysis", "D04_PlanConfirm", "B04_PreProcess"),
|
||||
"PLAN": ("D03_PlanAnalysis", "D04_PlanConfirm", "B04_PreProcess"),
|
||||
"STABILITY": (
|
||||
"E02_StabGround",
|
||||
"E03_StabSection",
|
||||
|
||||
@@ -20,11 +20,11 @@ STAGE_KEYS = [
|
||||
]
|
||||
|
||||
|
||||
# 종류별 단계(요구 345 · 390) — ROAD = B03~B09 · PLAN = D02~D04 ·
|
||||
# 종류별 단계(요구 345 · 390) — ROAD = B03~B09 · PLAN = D03 · D04 ·
|
||||
# STABILITY = E02 · E03 · E04 · E06 · E07 · E05(보고서 맨 뒤 · 옛 행 = 022 SQL 로 다시 깖).
|
||||
STAGE_KEYS_BY_KIND = {
|
||||
"ROAD": STAGE_KEYS,
|
||||
"PLAN": ["PLAN_BASE", "PLAN_ANALYSIS", "PLAN_CONFIRM"],
|
||||
"PLAN": ["PLAN_ANALYSIS", "PLAN_CONFIRM"],
|
||||
"STABILITY": [
|
||||
"STAB_GROUND",
|
||||
"STAB_SECTION",
|
||||
|
||||
@@ -122,7 +122,6 @@ export const ROUTES = {
|
||||
M02_MASTER_TEMPLATE: "m02-master-template",
|
||||
// D 예정노선 · E 안정성평가(요구 345) — 길 선언은 페이지 폴더 `draft/*_Route.ts`
|
||||
D01_PLAN_REGISTER: "d01-plan-register",
|
||||
D02_PLAN_BASE: "d02-plan-base",
|
||||
D03_PLAN_ANALYSIS: "d03-plan-analysis",
|
||||
D04_PLAN_CONFIRM: "d04-plan-confirm",
|
||||
E01_STAB_REGISTER: "e01-stab-register",
|
||||
@@ -134,12 +133,8 @@ export const ROUTES = {
|
||||
E07_STAB_AREA: "e07-stab-area",
|
||||
} as const;
|
||||
|
||||
/** D 예정노선 단계 주소(D02 → D04 · 등록 D01 은 단계 밖) */
|
||||
export const PLAN_STEP_ROUTES = [
|
||||
ROUTES.D02_PLAN_BASE,
|
||||
ROUTES.D03_PLAN_ANALYSIS,
|
||||
ROUTES.D04_PLAN_CONFIRM,
|
||||
] as const;
|
||||
/** D 예정노선 단계 주소(D03 → D04 · 등록 D01 은 단계 밖) */
|
||||
export const PLAN_STEP_ROUTES = [ROUTES.D03_PLAN_ANALYSIS, ROUTES.D04_PLAN_CONFIRM] as const;
|
||||
|
||||
/** E 안정성평가 단계 주소(E02 → E04 · 암반 E06 · 광역 E07 · 보고서 E05 맨 뒤 · 생성 E01 은 단계 밖) */
|
||||
export const STAB_STEP_ROUTES = [
|
||||
|
||||
@@ -76,7 +76,6 @@ from B08_Quantity.B08_Quantity_Router_Milestone import router as b08_milestone_r
|
||||
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
|
||||
from D01_PlanRegister.D01_PlanRegister_Router import dashboard_router as d01_dashboard_router
|
||||
from D01_PlanRegister.D01_PlanRegister_Router import router as d01_plan_register_router
|
||||
from D02_PlanBase.D02_PlanBase_Router import router as d02_plan_base_router
|
||||
from D03_PlanAnalysis.D03_PlanAnalysis_Network import router as d03_plan_network_router
|
||||
from D03_PlanAnalysis.D03_PlanAnalysis_Router import router as d03_plan_analysis_router
|
||||
from D04_PlanConfirm.D04_PlanConfirm_Link import dashboard_router as d04_link_dashboard_router
|
||||
@@ -495,7 +494,6 @@ app.include_router(b04_basins_router, dependencies=protected_with_company)
|
||||
app.include_router(tiles_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company)
|
||||
app.include_router(d02_plan_base_router, dependencies=protected_with_company)
|
||||
app.include_router(d03_plan_analysis_router, dependencies=protected_with_company)
|
||||
app.include_router(d03_plan_network_router, dependencies=protected_with_company)
|
||||
app.include_router(d04_plan_confirm_router, dependencies=protected_with_company)
|
||||
|
||||
@@ -118,7 +118,7 @@ const STUBS = {
|
||||
"@config/config_frontend": {
|
||||
ROUTES,
|
||||
API_BASE_URL: "",
|
||||
PLAN_STEP_ROUTES: ["d02-plan-base", "d03-plan-analysis", "d04-plan-confirm"],
|
||||
PLAN_STEP_ROUTES: ["d03-plan-analysis", "d04-plan-confirm"],
|
||||
// 565-1 — 보고서(E05) 맨 뒤
|
||||
STAB_STEP_ROUTES: [
|
||||
"e02-stab-ground",
|
||||
@@ -206,7 +206,7 @@ const ALL = [
|
||||
"m02-master-template",
|
||||
// 529-1 D · E 묶음 — 묶음 안에서만 [<] [>] · 등록(D01 · E01) = [?] 만
|
||||
"d01-plan-register",
|
||||
"d02-plan-base",
|
||||
"d03-plan-analysis",
|
||||
"d04-plan-confirm",
|
||||
"e01-stab-register",
|
||||
"e02-stab-ground",
|
||||
|
||||
@@ -36,10 +36,10 @@ def test_529_1_D_E_묶음_안에서만_앞뒤(results):
|
||||
rows = results["rows"]
|
||||
for route in ("d01-plan-register", "e01-stab-register"):
|
||||
assert rows[route]["plain"]["labels"] == ["?", "저장"], route
|
||||
for route in ("d02-plan-base", "d04-plan-confirm", "e02-stab-ground", "e05-stab-report"):
|
||||
for route in ("d03-plan-analysis", "d04-plan-confirm", "e02-stab-ground", "e05-stab-report"):
|
||||
assert rows[route]["plain"]["labels"] == ["?", "<", ">", "저장"], route
|
||||
# 묶음 첫 단계 [<] · 끝 단계 [>] 흐림(B 묶음과 따로)
|
||||
assert rows["d02-plan-base"]["plain"]["disabled"][:3] == [False, True, False]
|
||||
assert rows["d03-plan-analysis"]["plain"]["disabled"][:3] == [False, True, False]
|
||||
assert rows["d04-plan-confirm"]["plain"]["disabled"][:3] == [False, False, True]
|
||||
assert rows["e02-stab-ground"]["plain"]["disabled"][:3] == [False, True, False]
|
||||
assert rows["e05-stab-report"]["plain"]["disabled"][:3] == [False, False, True]
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_공용이_도우미를_씀():
|
||||
leave = (ROOT / "A00_Common/design/design_leave.ts").read_text(encoding="utf-8")
|
||||
assert "guardProjectLeave(root, {" in leave
|
||||
state = (ROOT / "A00_Common/b_page_state.ts").read_text(encoding="utf-8")
|
||||
for name in ("d02-points", "e02-ground", "e03-prob", "e04-settings", "e05-report"):
|
||||
for name in ("e02-ground", "e03-prob", "e04-settings", "e05-report"):
|
||||
assert f'"{name}": {{ bucket: "draft", scope: "project"' in state
|
||||
assert '"e06-rock"' in state and '"e07-settings"' in state
|
||||
|
||||
|
||||
@@ -273,13 +273,13 @@ def test_register_ignores_sent_company():
|
||||
assert "company_name" not in data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 3), ("STABILITY", 6)])
|
||||
@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 2), ("STABILITY", 6)])
|
||||
def test_stage_seed_by_kind(kind, count):
|
||||
log = []
|
||||
asyncio.run(initialize_project_stages(FakeCursor({}, log), "p1", kind))
|
||||
assert len(log) == count
|
||||
if kind == "PLAN":
|
||||
assert [a[2] for _, a in log] == ["PLAN_BASE", "PLAN_ANALYSIS", "PLAN_CONFIRM"]
|
||||
assert [a[2] for _, a in log] == ["PLAN_ANALYSIS", "PLAN_CONFIRM"]
|
||||
if kind == "STABILITY": # 565-1 — 보고서 맨 뒤
|
||||
assert [a[2] for _, a in log][3:] == ["STAB_ROCK", "STAB_AREA", "STAB_REPORT"]
|
||||
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
"""D02 기준 지정(PLAN 529-3 · 560-2 · 579-1 · 585-1 · 590-1) — 경위도 ↔ TM 변환 · 지점 목록 API ·
|
||||
지점 = 설계 내용 파일 `points` · 옛 DB 칸(시점 · 종점 꼴 · 한 점 꼴 · 연도 행 접기)은 열 때 옮김 ·
|
||||
저장된 좌표계 그대로.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_529_3_plan_points.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import require_company
|
||||
from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon, tm_to_lonlat
|
||||
from D02_PlanBase import D02_PlanBase_Router as router_mod
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
|
||||
|
||||
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
|
||||
URL = f"/api/projects/{PID}/plan/points"
|
||||
|
||||
|
||||
# --- 좌표 변환 ---
|
||||
|
||||
|
||||
@pytest.mark.parametrize("epsg, lon0", [(5185, 125.0), (5186, 127.0), (5187, 129.0)])
|
||||
def test_origin_is_false_origin(epsg, lon0) -> None:
|
||||
x, y = lonlat_to_tm(lon0, 38.0, epsg) # 원점 = (200 000, 600 000)
|
||||
assert abs(x - 200000.0) < 0.001 and abs(y - 600000.0) < 0.001
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lon, lat", [(127.3851, 36.3504), (125.7, 34.1), (129.36, 36.02)])
|
||||
def test_round_trip_within_1mm(lon, lat) -> None:
|
||||
epsg = tm_epsg_for_lon(lon)
|
||||
x, y = lonlat_to_tm(lon, lat, epsg)
|
||||
back = lonlat_to_tm(*tm_to_lonlat(x, y, epsg), epsg)
|
||||
assert abs(back[0] - x) < 0.001 and abs(back[1] - y) < 0.001
|
||||
|
||||
|
||||
def test_belt_by_longitude() -> None:
|
||||
assert [tm_epsg_for_lon(v) for v in (124.5, 125.999, 126.0, 127.999, 128.0, 131.8)] == [
|
||||
5185,
|
||||
5185,
|
||||
5186,
|
||||
5186,
|
||||
5187,
|
||||
5187,
|
||||
]
|
||||
|
||||
|
||||
# --- 지점 정본 API ---
|
||||
|
||||
|
||||
A, B, C = (
|
||||
{"lon": 127.10, "lat": 37.50},
|
||||
{"lon": 127.12, "lat": 37.51},
|
||||
{"lon": 127.15, "lat": 37.52},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api(monkeypatch, tmp_path):
|
||||
"""파일 = tmp_path 의 진짜 파일 · 없으면 옛 DB 칸(`base_json` · 연도 행)을 옮김."""
|
||||
state = {
|
||||
"base_json": None,
|
||||
"rows": [],
|
||||
"root": tmp_path,
|
||||
"session": {"role": "USER", "email": "kim@forest.go.kr", "company_id": 1},
|
||||
}
|
||||
|
||||
async def load(project_id):
|
||||
plan = store.read_plan(tmp_path)
|
||||
return tmp_path, plan or store.migrate(state["base_json"], None, state["rows"])
|
||||
|
||||
monkeypatch.setattr(store, "load_plan", load)
|
||||
app = FastAPI()
|
||||
app.include_router(router_mod.router)
|
||||
app.dependency_overrides[require_company] = lambda: state["session"]
|
||||
client = TestClient(app)
|
||||
client.state = state
|
||||
return client
|
||||
|
||||
|
||||
def test_put_lists(api) -> None:
|
||||
got = api.put(URL, json={"points": [A, B, C]})
|
||||
assert got.status_code == 200
|
||||
data = got.json()
|
||||
assert data["epsg"] == 5186 and len(data["points"]) == 3
|
||||
assert (data["points"][0]["x"], data["points"][0]["y"]) == pytest.approx(
|
||||
lonlat_to_tm(127.10, 37.50, 5186)
|
||||
)
|
||||
end = data["points"][2]
|
||||
assert abs(end["lon"] - C["lon"]) < 1e-8 and abs(end["lat"] - C["lat"]) < 1e-8
|
||||
stored = json.loads(store.plan_path(api.state["root"]).read_text("utf-8"))
|
||||
assert stored["version"] == 1 and stored["epsg"] == 5186 and stored["lines"] == []
|
||||
assert set(stored["points"][1]) == {"x", "y"}
|
||||
|
||||
|
||||
def test_put_partial_and_empty(api) -> None:
|
||||
data = api.put(URL, json={"points": [A]}).json()
|
||||
assert len(data["points"]) == 1 and data["epsg"] == 5186
|
||||
data = api.put(URL, json={}).json()
|
||||
assert data == {"status": "success", "epsg": None, "points": []}
|
||||
assert api.put(URL, json={"points": [A] * 40}).status_code == 200
|
||||
assert api.put(URL, json={"points": [A] * 41}).status_code == 422 # 상한 40(옛 20 + 20)
|
||||
|
||||
|
||||
def test_put_keeps_stored_epsg(api) -> None:
|
||||
api.put(URL, json={"points": [A, C]})
|
||||
far = {"lon": 128.9, "lat": 37.5} # 평균이 128° 를 넘어도 저장된 5186 그대로
|
||||
data = api.put(URL, json={"points": [A, far, far]}).json()
|
||||
assert data["epsg"] == 5186
|
||||
|
||||
|
||||
def test_get_joins_old_start_end_lists(api) -> None:
|
||||
a, b, c = (lonlat_to_tm(p["lon"], p["lat"], 5186) for p in (A, B, C))
|
||||
old = {"epsg": 5186, "starts": [{"x": a[0], "y": a[1]}, {"x": b[0], "y": b[1]}],
|
||||
"ends": [{"x": c[0], "y": c[1]}]} # fmt: skip
|
||||
api.state["base_json"] = json.dumps(old)
|
||||
data = api.get(URL).json()
|
||||
assert [p["x"] for p in data["points"]] == pytest.approx([a[0], b[0], c[0]]) # 시점 뒤 종점
|
||||
|
||||
|
||||
def test_get_wraps_old_single_points(api) -> None:
|
||||
a, c = (lonlat_to_tm(p["lon"], p["lat"], 5186) for p in (A, C))
|
||||
old = {"epsg": 5186, "start": {"x": a[0], "y": a[1]}, "end": {"x": c[0], "y": c[1]}}
|
||||
api.state["base_json"] = json.dumps(old)
|
||||
data = api.get(URL).json()
|
||||
assert [p["x"] for p in data["points"]] == pytest.approx([a[0], c[0]])
|
||||
old["end"] = None
|
||||
api.state["base_json"] = json.dumps(old)
|
||||
assert [p["x"] for p in api.get(URL).json()["points"]] == pytest.approx([a[0]])
|
||||
|
||||
|
||||
def test_put_forest_only(api) -> None:
|
||||
api.state["session"] = {"role": "USER", "email": "lee@company.com", "company_id": 1}
|
||||
assert api.put(URL, json={"points": [A, B]}).status_code == 403
|
||||
api.state["session"] = {"role": "SYSTEM_ADMIN", "email": "admin@x.com", "company_id": None}
|
||||
assert api.put(URL, json={"points": [A, B]}).status_code == 200
|
||||
|
||||
|
||||
def test_get_folds_old_year_rows(api) -> None:
|
||||
epsg = 5186
|
||||
a, b, c = (lonlat_to_tm(p["lon"], p["lat"], epsg) for p in (A, B, C))
|
||||
for year, s, e in ((2026, a, b), (2027, b, c)):
|
||||
api.state["rows"].append(
|
||||
{
|
||||
"year": year,
|
||||
**{"start_x": s[0], "start_y": s[1], "end_x": e[0], "end_y": e[1]},
|
||||
"epsg": epsg,
|
||||
}
|
||||
)
|
||||
data = api.get(URL).json()
|
||||
assert data["epsg"] == epsg
|
||||
assert [p["x"] for p in data["points"]] == pytest.approx([a[0], c[0]])
|
||||
|
||||
|
||||
def test_get_empty_new_project(api) -> None:
|
||||
assert api.get(URL).json() == {"status": "success", "epsg": None, "points": []}
|
||||
|
||||
|
||||
def test_put_keeps_line_epsg(api) -> None:
|
||||
"""선이 있으면 지점을 다 지워도 좌표계 그대로(파일 머리 한 곳 · 선 좌표가 그 좌표계)."""
|
||||
api.put(URL, json={"points": [A]})
|
||||
path = store.plan_path(api.state["root"])
|
||||
plan = json.loads(path.read_text("utf-8"))
|
||||
plan["lines"] = [{"id": "main", "vertices": [[0.0, 0.0], [1.0, 0.0]], "branch": None}]
|
||||
path.write_text(json.dumps(plan), "utf-8")
|
||||
assert api.put(URL, json={}).json()["points"] == []
|
||||
assert json.loads(path.read_text("utf-8"))["epsg"] == 5186
|
||||
@@ -177,7 +177,7 @@ class _Connection:
|
||||
class _Db:
|
||||
def __init__(self):
|
||||
self.log: list = []
|
||||
self.answers: dict = {"FROM projects": [(2026,)]}
|
||||
self.answers: dict = {"FROM projects": [(2026, None, None, None)]} # 연도 · 기준점(591-2)
|
||||
|
||||
def acquire(self):
|
||||
return _Connection(self)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""D 지형 API(PLAN 530-1 · 561-2) — 범위 = 전체 시점 · 종점(D02 정본) 상자 + 여유 · 받기 = 한 벌.
|
||||
"""D 지형 API(PLAN 530-1 · 561-2 · 591-2) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 · 선이
|
||||
없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌.
|
||||
|
||||
옛 분석 API 시험(GET · PUT analysis · 꺾임점 고침)은 옛 추천 엔진과 함께 걷음(PLAN 562-2) ·
|
||||
노선 정본 시험 = `test_530_2_plan_analyze.py` · 지형 셈 시험 = `test_530_1_plan_terrain.py`.
|
||||
@@ -31,26 +32,25 @@ class _Pool:
|
||||
def client(tmp_path, monkeypatch):
|
||||
plan = {
|
||||
"epsg": 5186,
|
||||
"points": [
|
||||
{"lon": 0, "lat": 0, "x": P0[0], "y": P0[1]},
|
||||
{"lon": 0, "lat": 0, "x": P2[0], "y": P2[1]},
|
||||
],
|
||||
"points": [{"x": P0[0], "y": P0[1]}, {"x": P2[0], "y": P2[1]}],
|
||||
"lines": [],
|
||||
}
|
||||
info = {"year_start": 2026, "site": None}
|
||||
|
||||
async def load_base(project_id):
|
||||
return plan
|
||||
async def load_plan(project_id):
|
||||
return tmp_path, plan
|
||||
|
||||
async def stored(connection, project_id):
|
||||
return "1/3/p"
|
||||
async def project_info(project_id):
|
||||
return info
|
||||
|
||||
monkeypatch.setattr(router_mod.base, "load_base", load_base)
|
||||
monkeypatch.setattr(router_mod.store, "load_plan", load_plan)
|
||||
monkeypatch.setattr(router_mod.network, "project_info", project_info)
|
||||
monkeypatch.setattr(router_mod, "get_db_pool", lambda: _Pool())
|
||||
monkeypatch.setattr(router_mod, "get_project_storage_relative_path", stored)
|
||||
monkeypatch.setattr(router_mod, "resolve_stored_project_path", lambda s: str(tmp_path))
|
||||
app = FastAPI()
|
||||
app.include_router(router_mod.router)
|
||||
test = TestClient(app)
|
||||
test.plan = plan
|
||||
test.info = info
|
||||
return test
|
||||
|
||||
|
||||
@@ -63,10 +63,28 @@ def test_extent_uses_whole_ends(client) -> None:
|
||||
extra = client.get(f"{URL}/extent", params={"extra": json.dumps([[2000, 0]])}).json()
|
||||
assert extra["box_m"][2] == 2000 + extra["margin_m"]
|
||||
assert client.get(f"{URL}/extent", params={"extra": "[1]"}).status_code == 400
|
||||
client.plan["points"] = [] # 지점 빔 — 1단계로
|
||||
client.plan["points"] = [] # 지점 · 기준점 빔
|
||||
assert client.get(f"{URL}/extent").status_code == 400
|
||||
|
||||
|
||||
def test_extent_site_first(client) -> None:
|
||||
"""591-2 — 지점 · 선 없음 = 기준점 둘레 ± 1 km(처음 여유 포함) ·
|
||||
좌표계 = 파일 머리 없으면 기준점 띠."""
|
||||
from common_util.common_util_crs import lonlat_to_tm
|
||||
|
||||
client.plan.update(epsg=None, points=[])
|
||||
client.info["site"] = {"lon": 127.8, "lat": 36.3, "address": None}
|
||||
got = client.get(f"{URL}/extent").json()
|
||||
x, y = lonlat_to_tm(127.8, 36.3, 5186)
|
||||
r = router_mod.terrain.SITE_RADIUS_M
|
||||
assert got["epsg"] == 5186
|
||||
assert got["box_m"] == pytest.approx([x - r, y - r, x + r, y + r])
|
||||
# 지점이 있으면 기준점은 안 넣음(받은 범위가 이미 덮음 · 옛 프로젝트 그대로)
|
||||
client.plan.update(epsg=5186, points=[{"x": P0[0], "y": P0[1]}])
|
||||
got = client.get(f"{URL}/extent?margin_m=400").json()
|
||||
assert got["box_m"] == [P0[0] - 400, P0[1] - 400, P0[0] + 400, P0[1] + 400]
|
||||
|
||||
|
||||
def test_post_runs_one_set(client, monkeypatch) -> None:
|
||||
calls: list = []
|
||||
saved: list = []
|
||||
|
||||
@@ -21,7 +21,7 @@ def test_thumb_line_fits_unit_box_and_caps_points():
|
||||
|
||||
def test_stage_names_follow_step_bar():
|
||||
assert len(summary.stage_names("ROAD")) == 7
|
||||
assert summary.stage_names("PLAN")[0] == "기준 지정"
|
||||
assert summary.stage_names("PLAN")[0] == "노선 분석" # 591-2 기준 지정 걷음
|
||||
assert summary.stage_names("EROSION") == summary.stage_names("ROAD")
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ def test_summaries_in_batch(monkeypatch, tmp_path):
|
||||
assert a["stale_days"] == 20 and a["owner_names"] == ["엄", "박"]
|
||||
assert a["metrics"] == {"length_m": 15.0, "stations": 3, "structures": 2}
|
||||
assert len(a["thumb"]) == 3 and a["done"] is False
|
||||
assert b["thumb"] is None and b["metrics"] == {} and b["stage_name"] == "기준 지정"
|
||||
assert b["thumb"] is None and b["metrics"] == {} and b["stage_name"] == "노선 분석"
|
||||
assert db["queries"] == 5
|
||||
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ def test_board_column_road():
|
||||
|
||||
|
||||
def test_board_column_other_kinds():
|
||||
assert board_column("PLAN", stages(C, C, P)) == "confirm"
|
||||
assert board_column("PLAN", stages(C, C, C)) == "done"
|
||||
assert board_column("PLAN", stages(C, P)) == "confirm"
|
||||
assert board_column("PLAN", stages(C, C)) == "done"
|
||||
assert board_column("STABILITY", stages(C, P, N, N, N, N)) == "design"
|
||||
assert board_column("STABILITY", stages(N, N, N, N, N)) == "start"
|
||||
# 모르는 종류(사방) = 단계 없음 → 시작 전 · 설계 중
|
||||
@@ -47,7 +47,7 @@ def test_board_column_other_kinds():
|
||||
|
||||
|
||||
def test_outputs_only_when_ready():
|
||||
assert project_outputs("PLAN", stages(C, C, C)) == []
|
||||
assert project_outputs("PLAN", stages(C, C)) == []
|
||||
assert project_outputs("ROAD", stages(C, C, C, P, N, N, N)) == []
|
||||
keys = lambda st: [o["key"] for o in project_outputs("ROAD", st)] # noqa: E731
|
||||
assert keys(stages(C, C, C, C, P, N, N)) == ["drawings"]
|
||||
|
||||
@@ -21,7 +21,7 @@ def tmp_path(): # 기본 pytest 임시 폴더가 이 PC 에서 막힘 — 직
|
||||
|
||||
|
||||
def test_every_page_has_manual_first():
|
||||
for page in [f"B0{n}" for n in range(2, 10)] + [f"D0{n}" for n in range(1, 5)]:
|
||||
for page in [f"B0{n}" for n in range(2, 10)] + ["D01", "D03", "D04"]: # D02 = 591-2 걷음
|
||||
items = read_help(page)
|
||||
assert items and items[0]["key"] == "00_설명서", page
|
||||
for n in range(1, 8):
|
||||
|
||||
@@ -16,15 +16,13 @@ def _no_hex(path: str) -> None:
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", css), f"{path}: 날 색"
|
||||
|
||||
|
||||
def test_d02_parts():
|
||||
ts = _src("D02_PlanBase/D02_PlanBase_UI_Page.ts")
|
||||
css = _src("D02_PlanBase/D02_PlanBase_UI_Style.css")
|
||||
assert 'el("input"' not in ts
|
||||
assert 'createInputField({ type: "number"' in ts
|
||||
assert 'classList.add("d02-info")' in ts # 촬영 장면이 `p.d02-info` 로 잡음
|
||||
for dead in (".d02-info", "is-invalid", "font-size: var(--text-caption);\n}\n\n.d02-info"):
|
||||
assert dead not in css
|
||||
assert "data-axis" in ts or "dataset.axis" in ts
|
||||
def test_d03_points_parts():
|
||||
"""591-2 — D02 걷음 · 지점 칸 = 목록 줄 · 아이콘 단추 · 우클릭 메뉴 부품."""
|
||||
assert not (ROOT / "D02_PlanBase" / "D02_PlanBase_UI_Page.ts").exists()
|
||||
ts = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts")
|
||||
assert "createListRow(" in ts and "createIconButton(" in ts and "createMapContextMenu(" in ts
|
||||
for raw in ('createElement("button")', 'createElement("input")', 'el("input"'):
|
||||
assert raw not in ts
|
||||
|
||||
|
||||
def test_d03_parts():
|
||||
|
||||
@@ -10,7 +10,6 @@ PAGES = (
|
||||
"B08_Quantity",
|
||||
"B09_Estimation",
|
||||
"D01_PlanRegister",
|
||||
"D02_PlanBase",
|
||||
"D03_PlanAnalysis",
|
||||
"D04_PlanConfirm",
|
||||
"E01_StabRegister",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""560-4 D 단계 이동 = 저장 뒤 이동 · 572-2 B 떠남 물음 지킴 시험 — 서버 · 화면 없이 소스만 본다.
|
||||
단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로가 한 함수(beforeLeave)를 거치고 · D02 첫 불러오기 실패에도 지도를 세움.
|
||||
단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로가 한 함수(beforeLeave)를 거치고 (D02 기준 지정은 591-2 에서 걷음 · 지점 = D03).
|
||||
572-3 — D · E 페이지는 공용 가드에 {dirty, save} 를 둠(말없이 저장 없음) · 캐시 = 등록표 · 프로젝트 = currentProjectId."""
|
||||
|
||||
import re
|
||||
@@ -19,15 +19,6 @@ def test_group_step_waits_before_leave():
|
||||
assert "leave().then((ok) => ok && navigateTo(" in ts # 단계 아이콘 · [<] [>]
|
||||
|
||||
|
||||
def test_d02_leave_saves_and_builds_map_on_fail():
|
||||
ts = _src("D02_PlanBase/D02_PlanBase_UI_Page.ts")
|
||||
assert "dirty:" in ts and "save:" in ts and "ui-step-nav" not in ts # 따로 듣개 없음
|
||||
tail = ts[ts.rindex(" try {") :]
|
||||
# 불러오기 catch 뒤에 paint() — 실패해도 지도 · 표를 세움
|
||||
assert re.search(r"\} catch \(error\) \{[^}]*\}\s*active = firstEmpty\(\);\s*paint\(\);", tail)
|
||||
assert "sameBase" in ts # 옛 캐시는 그때 서버 값이 지금과 같을 때만
|
||||
|
||||
|
||||
# 572-2 B 페이지 떠남 물음 — 공용 가드에 {dirty, save} · 지금 프로젝트 = 탭마다(currentProjectId)
|
||||
B_GUARDS = {
|
||||
"B04_PreProcess/B04_PreProcess_UI_Page.ts": (
|
||||
@@ -89,7 +80,6 @@ def test_b_pages_leave_other_project_cache_to_enter():
|
||||
|
||||
# 572-3 D · E — 고침 판별 · 저장 · [폐기] 때 지울 등록표 칸(E03 = 별 저장 진행 중만 · 지울 칸 없음)
|
||||
DE_GUARDED = {
|
||||
"D02_PlanBase/D02_PlanBase_UI_Page.ts": "d02-points",
|
||||
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts": "d03-plan",
|
||||
"D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts": "d04-assignees",
|
||||
"E02_StabGround/E02_StabGround_UI_Page.ts": "e02-ground",
|
||||
@@ -104,7 +94,6 @@ DE_PAGES = [
|
||||
"E07_StabArea/E07_StabArea_UI_Page.ts",
|
||||
]
|
||||
DE_STATES = [
|
||||
"d02-points",
|
||||
"e02-ground",
|
||||
"e03-prob",
|
||||
"e04-settings",
|
||||
@@ -124,7 +113,7 @@ def test_de_pages_guard_leave():
|
||||
if name:
|
||||
assert f'discard: () => clearState("{name}", projectId)' in call, path
|
||||
# D03 — 못 읽은 채(프로젝트 없음 · 불러오기 실패) 떠나면 고침 없음
|
||||
assert "let savedJson = JSON.stringify(network);" in _src(
|
||||
assert "let savedJson = JSON.stringify({ network, points });" in _src(
|
||||
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts"
|
||||
)
|
||||
e03 = _src("E03_StabSection/E03_StabSection_UI_Page.ts")
|
||||
|
||||
@@ -160,8 +160,8 @@ def test_two_saves_do_not_interleave(monkeypatch, tmp_path):
|
||||
async def load(_pid):
|
||||
return tmp_path, store.read_plan(tmp_path) or store.empty_plan()
|
||||
|
||||
async def year_start(_pid):
|
||||
return 2026
|
||||
async def project_info(_pid):
|
||||
return {"year_start": 2026, "site": None}
|
||||
|
||||
async def slow_status(connection, project_id, pieces, years):
|
||||
order.append(f"start {len(pieces)}")
|
||||
@@ -189,7 +189,7 @@ def test_two_saves_do_not_interleave(monkeypatch, tmp_path):
|
||||
return Conn()
|
||||
|
||||
monkeypatch.setattr(store, "load_plan", load)
|
||||
monkeypatch.setattr(net, "_year_start", year_start)
|
||||
monkeypatch.setattr(net, "project_info", project_info)
|
||||
monkeypatch.setattr(net, "_store_status", slow_status)
|
||||
monkeypatch.setattr(net, "get_db_pool", lambda: Pool())
|
||||
forest = {"role": "SYSTEM_ADMIN", "email": "a@b.kr"}
|
||||
|
||||
@@ -107,8 +107,7 @@ def test_화면_지도_출처():
|
||||
assert "DesignSpeed" not in page
|
||||
|
||||
|
||||
def test_D04_연결_설계속도_안_넘김(monkeypatch):
|
||||
from D04_PlanConfirm import D04_PlanConfirm_Link as link
|
||||
|
||||
monkeypatch.setattr(link, "get_db_pool", lambda: pytest.fail("빈 설계속도는 씨앗 안 씀"))
|
||||
asyncio.run(link.seed_design_speed("road-id", None)) # 예정노선 = 빈 값 → B05 기본 20
|
||||
def test_D04_연결_설계속도_안_넘김():
|
||||
# 590-1 이 씨앗 줄을 걷음 — 연결이 설계속도를 안 넘김(B05 기본 20)
|
||||
link = (ROOT / "D04_PlanConfirm" / "D04_PlanConfirm_Link.py").read_text(encoding="utf-8")
|
||||
assert "design_speed" not in link
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""591-2 D02 기준 지정 걷음 · 지점 = D03 노선분석(요구 452 · 453) — 옛 529-3 시험 갈음.
|
||||
|
||||
- 지점 = 설계 내용 파일 `points` · D03 [저장](PUT plan/network) 몸에 실림 ·
|
||||
빈 값 = 저장된 지점 그대로 · [초기화](빈 선) 뒤에도 지점 남음 · 상한 40 · 산림청만.
|
||||
- GET = 지점(TM + 경위도) · 등록 기준점 · 좌표계(파일 머리 · 없으면 기준점 경도 TM 띠).
|
||||
- 옛 지점 칸(시점 · 종점 목록 · 한 점 꼴)은 열 때 옮겨 보임.
|
||||
- 핀 끌기 = 그 자리 노선 노드 같이(TS `moveNodesAt` · 붙은 갈래 첫 점 · 다른 점은 그대로).
|
||||
- D02 길 · 단계 · 폴더가 남지 않음.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_591_2_plan_points.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import require_company
|
||||
from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon, tm_to_lonlat
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Network as net
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
|
||||
URL = f"/api/projects/{PID}/plan/network"
|
||||
SITE = {"lon": 127.11, "lat": 37.505, "address": "경기도 어딘가 산 1"}
|
||||
A, B = lonlat_to_tm(127.10, 37.50, 5186), lonlat_to_tm(127.12, 37.51, 5186)
|
||||
LINE = {"id": "main", "vertices": [list(A), list(B)], "branch": None}
|
||||
|
||||
|
||||
# --- 좌표 변환(옛 529-3 에서 옮김) ---
|
||||
|
||||
|
||||
@pytest.mark.parametrize("epsg, lon0", [(5185, 125.0), (5186, 127.0), (5187, 129.0)])
|
||||
def test_origin_is_false_origin(epsg, lon0) -> None:
|
||||
x, y = lonlat_to_tm(lon0, 38.0, epsg) # 원점 = (200 000, 600 000)
|
||||
assert abs(x - 200000.0) < 0.001 and abs(y - 600000.0) < 0.001
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lon, lat", [(127.3851, 36.3504), (125.7, 34.1), (129.36, 36.02)])
|
||||
def test_round_trip_within_1mm(lon, lat) -> None:
|
||||
epsg = tm_epsg_for_lon(lon)
|
||||
x, y = lonlat_to_tm(lon, lat, epsg)
|
||||
back = lonlat_to_tm(*tm_to_lonlat(x, y, epsg), epsg)
|
||||
assert abs(back[0] - x) < 0.001 and abs(back[1] - y) < 0.001
|
||||
|
||||
|
||||
# --- 지점 = D03 [저장] ---
|
||||
|
||||
|
||||
class _Conn:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def begin(self):
|
||||
pass
|
||||
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
async def rollback(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Pool:
|
||||
def acquire(self):
|
||||
return _Conn()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api(monkeypatch, tmp_path):
|
||||
"""파일 = tmp_path 의 진짜 파일 · 없으면 옛 DB 칸(`base_json`)을 옮김 · 연도 상태 줄은 안 씀."""
|
||||
state = {
|
||||
"base_json": None,
|
||||
"root": tmp_path,
|
||||
"site": SITE,
|
||||
"session": {"role": "USER", "email": "kim@forest.go.kr", "company_id": 1},
|
||||
}
|
||||
|
||||
async def load(project_id):
|
||||
plan = store.read_plan(tmp_path)
|
||||
return tmp_path, plan or store.migrate(state["base_json"], None, [])
|
||||
|
||||
async def project_info(project_id):
|
||||
return {"year_start": 2026, "site": state["site"]}
|
||||
|
||||
async def no_status(*args):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(store, "load_plan", load)
|
||||
monkeypatch.setattr(net, "project_info", project_info)
|
||||
monkeypatch.setattr(net, "_store_status", no_status)
|
||||
monkeypatch.setattr(net, "get_db_pool", lambda: _Pool())
|
||||
app = FastAPI()
|
||||
app.include_router(net.router)
|
||||
app.dependency_overrides[require_company] = lambda: state["session"]
|
||||
client = TestClient(app)
|
||||
client.state = state
|
||||
return client
|
||||
|
||||
|
||||
def _saved(api) -> dict:
|
||||
return json.loads(store.plan_path(api.state["root"]).read_text("utf-8"))
|
||||
|
||||
|
||||
def test_new_project_site_and_epsg(api) -> None:
|
||||
base = api.get(URL).json()["base"]
|
||||
assert base == {"epsg": 5186, "points": [], "site": SITE} # 기준점 경도 TM 띠
|
||||
api.state["site"] = None
|
||||
assert api.get(URL).json()["base"]["epsg"] is None # 기준점 · 지점 없음 = 화면 안내
|
||||
|
||||
|
||||
def test_save_points_with_lines(api) -> None:
|
||||
body = {"epsg": 5186, "points": [list(A), list(B)], "lines": [LINE]}
|
||||
got = api.put(URL, json=body)
|
||||
assert got.status_code == 200
|
||||
points = got.json()["base"]["points"]
|
||||
assert [(p["x"], p["y"]) for p in points] == pytest.approx([A, B])
|
||||
assert abs(points[0]["lon"] - 127.10) < 1e-8 and abs(points[1]["lat"] - 37.51) < 1e-8
|
||||
saved = _saved(api)
|
||||
assert saved["epsg"] == 5186 and set(saved["points"][0]) == {"x", "y"}
|
||||
assert saved["lines"][0]["id"] == "main"
|
||||
|
||||
|
||||
def test_points_kept_when_absent_and_after_reset(api) -> None:
|
||||
api.put(URL, json={"epsg": 5186, "points": [list(A)], "lines": [LINE]})
|
||||
api.put(URL, json={"epsg": 5186, "lines": [LINE]}) # 빈 값 = 그대로
|
||||
assert len(_saved(api)["points"]) == 1
|
||||
# [초기화] = 빈 선 · 지점 실어 보냄 → 선만 지워짐
|
||||
reset = api.put(URL, json={"epsg": 5186, "points": [list(A)], "lines": []}).json()
|
||||
assert reset["network"] is None and len(reset["base"]["points"]) == 1
|
||||
assert _saved(api)["lines"] == [] and len(_saved(api)["points"]) == 1
|
||||
api.put(URL, json={"epsg": 5186, "points": [], "lines": []})
|
||||
assert _saved(api)["points"] == []
|
||||
|
||||
|
||||
def test_points_limit_and_forest_only(api) -> None:
|
||||
many = [list(A)] * 40
|
||||
assert api.put(URL, json={"epsg": 5186, "points": many, "lines": []}).status_code == 200
|
||||
many.append(list(A))
|
||||
assert api.put(URL, json={"epsg": 5186, "points": many, "lines": []}).status_code == 422
|
||||
api.state["session"] = {"role": "USER", "email": "lee@company.com", "company_id": 1}
|
||||
assert api.put(URL, json={"epsg": 5186, "points": [], "lines": []}).status_code == 403
|
||||
|
||||
|
||||
def test_epsg_stays_once_saved(api) -> None:
|
||||
api.put(URL, json={"epsg": 5186, "points": [list(A)], "lines": []})
|
||||
got = api.put(URL, json={"epsg": 5187, "points": [list(A)], "lines": []})
|
||||
assert got.status_code == 400 # 파일 머리 좌표계 한 곳
|
||||
|
||||
|
||||
def test_old_start_end_points_visible(api) -> None:
|
||||
old = {"epsg": 5186, "starts": [{"x": A[0], "y": A[1]}], "ends": [{"x": B[0], "y": B[1]}]}
|
||||
api.state["base_json"] = json.dumps(old)
|
||||
points = api.get(URL).json()["base"]["points"]
|
||||
assert [p["x"] for p in points] == pytest.approx([A[0], B[0]]) # 시점 뒤 종점
|
||||
api.state["base_json"] = json.dumps({"epsg": 5186, "start": {"x": A[0], "y": A[1]}})
|
||||
assert [p["x"] for p in api.get(URL).json()["base"]["points"]] == pytest.approx([A[0]])
|
||||
|
||||
|
||||
# --- D02 걷음 ---
|
||||
|
||||
|
||||
def test_d02_removed() -> None:
|
||||
assert not (ROOT / "D02_PlanBase" / "D02_PlanBase_Router.py").exists()
|
||||
assert "D02_PlanBase" not in (ROOT / "main.py").read_text(encoding="utf-8")
|
||||
front = (ROOT / "config" / "config_frontend.ts").read_text(encoding="utf-8")
|
||||
assert "D02_PLAN_BASE" not in front and "d02-plan-base" not in front
|
||||
from common_util.common_util_project_kind import KIND_STAGE_DIRS
|
||||
from common_util.common_util_workflow_state import STAGE_KEYS_BY_KIND
|
||||
|
||||
assert STAGE_KEYS_BY_KIND["PLAN"] == ["PLAN_ANALYSIS", "PLAN_CONFIRM"]
|
||||
assert "D02_PlanBase" not in KIND_STAGE_DIRS["PLAN"]
|
||||
for path in (
|
||||
"D01_PlanRegister/D01_PlanRegister_UI_Page.ts",
|
||||
"B01_Dashboard/B01_Dashboard_UI_View_Assigned.ts",
|
||||
):
|
||||
assert "ROUTES.D03_PLAN_ANALYSIS" in (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# --- 핀 끌기 = 노드 같이(TS) ---
|
||||
|
||||
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
_RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
|
||||
const Y = 2026;
|
||||
// 본선 (0,0)→(100,0)→(100,100) · 갈래 (100,0)→(150,0) · 다른 본선이 (100,0) 에서 시작
|
||||
let n = plan.newLine(plan.emptyNetwork(), [0, 0]);
|
||||
n = plan.appendVertex(plan.appendVertex(n, 0, [100, 0]), 0, [100, 100]);
|
||||
n = plan.appendVertex(plan.addBranch(n, 0, 1), 1, [150, 0]);
|
||||
n = plan.appendVertex(plan.newLine(n, [100, 0]), 2, [100, -50]);
|
||||
n = plan.normalizePieces(n, Y);
|
||||
const moved = plan.moveNodesAt(n, [100, 0], [110, 5]);
|
||||
const none = plan.moveNodesAt(n, [55, 55], [0, 0]);
|
||||
writeFileSync(process.argv[2], JSON.stringify({
|
||||
lines: moved.lines.map((l) => l.vertices),
|
||||
same: none === n,
|
||||
pieces: plan.normalizePieces(moved, Y).pieces.map((p) => [p.line, p.to_m]),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def _run_ts(tmp_path: Path) -> dict:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--rootDir", str(ROOT),
|
||||
"--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler",
|
||||
"--ignoreConfig"],
|
||||
cwd=str(ROOT), check=True, capture_output=True,
|
||||
) # fmt: skip
|
||||
for emitted in out.rglob("*.js"):
|
||||
emitted.write_text(
|
||||
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
result = tmp_path / "out.json"
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(result)],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_pin_drag_moves_nodes(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
main, branch, other = got["lines"]
|
||||
assert main == [[0, 0], [110, 5], [100, 100]] # 지점 자리 꺾임점
|
||||
assert branch[0] == [110, 5] and branch[1] == [150, 0] # 붙은 갈래 첫 점 같이
|
||||
assert other == [[110, 5], [100, -50]] # 같은 자리 다른 선 머리도
|
||||
assert got["same"] # 꺾임점 없는 자리 = 그대로
|
||||
lengths = dict(got["pieces"])
|
||||
# 조각 = 새 길이(1 mm 반올림)
|
||||
assert lengths["main"] == pytest.approx(math.hypot(110, 5) + math.hypot(10, 95), abs=1e-3)
|
||||
@@ -6,7 +6,7 @@
|
||||
* 상태 = 마우스 · 눌림 · 고름(`is-active` + aria-selected) · 막힘(disabled)
|
||||
* 쓰는 법 = list.append(createListRow({ label: "B1 배수관", active: true, onClick: pick }))
|
||||
* 대신하는 옛 변종 = `.ui-sidebar-row`(이 이름도 그대로 냄) · `.ui-tree__row` · `.ui-find__row` ·
|
||||
* `b07-plan-manage__row` + `__grip` · `b05-drainage__basin` · `d02-points__row` · `m02-pick__item` …
|
||||
* `b07-plan-manage__row` + `__grip` · `b05-drainage__basin` · `d03-plan__point` · `m02-pick__item` …
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_list_row.css";
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { LocaleEntry } from "./ui_template_locale_common";
|
||||
|
||||
export const ui_locales_de = {
|
||||
/* --- 단계 막대 · 빈 단계 --- */
|
||||
DE_Step_PlanBase: ["기준 지정", "Base Points"],
|
||||
DE_Step_PlanAnalysis: ["노선 분석", "Route Analysis"],
|
||||
DE_Step_PlanConfirm: ["확정 · 내보내기", "Confirm · Export"],
|
||||
DE_Step_StabGround: ["지반 조건", "Ground"],
|
||||
@@ -132,29 +131,4 @@ export const ui_locales_de = {
|
||||
DE_Link_New: ["새 임도 프로젝트로", "As a new road project"],
|
||||
DE_Link_Existing: ["기존 프로젝트에 연결", "Link to an existing project"],
|
||||
DE_Link_NoRoad: ["우리 회사 임도 프로젝트가 없습니다.", "No road project in our company."],
|
||||
|
||||
/* --- D02 기준 지정(529-3) --- */
|
||||
D02_Points_Title: ["지점 목록", "Points"],
|
||||
D02_Points_Hint: [
|
||||
"📍 로 고른 지점을 지도 누름으로 놓고 · 표지를 끌거나 경도 · 위도를 넣습니다. 연도 나누기는 노선분석에서.",
|
||||
"Pick a point with 📍 and click the map, drag markers, or type lon · lat. Split by year in analysis.",
|
||||
],
|
||||
D02_Point: ["지점", "Point"],
|
||||
D02_Add_Point: ["지점", "Point"],
|
||||
D02_Remove_Point: ["이 지점 지움", "Remove this point"],
|
||||
D02_Lon: ["경도", "Lon"],
|
||||
D02_Lat: ["위도", "Lat"],
|
||||
D02_Pick_Point: ["지도에 놓을 지점 고르기", "Pick the point to place on the map"],
|
||||
D02_Map_Hint: ["지도를 눌러 지점을 놓습니다.", "Click the map to place a point."],
|
||||
D02_Map_Type: ["바탕 지도", "Base map"],
|
||||
D02_Map_Normal: ["일반", "Map"],
|
||||
D02_Map_Satellite: ["위성", "Satellite"],
|
||||
D02_View_Only: [
|
||||
"보기 전용 — 지점은 산림청만 고칩니다.",
|
||||
"View only — only the Forest Service edits points.",
|
||||
],
|
||||
D02_Save: ["저장", "Save"],
|
||||
D02_Saved: ["지점을 저장했습니다.", "Points saved."],
|
||||
D02_Saved_Crs: ["저장 좌표계", "Saved CRS"],
|
||||
D02_Not_Saved: ["아직 저장 안 함", "Not saved yet"],
|
||||
} as const satisfies Record<string, LocaleEntry>;
|
||||
|
||||
@@ -37,7 +37,7 @@ export function themeColor(name: string, fallback: string): string {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** 585-1 예정노선 지점 색 — 그림 색 여섯 차례 돌림(지점 1 · 2 …) · 기준지정 · 노선분석 같은 번호 같은 색 */
|
||||
/** 585-1 예정노선 지점 색 — 그림 색 여섯 차례 돌림(지점 1 · 2 …) · 핀 · 목록 줄 같은 번호 같은 색 */
|
||||
const POINT_COLORS = [
|
||||
["--draw-chart-0", "#5b8def"],
|
||||
["--draw-chart-1", "#34c38f"],
|
||||
|
||||