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>
This commit is contained in:
2026-10-30 10:19:34 +09:00
co-authored by Claude Opus 5.5
parent 7d71230fca
commit 29512c001b
72 changed files with 895 additions and 1261 deletions
+1 -2
View File
@@ -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" },
+2 -2
View File
@@ -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,
+2 -4
View File
@@ -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),
+1 -1
View File
@@ -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 | 등록하지 않고 나가려면 [취소]를 누름. 대시보드로 돌아감. |
![입력 오류 안내](D01_04_errors.webp)
+1 -1
View File
@@ -17,7 +17,7 @@
| 사업 연도 끝 | [노선 분석](D03/00_설명서)에서 구역마다 연도를 고르고 [저장]하면 가장 늦은 연도로 정해짐 |
| 임도 종류 · 설계속도 | 받지 않음. [확정 · 내보내기](D04/00_설명서) 뒤 업체가 임도 프로젝트로 연결하면 임도설계 기본값(작업임도 · 20 km/h)으로 시작하고, 임도 프로젝트에서 바꿈 |
| 노선 시작 · 종료 측점 | 받지 않음. 측점은 선마다 No.0 부터 새로 셈 |
| 시점 · 종점 | [기준 지정](D02/00_설명서)에서 지도로 정함 |
| 지점 | [노선 분석](D03/00_설명서) 지도 우클릭 「지점 놓기」 |
## 프로젝트명 자동 조합
-34
View File
@@ -1,34 +0,0 @@
# 기준 지정
예정노선 1단계. 사업 전체의 시점과 종점, 두 점을 지도 위에 정하는 화면. 연도는 나누지 않음.
## 여는 길
- [예정노선 등록](D01/00_설명서)에서 [등록]을 누르면 바로 열림.
- 대시보드 프로젝트 표에서 예정노선 줄의 단계 막대 「기준 지정」을 눌러도 열림.
- 이미 만든 예정노선은 어느 단계 화면에서든 ☰ 에서 「기준 지정」을 골라 돌아옴. [단계 이동 메뉴](D02/06_단계_이동_메뉴) 참고.
- 두 점 저장은 산림청만 할 수 있음. 지정받은 다른 회사는 보기만 가능함.
![화면 한눈에](D02_00_overview.webp)
## 화면 구역
| 번호 | 구역 | 하는 일 |
| --- | --- | --- |
| 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 @@
# 시점 · 종점 표
왼쪽 패널의 「전체 시점 · 종점」 카드. 점마다 줄 하나로 이름 · 📍 · 경도 · 위도를 보여 줌.
## 여는 길
- 기준 지정 화면 왼쪽 패널 맨 위의 「전체 시점 · 종점」 카드. 제목 오른쪽 ▼ 를 누르면 접고 펼침.
![시점 · 종점 표](D02_01_points_table.webp)
그림 번호 — 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 @@
# 지도에서 지점 놓기
지도를 눌러 시점 · 종점을 놓고, 놓은 표지를 끌어 옮기는 방법.
## 여는 길
- 기준 지정 화면 가운데 지도와 왼쪽 「전체 시점 · 종점」 카드의 📍 단추.
![시점과 종점을 이은 지도](D02_02_map.webp)
그림 번호 — 1 지도 위 안내 글
## 단계
| 단계 | 할 일 |
| --- | --- |
| 1 | 놓을 점을 고름. 화면이 열리면 비어 있는 첫 줄이 자동으로 골라져 강조됨. 다른 줄에 놓으려면 그 줄의 📍 를 누름. |
| 2 | 지도에서 놓을 자리를 누름. 그 자리에 표지가 놓이고 표의 경도 · 위도가 채워짐. |
| 3 | 놓고 나면 다음 빈 점이 자동으로 놓을 점이 됨. |
| 4 | 놓은 뒤 고치려면 표지를 마우스로 끌어 옮김. 표의 값이 따라 바뀜. |
| 5 | 지도는 빈 곳을 끌어 옮기고 휠로 확대 · 축소함. |
![놓을 지점으로 고른 줄](D02_03_pick_point.webp)
그림 번호 — 1 📍 단추 · 2 강조된 줄(놓을 점)
![점을 고르지 않고 지도를 누른 모습](D02_04_map_toast.webp)
그림 번호 — 1 「지도에 놓을 지점 고르기」 안내
## 보이는 것
- 두 점이 모두 놓이면 둘 사이에 선이 이어짐. 표지 위에는 「시점」 · 「종점」 이름이 붙음.
- 놓을 점을 고르지 않고 지도를 누르면 「지도에 놓을 지점 고르기」 안내가 나오고 아무것도 놓이지 않음.
- 점이 하나도 없으면 우리나라 가운데쯤이 먼저 보임. 처음 점을 놓으면 그 점에 맞춰 보이고, 그 뒤로는 사용자가 맞춘 보기(중심 · 확대)를 그대로 둠.
- 지도는 카카오맵을 씀.
- 산림청이 아닌 계정은 지도를 눌러도 놓이지 않고 표지도 끌 수 없음. 지도 위 안내 글이 「보기 전용 — 지점은 산림청만 고칩니다.」로 바뀜.
> 주의: 놓은 점은 [저장]이나 [>]를 누르거나 다른 단계로 옮겨 가기 전까지 서버에 남지 않음. [단추와 저장](D02/05_단추와_저장) 참고.
@@ -1,28 +0,0 @@
# 좌표 직접 입력
다른 프로그램에서 구한 좌표를 표의 경도 · 위도 칸에 직접 넣는 방법.
## 여는 길
- 왼쪽 「전체 시점 · 종점」 카드의 줄마다 있는 경도 · 위도 두 칸.
## 단계
| 단계 | 할 일 |
| --- | --- |
| 1 | 고칠 줄의 경도 칸에 값을 넣음. |
| 2 | 위도 칸에도 값을 넣고 칸 밖을 누르거나 Tab 키를 누름. |
| 3 | 두 값이 범위 안이면 점이 옮겨지고 지도 표지가 따라 움직임. |
| 4 | 범위 밖이면 두 칸이 빨갛게 표시되고 반영되지 않음. 값을 고쳐 다시 넣음. |
![범위 밖 값 안내](D02_05_coordinate_error.webp)
그림 번호 — 1 경도 칸(범위 밖 값 200을 넣은 모습) · 2 위도 칸
## 보이는 것
- 경도는 120 ~ 135, 위도는 30 ~ 45 범위만 받음.
- 두 칸이 모두 범위 안일 때 반영됨. 한 칸만 넣으면 반영되지 않음.
- 반영될 때 소수 7 자리(약 1 cm)로 반올림됨.
> 주의: 좌표는 경도 · 위도(WGS84)로 넣음. 평면좌표(X · Y)를 그대로 넣으면 범위 밖으로 처리됨.
@@ -1,27 +0,0 @@
# 일반 · 위성 지도
지도 오른쪽 위 [일반] [위성] 단추로 바탕 지도를 바꾸는 방법.
## 여는 길
- 기준 지정 화면 지도 오른쪽 위 두 칸짜리 단추.
![일반 · 위성 단추](D02_08_map_type.webp)
그림 번호 — 1 [일반] [위성] 단추(위성을 고른 모습)
## 단계
| 단계 | 할 일 |
| --- | --- |
| 1 | [위성]을 누르면 위성 사진 위에 지명이 겹쳐 보이는 지도로 바뀜. |
| 2 | 도로 · 하천 이름을 보고 싶으면 [일반]을 누름. |
| 3 | 바꿔도 놓은 점 · 선은 그대로 있음. |
## 보이는 것
- 고른 값은 이 컴퓨터의 화면 취향으로 기억되어, 다음에 열어도 같은 지도로 열림.
- 두 지도 모두 카카오맵이며, 위성은 지명이 함께 나오는 종류임.
- 바탕 지도는 점을 놓는 데 도움을 주는 용도이며 저장되는 값과는 상관 없음.
> 참고: 노선 분석 화면의 바탕은 이 지도가 아니라 받은 위성 사진과 등고선임. [노선 분석](D03/00_설명서) 참고.
@@ -1,31 +0,0 @@
# 단추와 저장
왼쪽 패널 맨 아래 단추 줄 — [?] · [<] · [>] · [저장]과 저장 때 일어나는 일.
## 여는 길
- 기준 지정 화면 왼쪽 패널 맨 아래.
![단추 줄](D02_06_buttons.webp)
그림 번호 — 1 [?] 가이드 · 2 [<] 이전 단계(첫 단계라 흐림) · 3 [>] 다음 단계 · 4 [저장]
## 단계
| 단계 | 할 일 |
| --- | --- |
| 1 | 시점과 종점을 모두 채움. |
| 2 | [저장]을 누름. 두 점이 서버에 저장되고 「지점을 저장했습니다.」 알림이 뜸. |
| 3 | 바로 다음 단계로 가려면 [>]를 누름. 바뀐 것이 있으면 저장한 뒤 [노선 분석](D03/00_설명서) 화면이 열림. |
| 4 | 저장하지 못하면(예: 점이 비어 있음) 이 화면에 머묾. |
## 보이는 것
- 저장하면 표 아래 한 줄이 「저장 좌표계 EPSG:번호」와 시점의 X · Y(m)로 바뀜.
- 다른 단계로 옮길 때(단계 아이콘 · [<] · [>] · 머리 ☰ 메뉴 · 브라우저 뒤로) 고친 것이 있으면 묻지 않고 저장한 뒤 옮겨 감. 저장이 실패하면 옮기지 않고 머묾.
- 산림청이 아닌 계정은 [저장]이 안 보이고, [>]는 저장 없이 그냥 다음 화면으로 감.
- 이미 노선 분석에서 그은 선은 두 점을 옮겨 저장해도 지우지 않음.
> 주의: 두 점의 평균 경도가 126° 나 128° 선을 넘게 옮기면 저장 좌표계가 바뀜. 자세한 내용은 [쓰인 방법과 근거](D02/20_쓰인_방법과_근거) 참고.
> 참고: 연도 범위는 이 화면에서 다루지 않음. 노선 분석에서 구역마다 연도를 고름.
@@ -1,27 +0,0 @@
# 단계 이동 메뉴
☰ 로 진행 단계를 열어 대시보드나 다른 단계 화면으로 옮겨 가는 방법.
## 여는 길
- 화면 왼쪽 위의 ☰ 를 누름.
![진행 단계 메뉴](D02_07_steps_menu.webp)
그림 번호 — 1 진행 단계 목록(대시보드 · 기준 지정 · 노선 분석 · 확정 · 내보내기)
## 단계
| 단계 | 할 일 |
| --- | --- |
| 1 | ☰ 를 누르면 왼쪽에 메뉴가 열림. |
| 2 | 가야 할 단계를 누름. 지금 단계는 강조되어 있음. |
| 3 | 메뉴 밖 어두운 곳을 누르면 닫힘. |
## 보이는 것
- 맨 위 「대시보드」 = 프로젝트 목록 화면으로 돌아감.
- 단계는 기준 지정 → 노선 분석 → 확정 · 내보내기 순.
- 메뉴 아래에는 홈페이지 바로가기 · 언어 · 테마 · 로그아웃이 같이 있음.
> 참고: 기준 지정에서 점을 고친 채 메뉴로 다른 단계에 가도 묻지 않고 저장한 뒤 옮겨 감. 저장이 실패하면 이 화면에 머묾.
-30
View File
@@ -1,30 +0,0 @@
# 칸 뜻
![시점 · 종점 표](D02_01_points_table.webp)
그림 번호는 [시점 · 종점 표](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_설명서)을 볼 것.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

