diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 93edcdb56..e04aeca2e 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -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" }, diff --git a/A00_Common/group_workflow.ts b/A00_Common/group_workflow.ts index f6d86daf2..019e7c7c3 100644 --- a/A00_Common/group_workflow.ts +++ b/A00_Common/group_workflow.ts @@ -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, diff --git a/A00_Common/key_registry.ts b/A00_Common/key_registry.ts index 9ae2a0d2b..e9e624aa5 100644 --- a/A00_Common/key_registry.ts +++ b/A00_Common/key_registry.ts @@ -20,7 +20,6 @@ export type KeyScope = | "drainage" | "section" | "plan" - | "d02" | "b04" | "cad" | "b08" @@ -49,7 +48,6 @@ export const KEY_SCOPES: Record = { 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), diff --git a/A05_EduDetail/guide/D01/00_설명서.md b/A05_EduDetail/guide/D01/00_설명서.md index d77fbd4ae..8045196cb 100644 --- a/A05_EduDetail/guide/D01/00_설명서.md +++ b/A05_EduDetail/guide/D01/00_설명서.md @@ -24,7 +24,7 @@ 2. 시작 연도와 사업 지역을 넣음. [사업 연도 · 지역](D01/02_사업연도_지역) 참고. 3. 프로젝트명을 확인함. [프로젝트명](D01/03_프로젝트명) 참고. 4. [등록]을 누름. 문제가 있는 칸은 아래에 빨간 안내가 나옴. [입력 오류와 등록](D01/04_입력오류와_등록) 참고. -5. 등록되면 바로 다음 화면인 [기준 지정](D02/00_설명서)이 열림. +5. 등록되면 바로 다음 화면인 [노선 분석](D03/00_설명서)이 열리고 기준점 둘레 1 km 지형을 받음. > 주의: 등록 직후에는 시점 · 종점이 비어 있음. 두 점을 정하고 저장해야 노선 분석 단계로 갈 수 있음. diff --git a/A05_EduDetail/guide/D01/01_새_프로젝트_여는_길.md b/A05_EduDetail/guide/D01/01_새_프로젝트_여는_길.md index 65bd86194..443730efe 100644 --- a/A05_EduDetail/guide/D01/01_새_프로젝트_여는_길.md +++ b/A05_EduDetail/guide/D01/01_새_프로젝트_여는_길.md @@ -23,4 +23,4 @@ - 카드 넷 = 예정노선 · 임도설계 · 사방설계 · 안정성평가. 사방설계는 「준비 중」 표시가 붙어 눌러도 열리지 않음. - 산림청이 아닌 계정은 「예정노선」 카드가 흐리게 보임. 마우스를 올리면 까닭이 말풍선으로 보이고, 눌러도 「예정노선은 산림청(시스템 관리자 · go.kr 메일)만 만들 수 있습니다.」 안내만 나옴. -> 참고: 이미 만든 예정노선은 대시보드 프로젝트 표에 「예정노선」 종류로 나옴. 표의 단계 막대(기준 지정 · 노선 분석 · 확정 · 내보내기)를 누르면 그 단계 화면으로 감. +> 참고: 이미 만든 예정노선은 대시보드 프로젝트 표에 「예정노선」 종류로 나옴. 표의 단계 막대(노선 분석 · 확정 · 내보내기)를 누르면 그 단계 화면으로 감. diff --git a/A05_EduDetail/guide/D01/04_입력오류와_등록.md b/A05_EduDetail/guide/D01/04_입력오류와_등록.md index 55b314b88..49747fc4d 100644 --- a/A05_EduDetail/guide/D01/04_입력오류와_등록.md +++ b/A05_EduDetail/guide/D01/04_입력오류와_등록.md @@ -12,7 +12,7 @@ | --- | --- | | 1 | 칸을 모두 채운 뒤 [등록]을 누름. | | 2 | 문제가 있으면 그 칸 아래에 빨간 안내가 나오고 등록되지 않음. 고친 뒤 다시 [등록]. | -| 3 | 문제가 없으면 프로젝트가 만들어지고 「프로젝트를 만들었습니다.」 알림과 함께 [기준 지정](D02/00_설명서) 화면이 열림. | +| 3 | 문제가 없으면 프로젝트가 만들어지고 「프로젝트를 만들었습니다.」 알림과 함께 [노선 분석](D03/00_설명서) 화면이 열림. | | 4 | 등록하지 않고 나가려면 [취소]를 누름. 대시보드로 돌아감. | ![입력 오류 안내](D01_04_errors.webp) diff --git a/A05_EduDetail/guide/D01/10_칸_뜻.md b/A05_EduDetail/guide/D01/10_칸_뜻.md index 2085c6b31..373e42fb7 100644 --- a/A05_EduDetail/guide/D01/10_칸_뜻.md +++ b/A05_EduDetail/guide/D01/10_칸_뜻.md @@ -17,7 +17,7 @@ | 사업 연도 끝 | [노선 분석](D03/00_설명서)에서 구역마다 연도를 고르고 [저장]하면 가장 늦은 연도로 정해짐 | | 임도 종류 · 설계속도 | 받지 않음. [확정 · 내보내기](D04/00_설명서) 뒤 업체가 임도 프로젝트로 연결하면 임도설계 기본값(작업임도 · 20 km/h)으로 시작하고, 임도 프로젝트에서 바꿈 | | 노선 시작 · 종료 측점 | 받지 않음. 측점은 선마다 No.0 부터 새로 셈 | -| 시점 · 종점 | [기준 지정](D02/00_설명서)에서 지도로 정함 | +| 지점 | [노선 분석](D03/00_설명서) 지도 우클릭 「지점 놓기」 | ## 프로젝트명 자동 조합 diff --git a/A05_EduDetail/guide/D02/00_설명서.md b/A05_EduDetail/guide/D02/00_설명서.md deleted file mode 100644 index 994708646..000000000 --- a/A05_EduDetail/guide/D02/00_설명서.md +++ /dev/null @@ -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_설명서) 참고. - -> 주의: 서버에는 [저장]이나 [>]를 눌러 저장하거나, 다른 단계로 옮겨 갈 때 저장되어야 반영됨. 화면 안에서 점을 놓기만 해서는 서버에 남지 않음. - -> 참고: 연도는 이 화면에서 나누지 않음. 노선 분석에서 그은 선에 구역을 나누고 구역마다 연도를 고름. diff --git a/A05_EduDetail/guide/D02/01_시점_종점_표.md b/A05_EduDetail/guide/D02/01_시점_종점_표.md deleted file mode 100644 index 29fabd0dd..000000000 --- a/A05_EduDetail/guide/D02/01_시점_종점_표.md +++ /dev/null @@ -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_쓰인_방법과_근거) 참고. diff --git a/A05_EduDetail/guide/D02/02_지도에서_지점_놓기.md b/A05_EduDetail/guide/D02/02_지도에서_지점_놓기.md deleted file mode 100644 index 441bb7b67..000000000 --- a/A05_EduDetail/guide/D02/02_지도에서_지점_놓기.md +++ /dev/null @@ -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_단추와_저장) 참고. diff --git a/A05_EduDetail/guide/D02/03_좌표_직접_입력.md b/A05_EduDetail/guide/D02/03_좌표_직접_입력.md deleted file mode 100644 index e93bedc3b..000000000 --- a/A05_EduDetail/guide/D02/03_좌표_직접_입력.md +++ /dev/null @@ -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)를 그대로 넣으면 범위 밖으로 처리됨. diff --git a/A05_EduDetail/guide/D02/04_일반_위성.md b/A05_EduDetail/guide/D02/04_일반_위성.md deleted file mode 100644 index 2f47abb63..000000000 --- a/A05_EduDetail/guide/D02/04_일반_위성.md +++ /dev/null @@ -1,27 +0,0 @@ -# 일반 · 위성 지도 - -지도 오른쪽 위 [일반] [위성] 단추로 바탕 지도를 바꾸는 방법. - -## 여는 길 - -- 기준 지정 화면 지도 오른쪽 위 두 칸짜리 단추. - -![일반 · 위성 단추](D02_08_map_type.webp) - -그림 번호 — 1 [일반] [위성] 단추(위성을 고른 모습) - -## 단계 - -| 단계 | 할 일 | -| --- | --- | -| 1 | [위성]을 누르면 위성 사진 위에 지명이 겹쳐 보이는 지도로 바뀜. | -| 2 | 도로 · 하천 이름을 보고 싶으면 [일반]을 누름. | -| 3 | 바꿔도 놓은 점 · 선은 그대로 있음. | - -## 보이는 것 - -- 고른 값은 이 컴퓨터의 화면 취향으로 기억되어, 다음에 열어도 같은 지도로 열림. -- 두 지도 모두 카카오맵이며, 위성은 지명이 함께 나오는 종류임. -- 바탕 지도는 점을 놓는 데 도움을 주는 용도이며 저장되는 값과는 상관 없음. - -> 참고: 노선 분석 화면의 바탕은 이 지도가 아니라 받은 위성 사진과 등고선임. [노선 분석](D03/00_설명서) 참고. diff --git a/A05_EduDetail/guide/D02/05_단추와_저장.md b/A05_EduDetail/guide/D02/05_단추와_저장.md deleted file mode 100644 index e9bea838e..000000000 --- a/A05_EduDetail/guide/D02/05_단추와_저장.md +++ /dev/null @@ -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_쓰인_방법과_근거) 참고. - -> 참고: 연도 범위는 이 화면에서 다루지 않음. 노선 분석에서 구역마다 연도를 고름. diff --git a/A05_EduDetail/guide/D02/06_단계_이동_메뉴.md b/A05_EduDetail/guide/D02/06_단계_이동_메뉴.md deleted file mode 100644 index 3535ccd54..000000000 --- a/A05_EduDetail/guide/D02/06_단계_이동_메뉴.md +++ /dev/null @@ -1,27 +0,0 @@ -# 단계 이동 메뉴 - -☰ 로 진행 단계를 열어 대시보드나 다른 단계 화면으로 옮겨 가는 방법. - -## 여는 길 - -- 화면 왼쪽 위의 ☰ 를 누름. - -![진행 단계 메뉴](D02_07_steps_menu.webp) - -그림 번호 — 1 진행 단계 목록(대시보드 · 기준 지정 · 노선 분석 · 확정 · 내보내기) - -## 단계 - -| 단계 | 할 일 | -| --- | --- | -| 1 | ☰ 를 누르면 왼쪽에 메뉴가 열림. | -| 2 | 가야 할 단계를 누름. 지금 단계는 강조되어 있음. | -| 3 | 메뉴 밖 어두운 곳을 누르면 닫힘. | - -## 보이는 것 - -- 맨 위 「대시보드」 = 프로젝트 목록 화면으로 돌아감. -- 단계는 기준 지정 → 노선 분석 → 확정 · 내보내기 순. -- 메뉴 아래에는 홈페이지 바로가기 · 언어 · 테마 · 로그아웃이 같이 있음. - -> 참고: 기준 지정에서 점을 고친 채 메뉴로 다른 단계에 가도 묻지 않고 저장한 뒤 옮겨 감. 저장이 실패하면 이 화면에 머묾. diff --git a/A05_EduDetail/guide/D02/10_칸_뜻.md b/A05_EduDetail/guide/D02/10_칸_뜻.md deleted file mode 100644 index a7d3eb0ff..000000000 --- a/A05_EduDetail/guide/D02/10_칸_뜻.md +++ /dev/null @@ -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 자리) | diff --git a/A05_EduDetail/guide/D02/20_쓰인_방법과_근거.md b/A05_EduDetail/guide/D02/20_쓰인_방법과_근거.md deleted file mode 100644 index 9d6a23b22..000000000 --- a/A05_EduDetail/guide/D02/20_쓰인_방법과_근거.md +++ /dev/null @@ -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_설명서)을 볼 것. diff --git a/A05_EduDetail/guide/D02/D02_00_overview.webp b/A05_EduDetail/guide/D02/D02_00_overview.webp deleted file mode 100644 index 361ce4a0f..000000000 Binary files a/A05_EduDetail/guide/D02/D02_00_overview.webp and /dev/null differ diff --git a/A05_EduDetail/guide/D02/D02_01_points_table.webp b/A05_EduDetail/guide/D02/D02_01_points_table.webp deleted file mode 100644 index 501ee7a2f..000000000 Binary files a/A05_EduDetail/guide/D02/D02_01_points_table.webp and /dev/null differ diff --git a/A05_EduDetail/guide/D02/D02_02_map.webp b/A05_EduDetail/guide/D02/D02_02_map.webp deleted file mode 100644 index d64a2158b..000000000 Binary files a/A05_EduDetail/guide/D02/D02_02_map.webp and /dev/null differ diff --git a/A05_EduDetail/guide/D02/D02_03_pick_point.webp b/A05_EduDetail/guide/D02/D02_03_pick_point.webp deleted file mode 100644 index b1ef35f24..000000000 Binary files a/A05_EduDetail/guide/D02/D02_03_pick_point.webp and /dev/null differ diff --git a/A05_EduDetail/guide/D02/D02_04_map_toast.webp b/A05_EduDetail/guide/D02/D02_04_map_toast.webp deleted file mode 100644 index 3ec43a8f6..000000000 Binary files a/A05_EduDetail/guide/D02/D02_04_map_toast.webp and /dev/null differ diff --git a/A05_EduDetail/guide/D02/D02_05_coordinate_error.webp b/A05_EduDetail/guide/D02/D02_05_coordinate_error.webp deleted file mode 100644 index 32d50c0fd..000000000 Binary files a/A05_EduDetail/guide/D02/D02_05_coordinate_error.webp and /dev/null differ diff --git a/A05_EduDetail/guide/D02/D02_06_buttons.webp b/A05_EduDetail/guide/D02/D02_06_buttons.webp deleted file mode 100644 index a157ce22e..000000000 Binary files a/A05_EduDetail/guide/D02/D02_06_buttons.webp and /dev/null differ diff --git a/A05_EduDetail/guide/D02/D02_07_steps_menu.webp b/A05_EduDetail/guide/D02/D02_07_steps_menu.webp deleted file mode 100644 index 3f14136c2..000000000 Binary files a/A05_EduDetail/guide/D02/D02_07_steps_menu.webp and /dev/null differ diff --git a/A05_EduDetail/guide/D02/D02_08_map_type.webp b/A05_EduDetail/guide/D02/D02_08_map_type.webp deleted file mode 100644 index 7d59b2272..000000000 Binary files a/A05_EduDetail/guide/D02/D02_08_map_type.webp and /dev/null differ diff --git a/A05_EduDetail/guide/D02/_scenes.json b/A05_EduDetail/guide/D02/_scenes.json deleted file mode 100644 index b6623dead..000000000 --- a/A05_EduDetail/guide/D02/_scenes.json +++ /dev/null @@ -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} -] diff --git a/A05_EduDetail/guide/D03/00_설명서.md b/A05_EduDetail/guide/D03/00_설명서.md index db2ee9934..693895035 100644 --- a/A05_EduDetail/guide/D03/00_설명서.md +++ b/A05_EduDetail/guide/D03/00_설명서.md @@ -4,9 +4,9 @@ ## 여는 길 -- [기준 지정](D02/00_설명서) 화면에서 [>]를 누르면 두 점을 저장하고 이 화면이 열림. +- [예정노선 등록](D01/00_설명서)에서 [등록]을 누르면 이 화면이 열림(옛 기준 지정 단계는 걷힘). - 대시보드 프로젝트 표의 단계 막대 「노선 분석」을 누르거나, ☰ 에서 「노선 분석」을 골라도 열림. -- 시점 · 종점이 비어 있으면 「시점 · 종점이 비었습니다 — 1단계(기준 지정)에서 정하세요.」 안내만 나오고 진행이 안 됨. +- 지점은 지도 우클릭 「지점 놓기」로 놓음 · 핀을 끌면 그 자리 노선 점도 같이 옮김 · 지점 없이 긋기 = 우클릭 「여기서 긋기 시작」. - 긋기 · 지형 받기 · 저장은 산림청만 할 수 있음. 그 밖의 계정은 「보기만 — 산림청 계정에서 긋습니다.」로 보기만 가능함. ![화면 한눈에](D03_00_overview.webp) diff --git a/A05_EduDetail/guide/D03/01_지형_받기.md b/A05_EduDetail/guide/D03/01_지형_받기.md index 1adb5a899..c9c1f8c4b 100644 --- a/A05_EduDetail/guide/D03/01_지형_받기.md +++ b/A05_EduDetail/guide/D03/01_지형_받기.md @@ -26,7 +26,7 @@ ## 보이는 것 -- 받을 범위 = 기준 지정의 시점 · 종점을 감싸는 상자에 여유를 더한 것. 여유 처음 값은 350 m 이고 300 ~ 5000 m 까지 쓸 수 있음. 이미 받은 범위가 있으면 그것과 합쳐서 받음. +- 받을 범위 = 처음엔 등록 기준점 둘레 1 km · 그 뒤 지점 · 선 · 지도 보기가 받은 범위 밖으로 가면 조용히 이어 받음(지점 · 선을 감싸는 상자에 여유를 더한 것). 여유 처음 값은 350 m 이고 300 ~ 5000 m 까지 쓸 수 있음. 이미 받은 범위가 있으면 그것과 합쳐서 받음. - 요약 한 줄 = 「여유 350 m · 도엽 2매(새로 2) · 지형 칸 약 2.1백만」. 도엽은 수치지형도 조각이며 「새로」는 아직 못 받은 조각 수. - 받기에는 수치지형도(도엽)와 위성 사진이 들어 있음. 위성 사진을 못 받아도 받기가 멈추지 않음. 위성이 없으면 어두운 바탕에 시점 · 종점과 선만 보임. - 지형 받기는 서버 뒤에서 도는 일이라, 화면을 닫았다 다시 열어도 도는 중이면 이어서 진행률을 보여 줌. diff --git a/A05_EduDetail/guide/D03/02_본선_긋기.md b/A05_EduDetail/guide/D03/02_본선_긋기.md index 09fdcc73b..d103b8b70 100644 --- a/A05_EduDetail/guide/D03/02_본선_긋기.md +++ b/A05_EduDetail/guide/D03/02_본선_긋기.md @@ -14,7 +14,7 @@ | 단계 | 할 일 | | --- | --- | -| 1 | [본선 긋기]를 누름. 단추 글자가 [긋기 끝]으로 바뀌고 선 색이 짙어짐. 처음에는 기준 지정의 시점에서 시작함. | +| 1 | [본선 긋기]를 누름. 단추 글자가 [긋기 끝]으로 바뀌고 선 색이 짙어짐. 지점 · 선 끝 · 선 위에서 시작함(빈 땅은 우클릭 「여기서 긋기 시작」). | | 2 | 마우스를 움직이면 마지막 점에서 마우스까지 점선이 따라오고, 옆에 길이(m)와 기울기(%)가 보임. | | 3 | 놓을 자리에서 누름. 그 자리에 점이 찍히고 선이 이어짐. 같은 방법으로 점을 이어 감. | | 4 | 종점 가까이 가서 누르면 종점에 붙으며 본선이 끝남. 아래 「보이는 것」 참고. | diff --git a/A05_EduDetail/guide/D03/08_저장_초기화.md b/A05_EduDetail/guide/D03/08_저장_초기화.md index 85228e856..08d849852 100644 --- a/A05_EduDetail/guide/D03/08_저장_초기화.md +++ b/A05_EduDetail/guide/D03/08_저장_초기화.md @@ -8,7 +8,7 @@ ![단추 줄](D03_15_buttons.webp) -그림 번호 — 1 [?] 가이드 · 2 [<] 이전 단계(기준 지정) · 3 [>] 다음 단계(확정 · 내보내기) · 4 [저장] · 5 [초기화] +그림 번호 — 1 [?] 가이드 · 2 [<] 이전 단계(첫 단계라 흐림) · 3 [>] 다음 단계(확정 · 내보내기) · 4 [저장] · 5 [초기화] ## 단계 @@ -29,7 +29,7 @@ - 저장하면 구역 연도로 연도별 노선이 만들어지고, 사업 연도 끝은 구역 연도 중 가장 늦은 해가 됨. 서버가 저장된 선으로 다시 셈하므로 화면 값을 그대로 받아 적지 않음. - 저장에 실패하면 「저장하지 못했습니다 — 까닭」이 뜨고 그은 것은 그대로 남음. 구역에 업체를 이미 지정했다면 그 구역을 없애는 저장은 막힘. - 단계 아이콘 · [<] · [>] · 머리 ☰ 메뉴 · 브라우저 뒤로로 떠날 때도 바뀐 것이 있으면 저장한 뒤 옮겨 감. 저장이 실패하면 이 화면에 머묾. -- [초기화] 확인 창: 「그은 선 · 갈래 · 구역을 모두 지우고 시점 · 종점만 남깁니다. 저장된 노선도 바뀝니다.」 [초기화]를 누르면 시점 · 종점만 남은 빈 선으로 저장되고 「시점 · 종점만 남겼습니다.」 알림이 뜸. [취소]면 아무것도 바뀌지 않음. +- [초기화] 확인 창: 「그은 선 · 갈래 · 구역을 모두 지웁니다(지점은 남김). 저장된 노선도 바뀝니다.」 [초기화]를 누르면 지점만 남은 빈 노선으로 저장되고 「선을 지웠습니다(지점은 남김).」 알림이 뜸. [취소]면 아무것도 바뀌지 않음. - 산림청이 아닌 계정은 [저장] · [초기화]가 보이지 않음. > 주의: [초기화]는 서버에 저장된 노선까지 비움. 이미 업체를 지정한 구역이 있으면 막힐 수 있음. diff --git a/A05_EduDetail/guide/D03/20_쓰인_방법과_근거.md b/A05_EduDetail/guide/D03/20_쓰인_방법과_근거.md index cca7cfc7e..226b1c8e1 100644 --- a/A05_EduDetail/guide/D03/20_쓰인_방법과_근거.md +++ b/A05_EduDetail/guide/D03/20_쓰인_방법과_근거.md @@ -4,7 +4,7 @@ ## 선을 만드는 방법 -- 선은 자동으로 찾지 않고 사용자가 점을 찍어 직선으로 이음. 본선은 기준 지정의 시점에서 시작하고 종점에서 끝나며, 갈래는 기존 선의 점에서 갈라짐. +- 선은 자동으로 찾지 않고 사용자가 점을 찍어 직선으로 이음. 본선은 지점 · 빈 땅에서 시작하고, 갈래는 기존 선의 점에서 갈라짐. - 구역 마크는 선 위 거리(m)로 기록됨. 마크 사이 구간이 구역(조각)이고, 구역마다 연도를 가짐. 구역 이름은 「선 이름 + 측점 범위」임. - 측점은 선마다 20 m 간격으로 0+0 부터 셈. 갈래도 0+0 부터 따로 셈. - 선의 높이는 저장하지 않고, 필요할 때 받은 지형에서 읽음. diff --git a/B01_Dashboard/B01_Dashboard_Router_Overview.py b/B01_Dashboard/B01_Dashboard_Router_Overview.py index 43db71d98..5511f5a17 100644 --- a/B01_Dashboard/B01_Dashboard_Router_Overview.py +++ b/B01_Dashboard/B01_Dashboard_Router_Overview.py @@ -23,7 +23,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 diff --git a/B01_Dashboard/B01_Dashboard_Summary.py b/B01_Dashboard/B01_Dashboard_Summary.py index 659b8c433..e2a0b2089 100644 --- a/B01_Dashboard/B01_Dashboard_Summary.py +++ b/B01_Dashboard/B01_Dashboard_Summary.py @@ -25,7 +25,7 @@ THUMB_POINTS = 100 # 단계 이름 = 화면 단계 막대 글(ui_template_locale_common · _de) 한국어 · 모르는 종류 = 임도 STAGE_NAMES: dict[str, tuple[str, ...]] = { "ROAD": ("파일입력", "전처리", "노선설계", "횡단설계", "상세설계", "수량산출", "원가계산"), - "PLAN": ("기준 지정", "노선 분석", "확정 · 내보내기"), + "PLAN": ("노선 분석", "확정 · 내보내기"), "STABILITY": ("지반 조건", "2D 해석", "3D 해석", "암반 해석", "광역 위험", "보고서"), } diff --git a/B01_Dashboard/B01_Dashboard_UI_View_Assigned.ts b/B01_Dashboard/B01_Dashboard_UI_View_Assigned.ts index 43213c7ac..71fb73505 100644 --- a/B01_Dashboard/B01_Dashboard_UI_View_Assigned.ts +++ b/B01_Dashboard/B01_Dashboard_UI_View_Assigned.ts @@ -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), diff --git a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts index 63e1e2f0d..83cc164b6 100644 --- a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts +++ b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts @@ -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, ); }; diff --git a/D02_PlanBase/D02_PlanBase_Router.py b/D02_PlanBase/D02_PlanBase_Router.py deleted file mode 100644 index e4d6f679d..000000000 --- a/D02_PlanBase/D02_PlanBase_Router.py +++ /dev/null @@ -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)} diff --git a/D02_PlanBase/D02_PlanBase_UI_Page.ts b/D02_PlanBase/D02_PlanBase_UI_Page.ts deleted file mode 100644 index 8dd8ef89b..000000000 --- a/D02_PlanBase/D02_PlanBase_UI_Page.ts +++ /dev/null @@ -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; - -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(path: string, init?: RequestInit): Promise { - 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 { - 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 { - showLoadingOverlay(); - try { - const body = { points: points.filter(Boolean) }; - adopt( - await request(`/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("[data-kakao-map-hint]"); - pointsHint.textContent = t("D02_View_Only"); - if (mapHint) mapHint.textContent = t("D02_View_Only"); - } - adopt(await request(`/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(); -} diff --git a/D02_PlanBase/D02_PlanBase_UI_Style.css b/D02_PlanBase/D02_PlanBase_UI_Style.css deleted file mode 100644 index 05df7de07..000000000 --- a/D02_PlanBase/D02_PlanBase_UI_Style.css +++ /dev/null @@ -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; -} diff --git a/D02_PlanBase/draft/D02_PlanBase_Route.ts b/D02_PlanBase/draft/D02_PlanBase_Route.ts deleted file mode 100644 index 36678ce0d..000000000 --- a/D02_PlanBase/draft/D02_PlanBase_Route.ts +++ /dev/null @@ -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, -}; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 6ee03e820..398513963 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -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: { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Network.py b/D03_PlanAnalysis/D03_PlanAnalysis_Network.py index 214bfcfa1..734991d93 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Network.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Network.py @@ -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} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 664f075ba..6059b375e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -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]]: diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py index d59576bf3..866937b52 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py @@ -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] diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 2904f5874..25439f9da 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -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> } + | { 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"); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index 06f8a033f..a5ef6888a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -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"; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts index 1c60f4cc6..abe6298fe 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -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 = { 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); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index e9c262a17..a3c8746c4 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -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 => new Promise((done) => setTimeout(done, ms)); @@ -84,15 +98,19 @@ const errorText = (error: unknown): string => export async function renderD03PlanAnalysis(root: HTMLElement): Promise { 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 viewCore: Vertex[] = []; // 지도 보기 가운데 상자 두 귀(넓으면 빈 것) + let viewTimer = 0; + let lastTried = ""; // 같은 받기를 거듭하지 않음 + let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음) let busy = false; let failed: string | null = null; let measuring = false; @@ -129,7 +147,44 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { 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 +205,19 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { 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 +233,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { 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 +246,32 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { 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 +279,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { 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 +290,39 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { 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, + ]; + + /** 점들이 받은 범위 안인가. */ + const inside = (list: Vertex[]): boolean => !!have && - [...points, ...allVertices()].some( - ([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3], - ); + list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]); + const outside = (): boolean => !!have && !inside([...points, ...allVertices()]); + + /** 조용히 받기(요구 453) — 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 · + * 단추 · 막는 창 없음 · 실패 = [다시 받기] · 같은 요청은 한 번만. */ + async function ensureCovered(): Promise { + if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return; + const need = [...points, ...allVertices()]; + if (inside(need) && inside(viewCore)) 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 +336,32 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { }); 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 { - let extent: TerrainExtent; + async function download(extra: Vertex[]): Promise { + 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 { - 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 +373,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { failed = job.error; await Promise.all([loadSurface(), backdrop.load()]); refresh(); + await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기 } /** 지형 받기를 끝날 때까지 지켜봄 — 진행은 지형 칸에. */ @@ -401,14 +406,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { async function loadSurface(): Promise { 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 = extent.have_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 +421,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { 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[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 +453,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { } } - /** [초기화] = 시점 · 종점만 남은 빈 선을 정본에. */ + /** [초기화] = 선만 지운 빈 노선을 정본에 · 지점은 남김(요구 453). */ async function reset(): Promise { const ok = await askConfirm({ title: "초기화", - message: - "그은 선 · 갈래 · 구역을 모두 지우고 시점 · 종점만 남깁니다. 저장된 노선도 바뀝니다.", + message: "그은 선 · 갈래 · 구역을 모두 지웁니다(지점은 남김). 저장된 노선도 바뀝니다.", okLabel: "초기화", danger: true, }); @@ -459,12 +465,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { 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 +496,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { (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 +537,47 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { } }; 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("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)}`; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts new file mode 100644 index 000000000..3cb3b662f --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts @@ -0,0 +1,209 @@ +/* ============================================================================= + * 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) { + const [sx, sy] = map.toScreen(params.points()[pin]); // 핀 머리를 눌러도 지점 자리에서 + items.push([`${name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, 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, + ); + }, + }; +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts index 9ccc40037..ba5b9eafc 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts @@ -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( diff --git a/common_util/common_util_project_kind.py b/common_util/common_util_project_kind.py index 85cc4568c..a4fac3c91 100644 --- a/common_util/common_util_project_kind.py +++ b/common_util/common_util_project_kind.py @@ -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", diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index 789f37eba..87a177e5e 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -22,11 +22,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", diff --git a/config/config_frontend.ts b/config/config_frontend.ts index c65487277..3c25f5238 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -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 = [ diff --git a/main.py b/main.py index 7528307aa..aef378cd7 100644 --- a/main.py +++ b/main.py @@ -75,7 +75,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 @@ -494,7 +493,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) diff --git a/resources/tester/helper_280_2_step_nav.cjs b/resources/tester/helper_280_2_step_nav.cjs index 799750e73..50b4c210b 100644 --- a/resources/tester/helper_280_2_step_nav.cjs +++ b/resources/tester/helper_280_2_step_nav.cjs @@ -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", diff --git a/resources/tester/test_280_2_step_nav.py b/resources/tester/test_280_2_step_nav.py index 2e8ae7087..2a53f17e7 100644 --- a/resources/tester/test_280_2_step_nav.py +++ b/resources/tester/test_280_2_step_nav.py @@ -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] diff --git a/resources/tester/test_401_project_isolation.py b/resources/tester/test_401_project_isolation.py index 7a3b677d7..2976b3919 100644 --- a/resources/tester/test_401_project_isolation.py +++ b/resources/tester/test_401_project_isolation.py @@ -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 diff --git a/resources/tester/test_528_project_groups.py b/resources/tester/test_528_project_groups.py index 928e94d01..f76ea1be1 100644 --- a/resources/tester/test_528_project_groups.py +++ b/resources/tester/test_528_project_groups.py @@ -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"] diff --git a/resources/tester/test_529_3_plan_points.py b/resources/tester/test_529_3_plan_points.py deleted file mode 100644 index 5ca5193bc..000000000 --- a/resources/tester/test_529_3_plan_points.py +++ /dev/null @@ -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 diff --git a/resources/tester/test_530_2_plan_analyze.py b/resources/tester/test_530_2_plan_analyze.py index a8c9afb64..27768f1d6 100644 --- a/resources/tester/test_530_2_plan_analyze.py +++ b/resources/tester/test_530_2_plan_analyze.py @@ -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) diff --git a/resources/tester/test_530_2_plan_router.py b/resources/tester/test_530_2_plan_router.py index c47ed35c0..483c6932e 100644 --- a/resources/tester/test_530_2_plan_router.py +++ b/resources/tester/test_530_2_plan_router.py @@ -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 = [] diff --git a/resources/tester/test_547_1_project_summary.py b/resources/tester/test_547_1_project_summary.py index 76b7f1543..05a7b296d 100644 --- a/resources/tester/test_547_1_project_summary.py +++ b/resources/tester/test_547_1_project_summary.py @@ -22,7 +22,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") @@ -122,7 +122,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 diff --git a/resources/tester/test_548_project_overview.py b/resources/tester/test_548_project_overview.py index ecc2ee329..bf3493f62 100644 --- a/resources/tester/test_548_project_overview.py +++ b/resources/tester/test_548_project_overview.py @@ -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"] diff --git a/resources/tester/test_54_2_help_files.py b/resources/tester/test_54_2_help_files.py index 0580109bc..fb929e2c7 100644 --- a/resources/tester/test_54_2_help_files.py +++ b/resources/tester/test_54_2_help_files.py @@ -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): diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 6e06ad974..b6ff5235e 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -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(): diff --git a/resources/tester/test_556_2_page_bg.py b/resources/tester/test_556_2_page_bg.py index f78248c03..f6a8c7ded 100644 --- a/resources/tester/test_556_2_page_bg.py +++ b/resources/tester/test_556_2_page_bg.py @@ -10,7 +10,6 @@ PAGES = ( "B08_Quantity", "B09_Estimation", "D01_PlanRegister", - "D02_PlanBase", "D03_PlanAnalysis", "D04_PlanConfirm", "E01_StabRegister", diff --git a/resources/tester/test_560_4_leave_save.py b/resources/tester/test_560_4_leave_save.py index da0a61373..20fc22a06 100644 --- a/resources/tester/test_560_4_leave_save.py +++ b/resources/tester/test_560_4_leave_save.py @@ -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") diff --git a/resources/tester/test_590_1_plan_file.py b/resources/tester/test_590_1_plan_file.py index f9a78ccc2..2cd15e56f 100644 --- a/resources/tester/test_590_1_plan_file.py +++ b/resources/tester/test_590_1_plan_file.py @@ -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"} diff --git a/resources/tester/test_591_1_plan_site.py b/resources/tester/test_591_1_plan_site.py index 611ae5a5f..0b7cc63a7 100644 --- a/resources/tester/test_591_1_plan_site.py +++ b/resources/tester/test_591_1_plan_site.py @@ -108,5 +108,6 @@ def test_화면_지도_출처(): 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 # 예정노선 설계속도 씨앗 걷음(590-1) → B05 기본 20 + assert "design_speed" not in link diff --git a/resources/tester/test_591_2_plan_points.py b/resources/tester/test_591_2_plan_points.py new file mode 100644 index 000000000..aa5684508 --- /dev/null +++ b/resources/tester/test_591_2_plan_points.py @@ -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) diff --git a/ui_template/ui_template_list_row.ts b/ui_template/ui_template_list_row.ts index 7b421b28d..ccc622632 100644 --- a/ui_template/ui_template_list_row.ts +++ b/ui_template/ui_template_list_row.ts @@ -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"; diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index e7bd4603e..b23b12a3c 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -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; diff --git a/ui_template/ui_template_palette.ts b/ui_template/ui_template_palette.ts index 01e377d13..93420d4dd 100644 --- a/ui_template/ui_template_palette.ts +++ b/ui_template/ui_template_palette.ts @@ -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"],