-11
View File
@@ -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}
]
+2 -2
View File
@@ -4,9 +4,9 @@
## 여는 길
- [기준 지정](D02/00_설명서) 화면에서 [>]를 누르면 두 점을 저장하고 이 화면이 열림.
- [예정노선 등록](D01/00_설명서)에서 [등록]을 누르면 이 화면이 열림(옛 기준 지정 단계는 걷힘).
- 대시보드 프로젝트 표의 단계 막대 「노선 분석」을 누르거나, ☰ 에서 「노선 분석」을 골라도 열림.
- 시점 · 종점이 비어 있으면 「시점 · 종점이 비었습니다 — 1단계(기준 지정)에서 정하세요.」 안내만 나오고 진행이 안 됨.
- 지점은 지도 우클릭 「지점 놓기」로 놓음 · 핀을 끌면 그 자리 노선 점도 같이 옮김 · 지점 없이 긋기 = 우클릭 「여기서 긋기 시작」.
- 긋기 · 지형 받기 · 저장은 산림청만 할 수 있음. 그 밖의 계정은 「보기만 — 산림청 계정에서 긋습니다.」로 보기만 가능함.
![화면 한눈에](D03_00_overview.webp)
+1 -1
View File
@@ -26,7 +26,7 @@
## 보이는 것
- 받을 범위 = 기준 지정의 시점 · 종점을 감싸는 상자에 여유를 더한 것. 여유 처음 값은 350 m 이고 300 ~ 5000 m 까지 쓸 수 있음. 이미 받은 범위가 있으면 그것과 합쳐서 받음.
- 받을 범위 = 처음엔 등록 기준점 둘레 1 km · 그 뒤 지점 · 선 · 지도 보기가 받은 범위 밖으로 가면 조용히 이어 받음(지점 · 선을 감싸는 상자에 여유를 더한 것). 여유 처음 값은 350 m 이고 300 ~ 5000 m 까지 쓸 수 있음. 이미 받은 범위가 있으면 그것과 합쳐서 받음.
- 요약 한 줄 = 「여유 350 m · 도엽 2매(새로 2) · 지형 칸 약 2.1백만」. 도엽은 수치지형도 조각이며 「새로」는 아직 못 받은 조각 수.
- 받기에는 수치지형도(도엽)와 위성 사진이 들어 있음. 위성 사진을 못 받아도 받기가 멈추지 않음. 위성이 없으면 어두운 바탕에 시점 · 종점과 선만 보임.
- 지형 받기는 서버 뒤에서 도는 일이라, 화면을 닫았다 다시 열어도 도는 중이면 이어서 진행률을 보여 줌.
+1 -1
View File
@@ -14,7 +14,7 @@
| 단계 | 할 일 |
| --- | --- |
| 1 | [본선 긋기]를 누름. 단추 글자가 [긋기 끝]으로 바뀌고 선 색이 짙어짐. 처음에는 기준 지정의 시점에서 시작함. |
| 1 | [본선 긋기]를 누름. 단추 글자가 [긋기 끝]으로 바뀌고 선 색이 짙어짐. 지점 · 선 끝 · 선 위에서 시작함(빈 땅은 우클릭 「여기서 긋기 시작」). |
| 2 | 마우스를 움직이면 마지막 점에서 마우스까지 점선이 따라오고, 옆에 길이(m)와 기울기(%)가 보임. |
| 3 | 놓을 자리에서 누름. 그 자리에 점이 찍히고 선이 이어짐. 같은 방법으로 점을 이어 감. |
| 4 | 종점 가까이 가서 누르면 종점에 붙으며 본선이 끝남. 아래 「보이는 것」 참고. |
@@ -8,7 +8,7 @@
![단추 줄](D03_15_buttons.webp)
그림 번호 — 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
+1 -1
View File
@@ -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),
+2 -2
View File
@@ -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,
);
};
-88
View File
@@ -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)}
-314
View File
@@ -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();
}
-48
View File
@@ -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;
}
-16
View File
@@ -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,
};
+6 -3
View File
@@ -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: {
+57 -13
View File
@@ -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}
+16 -11
View File
@@ -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]
+57 -18
View File
@@ -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";
+4 -4
View File
@@ -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);
+189 -162
View File
@@ -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(
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -7
View File
@@ -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 = [
-2
View File
@@ -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)
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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"]
-170
View File
@@ -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
+1 -1
View File
@@ -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)
+31 -13
View File
@@ -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"]
+1 -1
View File
@@ -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):
+7 -9
View File
@@ -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():
-1
View File
@@ -10,7 +10,6 @@ PAGES = (
"B08_Quantity",
"B09_Estimation",
"D01_PlanRegister",
"D02_PlanBase",
"D03_PlanAnalysis",
"D04_PlanConfirm",
"E01_StabRegister",
+2 -13
View File
@@ -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")
+3 -3
View File
@@ -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"}
+4 -5
View File
@@ -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
+248
View File
@@ -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)
+1 -1
View File
@@ -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";
-26
View File
@@ -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>;
+1 -1
View File
@@ -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"],