From 41102574605e1bb902994619e3860bf3482fc501 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 19:38:59 +0900 Subject: [PATCH 01/18] =?UTF-8?q?chore(=EA=B3=84=ED=9A=8D):=20PLAN=20616?= =?UTF-8?q?=20=C2=B7=20620=20=EC=9D=B4=EA=B4=80=20=E2=80=94=200=EC=B0=A8?= =?UTF-8?q?=EA=B3=A1=20=EC=B2=AB=20=EB=B2=88=200.4=20s=20=C2=B7=20?= =?UTF-8?q?=EA=B8=89=EA=B2=BD=EC=82=AC=20=EC=B9=A0=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20B06=20=EA=B4=80=20=EC=98=B5=EC=85=98=20=EC=B9=B8=20?= =?UTF-8?q?=EA=B9=8C=EB=8B=AD=20=EB=B6=84=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9 --- docs/raw/plans/2026-10-31_plan_616_620.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/raw/plans/2026-10-31_plan_616_620.md diff --git a/docs/raw/plans/2026-10-31_plan_616_620.md b/docs/raw/plans/2026-10-31_plan_616_620.md new file mode 100644 index 000000000..a4a48478c --- /dev/null +++ b/docs/raw/plans/2026-10-31_plan_616_620.md @@ -0,0 +1,22 @@ +# PLAN 616 · 620 이관 — 급경사 · 0차곡 첫 계산 빠르게 · B06 관 옵션 칸 까닭 분석(요구 497 · 529) + +## 616 급경사 · 0차곡 첫 계산 빠르게 (요구 497 · 529 승인 · 진행) + +- 근거: `docs/raw/요구_1025/_분석_497_급경사_0차곡_속도.md` — 급경사 = 이미 빠름(처음 0.2~0.7 s · 화면 0.75 s) · 0차곡 = 범위 바뀐 첫 번만 느림(D 3.5 s · 용화 11.9 s · 둘째 0.3~0.5 s) · 옛 지형 프로젝트(10-30 21:55 앞 받음)는 창고 안 씀 → 미리 계산 끝나도 안 빨라짐 · 0차곡 첫 셈이 서버 한 프로세스를 붙잡아 급경사 0.2 → 2.6 s · 화면 쪽 느림 없음. +- [x] 616-1 0차곡 셈 = 창고 격자에서(옛 격자 갈래 걷음 · `D03_PlanAnalysis_ZeroOrder.py:218 · 293`) — 확인 = 용화 11.9 → 약 3.7 s · 결과 선 전후 대조 + - 검증완료 — 용화(창고 격자) 4,585 / 4,584 가닥 · 230.89 / 230.84 km · 검증 D(옛 격자 통째 15.0 s) 5,619 / 5,650 가닥(−0.5 %) · 273.1 / 277.9 km(−1.7 % · 창고 지형 대 사업 laplace 원천 차) · 누적 열쇠 판 그대로(전국 누적 다시 셈 없음) · 파생 판 3 (66f9c68a1) +- [x] 616-2 0차곡 선을 도엽마다 미리 뽑아 창고에(`zero_lines_`) · 요청 = 이어 붙여 자름 · 전국 미리 계산 단계에 덧 — 확인 = 선 뽑기 1.9 s → 0.1~0.3 s + - 검증완료 — 용화 둘레 100 도엽 zero_lines(일꾼 3 · 155 s · 장당 가운데 4.8 s · 29 KB → 전국 약 0.4 GB) · 함수 용화 0.18 s · D 0.24~0.29 s · HTTP 0차곡 첫 번 용화 0.41 · D 0.61 s · 둘째 0.30 · 0.41 s · 기본 임계는 파생 저장본 안 씀 · 시험 = 도엽 선 이어 붙임 대 상자 통째 1 % 안 · 겹침 0 (66f9c68a1) + - 쓰는 법 — 전국 = `tools/precompute_terrain.py --steps zero_lines --workers N`(두 PC zero 단계 다 끝난 뒤 · 이웃 누적 없으면 그 이웃 빼고 셈 · 나중에 이웃 누적이 생기면 요청 때 일꾼이 그 도엽 선 다시) +- [x] 616-3 무거운 첫 셈을 서버 본체 밖(프로세스 풀) — 확인 = 0차곡 계산 중 급경사 요청 2.6 → 0.2 s + - 검증완료 — 0차곡 일꾼 셈(임계 1003 · 5.06 s) 도중 급경사 0.23 · 0.09 · 0.07 · 0.10 s · 일꾼 하나 113 MB(main.py 안 읽음) · 일꾼 죽임 → 다음 요청 새로 띄움(6.6 s) · 서버 다시 켬 → 옛 일꾼 따라 끝남 · 급경사 첫 셈은 스레드 그대로(0.2~0.7 s · numpy · PIL 이 GIL 놓음 · 일꾼에 0차곡 뒤 줄 서면 더 느림) (5d6d7bbb8) +- [x] 616-4 급경사 칠하기 = 기다림 없는 길(toBlob 대신) · 값 그림 바이너리 — 확인 = 최대 1 s 대기 없음 · 전송 −23 % + - 검증완료 — 멈춘 화면 범위 [적용] 다시 칠함 158~197 ms(옛 toBlob 최대 1,046 ms) · 급경사 응답 = PNG 바이트 용화 1.81 MB · D 2.50 MB(옛 base64 JSON 본문 −25 % · 전송은 PNG 가 이미 압축이라 옛 gzip JSON 과 거의 같음 −1 % · 「−23 %」는 base64 를 gzip 이 되돌린 몫) · 서버 gzip 0.1 s 덜 · 칸 수 4,432,752 서버와 같음 · ORCA 검증_sub2_D 급경사 · 0차곡 5,619 가닥 보임 · 시험 608 · 612 · 617-1 · 618 · 584-1 · 616 51 통과 · 569-1 · 581-1 19 통과 · typecheck 0 (5d6d7bbb8) + - 이음 — `GET …/plan/layers/raster?kind=slope` = `image/png` + 머리 `X-Plan-Raster`(JSON · counts · box · box_m · width · height) · `fetchPlanRaster` 가 `image` = blob 주소로 돌려줌(겉 꼴 그대로) · 음영기복 · 산사태 = JSON 그대로 +- 뒤로: 0차곡 · 급경사 보는 화면 조각(넓은 범위용) = 615 전후 결과 보고 정함. + +## 620 B06 [저장] 때 관 지점 옛 옵션 칸 지워짐 (sub8 알림 · 분석 끝 = `_분석_620` · 고침은 623 으로) + +- 근거: `docs/raw/요구_내역서/_점검_통합길.md` 3장 ⑩ — 집수정 `inlet_revet_*`(돌쌓기 10 m)가 [저장] 뒤 사라지고 동물이동형 기본 true 들어감 · B 그룹(요구 491 조심) · 사용자 승인 뒤 착수. +- [x] 620-1 까닭 찾기 — 데이터 손실 아님 · 까닭 79ca22082 + d5d9c5f66 · 고칠 자리 `B04_PreProcess_Router_Basins.py` `_apply_new_defaults` → 623 + From a19425e721c9df674aae09493cfbab8b67697e02 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:01:57 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat(D03=20=EC=A7=80=EB=8F=84=20=EA=B2=B9?= =?UTF-8?q?):=20=EC=82=B0=EC=82=AC=ED=83=9C=EC=9C=84=ED=97=98=20=EB=B2=94?= =?UTF-8?q?=EB=A1=80=20=E3=80=8C=EC=82=B0=EC=82=AC=ED=83=9C=20n=EB=93=B1?= =?UTF-8?q?=EA=B8=89=E3=80=8D=20=EC=9D=B4=EB=A6=84=20=EB=B6=99=EC=9E=84=20?= =?UTF-8?q?=C2=B7=20=EA=B2=80=EC=A6=9D=20=EC=B0=BD=EA=B5=AC=20layers().zoo?= =?UTF-8?q?m(GL=20=EC=A4=8C)=20=EB=8D=A7=20(PLAN=20622-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts | 2 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts index ccf4dce8b..b23ea6a82 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -166,7 +166,7 @@ export const ZONE_COLORS: Record = { }; /** 산사태위험지도 범례(1 등급 = 가장 위험 · 색 = WMS 그림 색). */ export const LANDSLIDE_LEGEND: LegendItem[] = [1, 2, 3, 4, 5].map((grade) => ({ - label: `${grade}등급`, + label: `산사태 ${grade}등급`, color: `var(--draw-map-landslide-grade-${grade})`, })); /** 선 겹 — 이름 · 그림 꼴(서버 `LINE_KINDS` 열쇠 = `kind` · 없으면 0차곡). 새 선 겹 = 여기 한 줄. */ diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index 825bee8a0..26718c2fa 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -121,6 +121,8 @@ export interface PlanLayers { counts: Record; /** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */ gl: Record; + /** GL 줌(GL 끔 = null · 622-2 검증). */ + zoom: number | null; /** 겹이 받은 범위(마지막 `load` 값) · 차례 번호(475 검증). */ area: [number, number, number, number] | null; turn: number; @@ -672,6 +674,7 @@ export function createPlanLayers(opts: { : gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length, ]), ), + zoom: gl.map()?.getZoom() ?? null, area, turn: areaTurn, tiles: tiles.state(), From 993cae613ebb198bcb4667f73754d33b130efa47 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:04:09 +0900 Subject: [PATCH 03/18] =?UTF-8?q?chore(=EA=B3=84=ED=9A=8D):=20PLAN=20622?= =?UTF-8?q?=20=EC=9D=B4=EA=B4=80=20=E2=80=94=20=EA=B5=AD=EC=9C=A0=EB=A6=BC?= =?UTF-8?q?=20=EC=9E=84=EB=8F=84=20=C2=B7=20=EC=82=B0=EB=A6=BC=EC=9E=85?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=C2=B7=20=EA=B2=BD=EC=A0=9C=EB=A6=BC=20?= =?UTF-8?q?=C2=B7=20=EC=83=9D=ED=83=9C=EC=9E=90=EC=97=B0=EB=8F=84=20=C2=B7?= =?UTF-8?q?=20=EC=82=B0=EC=82=AC=ED=83=9C=20=EC=A7=80=EB=8F=84=20=EA=B2=B9?= =?UTF-8?q?(=EC=A4=8C=EB=B3=84=20=EC=A1=B0=EA=B0=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9 --- docs/raw/plans/2026-10-31_plan_622.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/raw/plans/2026-10-31_plan_622.md diff --git a/docs/raw/plans/2026-10-31_plan_622.md b/docs/raw/plans/2026-10-31_plan_622.md new file mode 100644 index 000000000..fc17f1bd5 --- /dev/null +++ b/docs/raw/plans/2026-10-31_plan_622.md @@ -0,0 +1,15 @@ +# PLAN 622 이관 — 받은 산림 · 환경 자료 = 예정노선 지도 겹(요구 529) + +## 622 받은 산림 · 환경 자료 = 예정노선 지도 겹(줌별 조각) (요구 529 · 진행) + +- 근거: PLAN 621 확보 목록 · 615 등고선 줌별 조각 꼴(`common_util/common_util_contour_tiles.py` · 창고 · 잠금 · 지문 · 미리 만들기) · 산사태 WMS `D03_PlanAnalysis_Landslide.py`(지금 = 받은 범위 한 장). +- 이음: 벡터 `GET /api/projects/{id}/plan/vt/{name}/{z}/{x}/{y}.pbf`(name = forest_road · forest_soil · eco_forest) · 그림 `GET /api/projects/{id}/plan/wmt/{name}/{z}/{x}/{y}.png`(name = ecology · landslide · 서버가 대리 받아 기억) · 창고 `storage/_shared/vector_tiles//<지문>/` · `storage/_shared/wms_tiles//`. +- 이음(조각 칸 · sub3 화면이 읽음): 벡터 겹 이름(source-layer) = name 그대로 · forest_road `kind`(정수 1 간선 · 2 작업 · 3 산불 = FRRD_DV_B) `name`(FRRD_NM) · forest_soil `code`(글 두 자리 = SLTP_CD 「02」) · eco_forest `own`(글 「국유」 · 「사유」 = ecoFrst_01 · 02) · 벡터 z8~12 서버 · 13 이상 = 12 늘려 씀 · 그림 z6~19 256 px 투명 PNG. +- 원자료: `resources/data_forest/임도망도/<시도>/` 국가임도 선 · `산림입지토양도_1-25000/` 면 · `경제림육성단지/` 면(국유 · 사유) · 생태자연도 오픈API(`.env` `DATA_GO_KR_API_KEY`) · 산사태 생활안전지도 WMS(`SAFEMAP_API_KEY`). +- 정함: 새 라우터 파일(`D03_PlanAnalysis_Router.py` 651 줄 · 늘리지 않음) · 화면 새 줄은 `UI_MapGl.ts` 또는 새 파일(`UI_Layers.ts` 688 줄 늘리지 않음) · 산림입지도 화면 = 받은 전국 파일 조각(브이월드 받기 요약은 621-3 정할 때까지 그대로) · 색 `--draw-map-*` · 지방 · 민간임도 = 사용자 입력(이번 범위 밖). +- 이음 확정(622-1 · 위 줄 따름 + 덧): forest_road 선 {kind 1|2|3 · name · width m · year} · forest_soil 면 {code · name 토양형 이름} · eco_forest 면 {own 국유|사유 · name 단지명} · MVT 512 px · 범위 4096 · 벡터 서버 z8~15(줌별 단순화 · 13 이상도 셈 — 화면 maxzoom 15 가능) · z8~15 밖 = 400 · 자료 줌 밖(forest_soil z8~9) = 빈 조각 · 모르는 name = 404 / 그림 z6~19 · 키 없음 = 404 detail 까닭 · 빈 · 오류 조각 = 투명 PNG + 머리 `X-Tile-Empty: empty|error` · Cache-Control 300 s +- [x] 622-1 서버(sub1) — 공용 벡터 조각(SHP 원자료 · 줌별 단순화 · 남길 칸 · 미리 z8~12) · WMS 조각 대리(생태자연도 · 산사태 · 기억) · 시험 + - 검증완료 — 시험 13 · 615 시험 9 통과 · typecheck 0 · 조각 셈(z10 · z13 · z15) 용화: 임도 0.59 s 12 KB · 0.02 s 1 KB · 0.01 s / 토양 0.95 s 192 KB · 0.04 s 13 KB · 0.02 s 2 KB / 경제림 0.37 s 52 KB · 0.18 s 2 KB · 0.11 s 0.5 KB · 강원 평창 산지 같은 꼴(토양 z10 0.77 s 137 KB) · 둘째 = 파일 31~38 ms(8001) · 색인 처음 한 번 임도 4 s 27 MB · 경제림 18 s 203 MB · 토양 91 s 1.7 GB · 전국 낮은 줌 조각 어림 임도 1,078 · 토양 2,659(z10~12) · 경제림 1,960 ≈ 5,700 개 · 약 170 MB(시험 지역 용화 106 조각 만듦) · 그림 처음 · 둘째 = 산사태 0.25~0.51 s · 11~53 ms · 생태자연도 2~3 s(가끔 8~15 s · 대기 30 s) · 64 ms · 키 없음 404 · 로그인 없음 401 · 커밋 251dfd9a5 +- [x] 622-2 화면(sub3) — 예정노선 겹 다섯(국유림 임도 · 산림토양도 · 경제림 · 생태자연도 · 산사태) 고정 소스 · 범례 · 켬끔 · 밝음 · 어두움 · ORCA 수치 + - 검증완료 — typecheck 0 · 시험 88 통과 · ORCA 용화 다섯 겹 켬 → 0.3 s 안 그림(z12 임도 53 · 토양 477~539 · 경제림 128 지물) · 서버 조각 화면 하나 z9 2.9 MB / z12 3.8 MB / z15 1.0 MB(생태자연도 첫 대리 최대 7.6 s · 뒤 기억) · 켬 때 가장 긴 틈 54 ms · 산사태 조각 = 옛 한 장 같은 등급 94.6 %(밀면 80 % — 자리 같음) · 옛 캔버스 한 장 1,375 px 그대로 · 밝음 · 어두움 · 범례 색 = 조각 색 + From 689727fc98a0f99afd9fbea9f795ee07a906b15e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:07:06 +0900 Subject: [PATCH 04/18] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC):=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20531=20=EC=9B=90=EB=AC=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=E2=80=94=20=EC=9A=94=EA=B5=AC=EC=84=9C=20530=20?= =?UTF-8?q?=EC=A7=84=ED=96=89(=EA=B2=B0=EC=A0=95=20=EA=B6=8C=ED=95=A8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9 --- docs/raw/요구_1025/사용자요구_1025.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1025/사용자요구_1025.md b/docs/raw/요구_1025/사용자요구_1025.md index b27d5fbb6..cbe378bed 100644 --- a/docs/raw/요구_1025/사용자요구_1025.md +++ b/docs/raw/요구_1025/사용자요구_1025.md @@ -1110,3 +1110,7 @@ CPU는 더늘리기는 힘들어보이고 GPU 사용을 위해 부품사용에 - 노선이 변경된이후 다시 예정노선 추적 로직을 다시 계산함. 초기화값 변경 없음. 파일입력 페이지에서 받은 예정노선이 초기화 기준임. - 노선추천의 재계산하는 경우가 없음. - 평면도의 노선변경은 상당히 복잡한 로직으로 B05/B06이 연계되어 있음. 그러니 수정을 할때는 평면노선 값만 변경이 되도록 잘 조사할 것. + +## 요구 531 (10-31 · 사용자) + +요구서 530 진행해줘. From 5d4737210ab9b9c28f4ee71ce92894f953c4e26f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:12:48 +0900 Subject: [PATCH 05/18] =?UTF-8?q?feat(D03=20=EA=B8=8B=EA=B8=B0):=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EA=B8=B8=EC=9D=B4=C2=B7=EA=B8=B0?= =?UTF-8?q?=EC=9A=B8=EA=B8=B0=20=EA=B3=A0=EC=A0=95=20=C2=B7=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=9E=90=EC=9C=A0=EC=A7=80=EC=A0=95=20=EC=98=A4?= =?UTF-8?q?=EB=A5=B8=EC=AA=BD=20=C2=B7=20Tab=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EB=B0=94=EA=BF=88=20=C2=B7=20=EC=95=88=20=EC=93=B0=EB=8A=94=20?= =?UTF-8?q?=EC=B9=B8=20=EC=88=A8=EA=B9=80=20(PLAN=20625-1=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20530=20=C2=B7=20531)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 방식 차례 = 자유지정 · 길이·기울기 고정 / 길이 고정 · 기울기 고정 · 「자유」 → 「자유지정」 - 긋기 처음 방식 = 길이·기울기 고정(기억 안 함) - Tab(긋는 중) = 자유지정 ↔ 길이·기울기 고정 · 등록부 plan.drawKind · 긋기 아닐 때 = 초점 이동 그대로 - 길이 · 기울기 칸 = 방식에 안 쓰면 숨김 · 막힘 = 권한 없음만 - 지도 안내 글 Tab · Shift 줄 · 겹 hover 에 shift 인자(625-2 몫) Co-Authored-By: Claude Opus 5.5 --- A00_Common/key_registry.ts | 2 ++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts | 14 ++++++++++++-- D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts | 15 +++++++++------ D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts | 6 ++++-- D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css | 4 ++++ 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/A00_Common/key_registry.ts b/A00_Common/key_registry.ts index c460cc0ff..1074f20e8 100644 --- a/A00_Common/key_registry.ts +++ b/A00_Common/key_registry.ts @@ -138,6 +138,8 @@ export const KEY_ACTIONS: readonly KeyAction[] = [ A("plan.redo", "plan", ["Ctrl+Shift+Z", "Ctrl+Y"], ["다시", "Redo"]), A("plan.drawFinish", "plan", ["Enter"], ["그리기 끝", "Finish drawing"]), A("plan.drawBack", "plan", ["Backspace"], ["그리기 마지막 점 빼기", "Drawing: remove last point"]), + A("plan.drawKind", "plan", ["Tab"], ["긋는 중 방식 자유지정 ↔ 길이·기울기 고정", "Drawing: free ↔ length + slope"]), + A("plan.drawBetween", "plan", ["Shift 누른 채"], ["길이·기울기 고정 긋는 중 부채꼴 사이 기울기 자리(길이 그대로)", "Drawing: pick between-slope on fan (length kept)"], F), A("plan.delete", "plan", ["Delete"], ["고른 노드 · 선 · 구역 · 전체 · 마커 지움", "Delete picked node · segment · region · all · marker"]), A("plan.escape", "plan", ["Escape"], ["그리기 취소 · 고름 풀기 · 재기 끝", "Cancel drawing · clear selection · end measure"], F), A("plan.zoom", "plan", ["휠"], ["커서 자리 확대 · 축소", "Zoom at cursor"], F), diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 7d18054d1..64ee4a4a1 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -16,7 +16,7 @@ * 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 · * 여러 해 = 다 그은 뒤 그리기 칸 「연도 구역 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름. * 고르기 · 고치기 · 마커(쉴 때) = `D03_PlanAnalysis_UI_Edit`. - * 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄). + * 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기 · Tab 방식 바꿈(설정 창 단축키 표 D03 줄). * 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` · * 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지). * ========================================================================== */ @@ -120,7 +120,8 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { let strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴 let preview: DrawPreview | null = null; let candidates: Vertex[] = []; - let kind: DrawConstraint = "free"; + let kind: DrawConstraint = "both"; // 625-1 · 기억 안 함(B9) + let hoverAt: { px: number; py: number; map: ScreenMap } | null = null; // Tab 뒤 미리보기 다시 let lengthM = DRAW_DEFAULT_LENGTH_M; let slopePct = DRAW_DEFAULT_SLOPE_PCT; // 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 · 그 기록 @@ -472,9 +473,11 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { return false; }, hover(px, py, map) { + hoverAt = { px, py, map }; preview = mode === "draw" ? previewAt(px, py, map) : null; }, leave() { + hoverAt = null; preview = null; }, }; @@ -501,6 +504,13 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { panelState(); }, key(event) { + // Tab = 자유지정 ↔ 길이·기울기 고정(단독 고정이면 길이·기울기로 · 625-1) · 긋기 아닐 때 = 초점 이동 + if (mode === "draw" && keyIs(event, "plan.drawKind")) { + kind = kind === "both" ? "free" : "both"; + kindFields.set({ kind }); + if (hoverAt) preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.map); + return true; + } if (mode === "draw" && keyIs(event, "plan.drawFinish")) { if (span) showToast("다시 긋기는 뒤 노드에 붙어야 끝 — 취소했습니다.", "info"); setMode("idle"); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts index 18b8214d7..20c2de8cc 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts @@ -2,8 +2,9 @@ * D03_PlanAnalysis_UI_DrawPanel.ts * D03 좌측 「그리기」 칸 몸(PLAN 599-1 · 599-4 · 요구 463-10 · 22) — [긋기] · 연도 구역 마커 · 긋기 방식 칸. * - * 방식 칸 `createDrawKindFields` = 마디 고름 2 × 2(자유 · 길이 고정 · 기울기 고정 · 길이·기울기 고정) + - * 길이 · 기울기 두 열(기울기 아래 ≈ °) — 그리기 칸 · 노드 판(598-4)이 같은 부품을 씀. + * 방식 칸 `createDrawKindFields` = 마디 고름 2 × 2(자유지정 · 길이·기울기 고정 / 길이 고정 · 기울기 고정 · + * 625-1) + 길이 · 기울기 두 열(기울기 아래 ≈ °) — 방식에 안 쓰는 칸은 숨김(막힘 = 권한 없음만) · + * 그리기 칸 · 노드 판(598-4)이 같은 부품을 씀. * 연도 구역 마커 = 끌어 선 위에 놓기(`YEAR_MARK_DRAG` · 받음 = `D03_PlanAnalysis_UI_Edit` drop). * 설명 글 없음(요구 463-6) — 칸 오류 · ≈ ° 값만. * ========================================================================== */ @@ -29,10 +30,10 @@ export interface DrawKindValue { } export const DRAW_KIND_LABELS: Record = { - free: "자유", + free: "자유지정", + both: "길이·기울기 고정", length: "길이 고정", slope: "기울기 고정", - both: "길이·기울기 고정", }; export interface DrawKindFields { @@ -118,8 +119,10 @@ export function createDrawKindFields(opts: { function fieldsState(): void { for (const button of segment.root.querySelectorAll("button")) button.disabled = disabled; - lengthField.input.disabled = disabled || value.kind === "free" || value.kind === "slope"; - slopeField.input.disabled = disabled || value.kind === "free" || value.kind === "length"; + lengthField.input.disabled = slopeField.input.disabled = disabled; + fields.hidden = value.kind === "free"; + lengthField.root.hidden = value.kind === "slope"; + slopeColumn.hidden = value.kind === "length"; } degText(); fieldsState(); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index 0681865f2..de6d27bda 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -101,7 +101,7 @@ export interface PlanMapLayer { base?: boolean; draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void; press?: (px: number, py: number, map: ScreenMap, button: 0 | 2, shift: boolean) => boolean; - hover?: (px: number, py: number, map: ScreenMap) => void; + hover?: (px: number, py: number, map: ScreenMap, shift: boolean) => void; release?: (map: ScreenMap) => void; /** 「연도 마커」를 끌고 지나감(참 = 받을 겹) · 놓음(참 = 받음) — 585-3. */ dragOver?: (px: number, py: number, map: ScreenMap) => boolean; @@ -210,6 +210,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { "Enter = 긋기 끝", "Esc = 긋기 취소", "Backspace = 마지막 점 빼기", + "Tab(긋는 중) = 자유지정 ↔ 길이·기울기 고정", + "Shift 누른 채(길이·기울기 고정) = 부채꼴 사이 기울기 자리 · 길이 그대로", "[다시 긋기] = 앞 노드부터 긋고 뒤 노드에 붙으면 끝", "누름 = 노드 · 선 고름 · 다시 누름 = 선 → 구역 → 전체로 넓힘", "Shift+누름 = 선 더함 · 뺌", @@ -458,7 +460,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { perfTime("plan:pointermove", () => { const [px, py] = at(event); const map = screenMap(); - for (const layer of layers) layer.hover?.(px, py, map); + for (const layer of layers) layer.hover?.(px, py, map, event.shiftKey); drawTop(); }); }); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 2ca1ca5ff..5694f0d85 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -192,6 +192,10 @@ align-items: start; } +.d03-plan__draw-fields[hidden] { + display: none; +} + .d03-plan__draw-fields .ui-field { margin: 0; min-width: 0; From 77a769776847929786e8f1042d45676d920b5a99 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:16:48 +0900 Subject: [PATCH 06/18] =?UTF-8?q?fix(B04):=20=EC=83=88=20=EA=B4=80=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=20=E2=80=94=20=EC=9C=A0=EC=9E=85=20?= =?UTF-8?q?=EC=A7=91=EC=88=98=EC=A0=95=EC=9D=B4=EB=A9=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=9E=85=20=ED=9D=99=EB=A7=89=EC=9D=B4=20=EC=B9=B8=20=EC=95=88?= =?UTF-8?q?=20=EC=B1=84=EC=9B=80=20=C2=B7=20=EB=8F=99=EB=AC=BC=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=ED=98=95=20=EC=9C=A0=20=C2=B7=20=EA=B4=80=EA=B2=BD=20?= =?UTF-8?q?=EC=88=98=20(PLAN=20623=20=C2=B7=20=EC=9A=94=EA=B5=AC=20530=20E?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_apply_new_defaults` — 집수정 갈래면 `inlet_revet_*` 기본값 · 높이 안 채움 · `inlet_basin_animal` "true" · 등록부 관경 글 "1000" → 수 - 새 자동 관이 폼을 처음 거쳐도 파일이 안 바뀜(620 증상) · 옛 프로젝트 이주 없음 - 시험 — 새 `test_623_inlet_defaults`(폼 왕복 차 0 · `helper_623_inlet_defaults.cjs`) · 기대값 고침 `test_b05_new_structure_defaults` · `test_211_1`(기슭막이 관으로) · `test_b05_drainage_recommend`(관경 수) Co-Authored-By: Claude Opus 5.5 --- .../B04_PreProcess_Router_Basins.py | 19 ++- .../tester/helper_623_inlet_defaults.cjs | 134 ++++++++++++++++++ .../tester/test_211_1_inlet_revet_height.py | 7 +- resources/tester/test_623_inlet_defaults.py | 59 ++++++++ .../tester/test_b05_drainage_recommend.py | 3 +- .../tester/test_b05_new_structure_defaults.py | 20 ++- 6 files changed, 226 insertions(+), 16 deletions(-) create mode 100644 resources/tester/helper_623_inlet_defaults.cjs create mode 100644 resources/tester/test_623_inlet_defaults.py diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 24928ccb5..799ccb1bd 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -257,21 +257,26 @@ def _apply_new_defaults( if definition is None: continue options = dict(point.options or {}) + given = set(options) for field in definition.options: if field.key not in options and field.default not in (None, ""): options[field.key] = field.default - # 집수정 모양도 실제 값으로(54-39 · 54-36 기본 L형 — 화면 짝 `DEFAULT_BASIN_SHAPE`) - if options.get("inlet_type") == "집수정": + if "pipe_diameter_mm" not in given and isinstance(options.get("pipe_diameter_mm"), str): + options["pipe_diameter_mm"] = int(_float(options["pipe_diameter_mm"])) # 폼 = 수(623) + # 집수정 = 모양 L(54-39 · `DEFAULT_BASIN_SHAPE`) · 동물이동형 유 · 유입 흙막이 없음(623) + basin = options.get("inlet_type") == "집수정" + if basin: options.setdefault("inlet_structure", "L") - # 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · 화면 짝 `PIPE_HEIGHT_KEYS`) + options.setdefault("inlet_basin_animal", "true") + for key in [k for k in options if k.startswith("inlet_revet_") and k not in given]: + del options[key] + # 보호공 · 집수정 높이 = 관경 + 0.5(139-1 · `PIPE_HEIGHT_KEYS`) · 유입 흙막이 + 0.7(211-1) keys = {field.key for field in definition.options} diameter_m = _float(options.get("pipe_diameter_mm")) / 1000 if diameter_m > 0: for key in PIPE_HEIGHT_KEYS: - if key in keys: - side = ( - "inlet" if key == "inlet_revet_height_m" else "" - ) # 유입 흙막이 + 0.7(211-1) + if key in keys and not (basin and key.startswith("inlet_revet_")): + side = "inlet" if key == "inlet_revet_height_m" else "" options.setdefault(key, pipe_wall_min_height_m(diameter_m, side)) if point.facility == "ford_bridge" and "ford_height_m" not in options: flow = (flows or {}).get(round(point.chainage_m, 2)) diff --git a/resources/tester/helper_623_inlet_defaults.cjs b/resources/tester/helper_623_inlet_defaults.cjs new file mode 100644 index 000000000..df063c226 --- /dev/null +++ b/resources/tester/helper_623_inlet_defaults.cjs @@ -0,0 +1,134 @@ +/* 623 헬퍼 — 서버가 새로 만든 관 옵션(`_apply_new_defaults`)을 공용 명세 폼 `write → read` 에 돌리고 + * 같은 종류 병합(`mergeFacilityOptions`)까지 — 폼을 처음 거칠 때 파일이 바뀌는 칸(지워짐 · 더함 · 바뀜). + * 입력 = 표준입력 JSON(옵션 목록) · 출력 = 표준출력 JSON 목록 · 짝 = test_623_inlet_defaults.py. + * 가짜 DOM 틀 = helper_173_7_inlet_animal.cjs. */ +const fs = require("fs"); +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) + +const ROOT = path.join(__dirname, "..", ".."); + +class FakeNode { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.dataset = {}; + this.style = {}; + this._classes = new Set(); + this._on = {}; + this._text = ""; + this.value = ""; + const self = this; + this.classList = { + add: (...n) => n.forEach((c) => self._classes.add(c)), + remove: (...n) => n.forEach((c) => self._classes.delete(c)), + toggle: (c, f) => + (f ?? !self._classes.has(c)) ? self._classes.add(c) : self._classes.delete(c), + contains: (c) => self._classes.has(c), + }; + } + set className(v) { + this._classes = new Set(String(v).split(/\s+/).filter(Boolean)); + } + set textContent(v) { + this._text = String(v); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent ?? "").join(""); + } + append(...kids) { + kids.forEach((k) => this.children.push(typeof k === "string" ? { textContent: k } : k)); + } + replaceChildren(...kids) { + this.children = []; + this.append(...kids); + } + add(kid) { + this.children.push(kid); + } + setAttribute(n, v) { + this[n] = v; + } + addEventListener(type, fn) { + (this._on[type] ??= []).push(fn); + } + fire(type) { + (this._on[type] ?? []).forEach((fn) => fn({ target: this })); + } + closest() { + return null; + } + matches(sel) { + return sel.split(",").some((s) => { + s = s.trim(); + return s.startsWith(".") ? this._classes.has(s.slice(1)) : this.tagName === s.toUpperCase(); + }); + } + querySelectorAll(sel) { + const found = []; + const walk = (n) => + (n.children ?? []).forEach((k) => { + if (k.matches?.(sel)) found.push(k); + walk(k); + }); + walk(this); + return found; + } + querySelector(sel) { + return this.querySelectorAll(sel)[0] ?? null; + } +} +globalThis.document = { createElement: (t) => new FakeNode(t) }; +globalThis.queueMicrotask = (fn) => fn(); +globalThis.Option = function (text, value) { + const o = new FakeNode("option"); + o.textContent = text; + o.value = value; + return o; +}; + +const STUBS = { + "@ui/ui_template_elements": { showToast: () => {} }, + "@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 }, + "./ui_template_checkbox.css": {}, + "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": { buildSummaryRow: () => null }, +}; + +const { loadTs } = makeLoader({ + root: ROOT, + requireFor: (full) => (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + if (STUBS[request]) return STUBS[request]; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + return loadTs(path.join(path.dirname(full), `${request}.ts`)); + }, +}); + +const { createSpecForm } = loadTs( + path.join(ROOT, "A00_Common", "design", "design_structure_form.ts"), +); +const { PIPE_SPEC } = loadTs( + path.join(ROOT, "A00_Common", "design", "logic", "design_structure_pipe.ts"), +); +const { mergeFacilityOptions } = loadTs( + path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Drainage_Facility_Merge.ts"), +); + +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); +const inputs = JSON.parse(fs.readFileSync(0, "utf-8")); +const out = inputs.map((options) => { + const form = createSpecForm(null, {}); + form.write(PIPE_SPEC, options); + const merged = + mergeFacilityOptions({ facility: "pipe", options }, { facility: "pipe", options: form.read() }) + .options ?? {}; + return { + removed: Object.keys(options).filter((k) => !(k in merged)), + added: Object.keys(merged).filter((k) => !(k in options)), + changed: Object.keys(options).filter((k) => k in merged && !same(options[k], merged[k])), + }; +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_211_1_inlet_revet_height.py b/resources/tester/test_211_1_inlet_revet_height.py index f7813439f..572b9008e 100644 --- a/resources/tester/test_211_1_inlet_revet_height.py +++ b/resources/tester/test_211_1_inlet_revet_height.py @@ -47,7 +47,12 @@ def test_파이썬_기본_높이_유입만_0_7(): def test_새_관_기본값_유입_흙막이만_0_7(): - fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 1200}) + # 유입 기슭막이 관만 유입 흙막이 칸을 받음 — 기본 집수정이면 안 채움(623) + fresh = PipePoint( + chainage_m=10.0, + source="stream", + options={"pipe_diameter_mm": 1200, "inlet_type": "기슭막이"}, + ) basins._apply_new_defaults([fresh]) assert fresh.options["inlet_revet_height_m"] == pytest.approx(1.9) assert fresh.options["outlet_revet_height_m"] == pytest.approx(1.7) diff --git a/resources/tester/test_623_inlet_defaults.py b/resources/tester/test_623_inlet_defaults.py new file mode 100644 index 000000000..14b3d4b7b --- /dev/null +++ b/resources/tester/test_623_inlet_defaults.py @@ -0,0 +1,59 @@ +"""623 새 관 기본값 — 유입 집수정이면 유입 흙막이 칸 안 채움 · 동물이동형 유(요구 530 E1 · 분석 620). + +못박는 것 + - 서버가 새로 만든 관(`_apply_new_defaults`) = 집수정 갈래 · `inlet_revet_*` 없음 · `inlet_basin_animal` "true". + - 그 옵션을 공용 명세 폼 `write → read` + 병합(`mergeFacilityOptions`)에 돌리면 차이 0 + (지워짐 0 · 더함 0 · 바뀜 0) — 폼을 처음 거쳐도 파일이 안 바뀜(`helper_623_inlet_defaults.cjs`). + - 유입 기슭막이로 받은 관 · 이미 적힌 유입 흙막이 칸은 그대로. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from B04_PreProcess import B04_PreProcess_Router_Basins as basins +from common_util.common_util_drainage_pipes import PipePoint + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] + + +def _fresh(**options) -> dict: + pipe = PipePoint(chainage_m=10.0, source="stream", options=options or None) + basins._apply_new_defaults([pipe]) + return pipe.options + + +def test_집수정이면_유입_흙막이_없음_동물이동형_유(): + for options in (_fresh(), _fresh(pipe_diameter_mm=800)): + assert options["inlet_type"] == "집수정" + assert not [k for k in options if k.startswith("inlet_revet_")] + assert options["inlet_basin_animal"] == "true" and options["inlet_structure"] == "L" + assert _fresh()["pipe_diameter_mm"] == 1000 # 등록부 글 "1000" → 수(폼과 같음) + + +def test_기슭막이_관과_적힌_칸은_그대로(): + soil = _fresh(inlet_type="기슭막이") + assert soil["inlet_revet_form"] == "돌쌓기(찰)" and soil["inlet_revet_height_m"] == 1.7 + assert "inlet_basin_animal" not in soil + kept = _fresh(inlet_revet_form="돌쌓기(메)", inlet_basin_animal="false") + assert kept["inlet_revet_form"] == "돌쌓기(메)" and kept["inlet_basin_animal"] == "false" + + +def test_서버가_만든_관_폼_왕복_차이_0(): + if not (ROOT / "config" / "node_modules").is_dir(): + pytest.skip("의존성 미설치") + inputs = [_fresh(), _fresh(pipe_diameter_mm=800), _fresh(pipe_diameter_mm=1200)] + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_623_inlet_defaults.cjs")], + input=json.dumps(inputs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + assert json.loads(done.stdout) == [{"removed": [], "added": [], "changed": []}] * 3 diff --git a/resources/tester/test_b05_drainage_recommend.py b/resources/tester/test_b05_drainage_recommend.py index f145898a0..7ab3a7bac 100644 --- a/resources/tester/test_b05_drainage_recommend.py +++ b/resources/tester/test_b05_drainage_recommend.py @@ -99,7 +99,8 @@ def test_new_pipe_gets_master_default_not_recommendation(monkeypatch, facility, """새로 놓인 관 = 배관 · 마스터 기본 관경 — 추천(관경 · BOX암거 · 세월교)은 안 얹는다.""" carried = _run_build(monkeypatch, _basin(100.0, facility, diameter), None) assert carried[0].facility == PIPE_FACILITY_PIPE - assert carried[0].options["pipe_diameter_mm"] == _master_pipe_diameter() + # 등록부 글 "1000" → 수로 적힘(623 · 폼 왕복과 같음) + assert carried[0].options["pipe_diameter_mm"] == int(_master_pipe_diameter()) def test_existing_auto_pipe_is_untouched(monkeypatch): diff --git a/resources/tester/test_b05_new_structure_defaults.py b/resources/tester/test_b05_new_structure_defaults.py index b6177eddf..d471a9987 100644 --- a/resources/tester/test_b05_new_structure_defaults.py +++ b/resources/tester/test_b05_new_structure_defaults.py @@ -30,13 +30,19 @@ def _defaults(type_id: str) -> dict: } +def _basin_defaults() -> dict: + """새 관(유입 집수정) — 유입 흙막이 칸 없음 · 모양 L · 동물이동형 유 · 관경 수(623).""" + kept = {k: v for k, v in _defaults("pipe").items() if not k.startswith("inlet_revet_")} + return {**kept, "pipe_diameter_mm": 1000, "inlet_structure": "L", "inlet_basin_animal": "true"} + + def test_새_관만_기본값_빈_키에만(): fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 800}) basins._apply_new_defaults([fresh]) # 추천(먼저 들어간 값)이 이김 · 집수정 모양 L 도 실제 값(54-39) - # 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · Φ800 → 1.3) · 유입 흙막이만 + 0.7(211-1 → 1.5) - heights = {**dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.3), "inlet_revet_height_m": 1.5} - expected = {**_defaults("pipe"), "pipe_diameter_mm": 800, "inlet_structure": "L", **heights} + # 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · Φ800 → 1.3) · 집수정이면 유입 흙막이 없음(623) + heights = {"outlet_revet_height_m": 1.3, "inlet_basin_height_m": 1.3} + expected = {**_basin_defaults(), "pipe_diameter_mm": 800, **heights} assert fresh.options == expected assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "집수정" box = PipePoint(chainage_m=20.0, source="stream", facility="box_culvert") @@ -65,10 +71,10 @@ def test_원본에_있던_관은_빈칸_그대로(monkeypatch): _, points = basins._build("p", Ctx(), [old]) by = {p.chainage_m: p for p in points} assert by[100.0].options is None # 옛 빈칸 그대로 - # 새로 보충된 관 = 기본값 + 집수정 모양 L(54-39) - # 기본 관경 1000 → 1.5(139-1) · 유입 흙막이 1.7(211-1) - heights = {**dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.5), "inlet_revet_height_m": 1.7} - assert by[300.0].options == {**_defaults("pipe"), "inlet_structure": "L", **heights} + # 새로 보충된 관 = 기본값 + 집수정 모양 L(54-39) · 동물이동형 유 · 유입 흙막이 없음(623) + # 기본 관경 1000 → 1.5(139-1) + heights = {"outlet_revet_height_m": 1.5, "inlet_basin_height_m": 1.5} + assert by[300.0].options == {**_basin_defaults(), **heights} def test_월류_폭_기본값은_등록부_엔진과_같음(): From b96479fd7eba3b716f1b3d7a4aef7180b00e74a6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:19:01 +0900 Subject: [PATCH 07/18] =?UTF-8?q?refactor(=EA=B3=B5=EC=9A=A9=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84):=20=EA=B2=B9=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20GL=20?= =?UTF-8?q?=EA=B2=B9=20=EA=BC=B4=20=C2=B7=20=EA=B2=B9=20=EC=A4=84=20=C2=B7?= =?UTF-8?q?=20=EB=B2=94=EB=A1=80=20=C2=B7=20=EC=82=B0=EB=A6=BC=20=EC=A1=B0?= =?UTF-8?q?=EA=B0=81=20=EA=B2=B9=EC=9D=84=20=EC=83=88=20=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?=20=ED=8F=B4=EB=8D=94=20A00=5FCommon/map=20=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=E2=80=94=20D03=20=EC=9D=80=20=EB=B6=80=EB=A5=B4=EA=B8=B0?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20=EA=B2=89=EB=AA=A8=EC=8A=B5=20=C2=B7=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20(PLAN=20624-?= =?UTF-8?q?2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20530=20=C2=B7=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=95=20A=E2=91=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - map_layer_list = 겹 스물셋 · 처음 값 · 페이지 표(d03 · b05 보일 겹 · 처음 켬 · 취향 열쇠 — A② · A⑨) - map_layer_specs = GL 겹 꼴 · 차례(D03 UI_MapGl 에서 옮김 · 선 겹 꼴은 LINE_LAYERS 한 벌) - map_layer_bar = 겹 단추 줄 · 켬끔 · 취향 · 그래픽 카드 단추 / map_legend = 범례 칸 · 조각 겹 범례 - map_forest_layers = D03_UI_ForestLayers 옮김(이름만) - D03 UI_Layers 683 → 639 줄 · UI_MapGl 423 → 115 줄 · UI_LayerDefs 는 캔버스 도움 + 옛 이름 다시 내놓음 - 시험 길 고침(553-4 · 581-2 · 584-1 · 584-3 · 606-1 · 615 · 622-2) — 옮긴 파일 자리 · 553-4 겹 목록은 622-2 때 낡은 것(국유림 임도 · 경제림 · 생태자연도 빠짐) 같이 고침 Co-Authored-By: Claude Opus 5.5 --- .../map/map_forest_layers.ts | 14 +- A00_Common/map/map_layer_bar.ts | 96 +++++ A00_Common/map/map_layer_list.ts | 221 ++++++++++++ A00_Common/map/map_layer_specs.ts | 317 ++++++++++++++++ A00_Common/map/map_legend.ts | 36 ++ .../D03_PlanAnalysis_UI_LayerDefs.ts | 199 +--------- .../D03_PlanAnalysis_UI_LayerLegend.ts | 15 +- .../D03_PlanAnalysis_UI_Layers.ts | 110 ++---- D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts | 340 +----------------- resources/tester/test_553_4_de_parts.py | 12 +- resources/tester/test_581_2_plan_soils.py | 2 +- resources/tester/test_584_1_plan_geo_lines.py | 2 +- .../test_584_3_plan_forest_road_zones.py | 2 +- resources/tester/test_606_1_sat_light.py | 2 +- resources/tester/test_615_contour_tiles.py | 3 +- resources/tester/test_622_2_forest_layers.py | 7 +- 16 files changed, 763 insertions(+), 615 deletions(-) rename D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts => A00_Common/map/map_forest_layers.ts (93%) create mode 100644 A00_Common/map/map_layer_bar.ts create mode 100644 A00_Common/map/map_layer_list.ts create mode 100644 A00_Common/map/map_layer_specs.ts create mode 100644 A00_Common/map/map_legend.ts diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts b/A00_Common/map/map_forest_layers.ts similarity index 93% rename from D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts rename to A00_Common/map/map_forest_layers.ts index c7aa20c51..271194d9a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts +++ b/A00_Common/map/map_forest_layers.ts @@ -1,24 +1,24 @@ /* ============================================================================= - * D03_PlanAnalysis_UI_ForestLayers.ts - * D03 받은 산림 · 환경 자료 겹(PLAN 622-2 · 요구 529) — 서버 줌별 조각(622-1)을 GL 고정 소스로(위성 · 등고선 615 꼴). + * map_forest_layers.ts + * 공용 지도 — 받은 산림 · 환경 자료 겹(PLAN 622-2 · 624-2 · 요구 529 · 530) — 서버 줌별 조각(622-1)을 GL 고정 소스로(위성 · 등고선 615 꼴). * 벡터 = 국유림 임도(forest_road · 간선 · 작업 · 산불 색 · 줌 따라 굵기) · 산림입지도(forest_soil · 토양 무리 색 · * 반투명 · z10 아래 숨김) · 경제림(eco_forest · 국유 · 사유 테두리) / * 그림 = 생태자연도(ecology) · 산사태위험지도(landslide · 옛 캔버스는 받은 범위 한 장 그대로). * 조각만 쓰는 겹(`TILE_ONLY`) = 받기 없음 · 옛 캔버스(그래픽 카드 끔)엔 안 보임. * 토양 무리 색(`soilColor`) = 조각(`code` = SLTP_CD 두 자리) · 브이월드 요약(기호 「B₁」) 같은 색 — 범례 · 옛 캔버스도 이것. - * 부르는 곳 = `D03_PlanAnalysis_UI_MapGl`(꼴 · 소스) · `UI_Layers`(받기 거름) · `UI_LayerLegend`(범례). + * 부르는 곳 = `map_layer_specs`(꼴) · D03 `UI_MapGl`(소스) · `UI_Layers`(받기 거름) · `map_legend`(범례). * ========================================================================== */ import type { ExpressionSpecification, SourceSpecification } from "maplibre-gl"; import type { LegendItem } from "@ui/ui_template_flow_legend"; import { tileSource, type GlSpec } from "@ui/ui_template_gl_map"; import { themeColor } from "@ui/ui_template_palette"; -import type { PlanLayerKey } from "./D03_PlanAnalysis_UI_LayerDefs"; +import type { MapLayerKey } from "./map_layer_list"; /** 조각만 쓰는 겹 — 받을 자료 없음(위성 포함). */ const TILE_ONLY = ["satellite", "nationalRoad", "ecoForest", "ecology"] as const; export type TileOnlyKey = (typeof TILE_ONLY)[number]; -export const tileOnly = (key: PlanLayerKey): key is TileOnlyKey => +export const tileOnly = (key: MapLayerKey): key is TileOnlyKey => (TILE_ONLY as readonly string[]).includes(key); /** 받는 동안 서클에 세는 조각 소스(등고선 포함). */ @@ -89,7 +89,7 @@ const ROUND = { "line-join": "round", "line-cap": "round" } as const; const zoomWidth = (z8: number, z12: number, z15: number): ExpressionSpecification => ["interpolate", ["linear"], ["zoom"], 8, z8, 12, z12, 15, z15] as ExpressionSpecification; -type Spec = GlSpec; +type Spec = GlSpec; const raster = (key: "landslide" | "ecology", opacity: number): Spec => ({ key, @@ -172,7 +172,7 @@ export function forestRoadSpecs(): Spec[] { } /** 범례 — 켠 조각 겹(산사태위험 = 범례 자리 `LANDSLIDE_LEGEND` 따로). */ -export function forestLegendRows(on: Record): LegendItem[][] { +export function forestLegendRows(on: Record): LegendItem[][] { const item = (label: string, color: string, kind?: LegendItem["kind"]): LegendItem => ({ label, color: `var(${color})`, diff --git a/A00_Common/map/map_layer_bar.ts b/A00_Common/map/map_layer_bar.ts new file mode 100644 index 000000000..b0c2f9db9 --- /dev/null +++ b/A00_Common/map/map_layer_bar.ts @@ -0,0 +1,96 @@ +/* ============================================================================= + * map_layer_bar.ts + * 공용 지도 겹 줄(PLAN 569-2 · 586-2 · 624-2 · 요구 530) — 겹 단추 한 줄(공용 지도 도구줄 `createMapTools` · 묶음 사이 나눔선) + + * 끝에 그래픽 카드 단추(GL ↔ 옛 캔버스). + * 보일 겹 · 처음 켬 · 취향 열쇠 = 페이지 표(`map_layer_list` `MAP_LAYER_PAGES`) · 누르면 켬끔 뒤집고 취향에 적고 `onToggle`. + * 막힘 = 지형을 아직 안 받음(`sync({ ready: false })`) · 조각만 쓰는 겹은 옛 캔버스에서 「안 보임」 글. + * 받기 · 그리기 · 범례는 페이지 몫 — 켬끔 값(`on`)은 같은 객체를 같이 씀. + * 쓰는 법 = `createMapLayerBar({ page: "d03", glOn, onToggle, onGl })` → `{ root, bar, on, toggle, save, sync }`. + * ========================================================================== */ + +import { createIconButton } from "@ui/ui_template_button"; +import { createMapTools } from "@ui/ui_template_map_tools"; +import { writeByKey } from "../b_page_state"; +import { tileOnly } from "./map_forest_layers"; +import { + MAP_LAYER_PAGES, + pageGroups, + readLayerPref, + type MapLayerKey, + type MapPage, +} from "./map_layer_list"; + +export interface MapLayerBar { + /** 도구줄 감싼 것(자리는 페이지 CSS). */ + root: HTMLElement; + /** 도구줄 자체(폭 재기). */ + bar: HTMLElement; + /** 켬끔 — 페이지가 바꾸면 `save` · 받기 실패로 끄는 것은 안 적음. */ + on: Record; + /** 뒤집고 적고 `onToggle`. */ + toggle: (key: MapLayerKey) => void; + save: () => void; + /** 단추 눌림 · 막힘 · 글을 켬끔 값에 맞춤. */ + sync: (state: { ready: boolean; glOn: boolean }) => void; +} + +export function createMapLayerBar(opts: { + page: MapPage; + glOn: boolean; + onToggle: (key: MapLayerKey) => void; + /** 그래픽 카드 단추 누름. */ + onGl: () => void; +}): MapLayerBar { + const { page } = opts; + const on = readLayerPref(page); + const save = (): void => writeByKey(MAP_LAYER_PAGES[page].pref, JSON.stringify(on)); + const toggle = (key: MapLayerKey): void => { + on[key] = !on[key]; + save(); + opts.onToggle(key); + }; + const defs = pageGroups(page); + const buttons = new Map(); + const groups = defs.map((group) => + group.map((def) => { + const button = createIconButton({ + icon: def.icon, + title: def.title, + size: 32, + pressed: on[def.key], + onClick: () => toggle(def.key), + }); + button.dataset.layer = def.key; + buttons.set(def.key, button); + return button; + }), + ); + const glButton = createIconButton({ + icon: "gauge", + title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)", + size: 32, + pressed: opts.glOn, + onClick: opts.onGl, + }); + glButton.dataset.layer = "gl"; + groups.push([glButton]); + const tools = createMapTools({ label: "지도 겹", groups }); + + function sync({ ready, glOn }: { ready: boolean; glOn: boolean }): void { + for (const def of defs.flat()) { + const button = buttons.get(def.key)!; + button.setAttribute("aria-pressed", String(on[def.key])); + button.disabled = !ready; + const title = !ready + ? `${def.title} — 지형 받기 뒤 보임` + : tileOnly(def.key) && !glOn + ? `${def.title} — 옛 그림(그래픽 카드 끔)엔 안 보임` + : def.title; + button.title = title; + button.setAttribute("aria-label", title); + } + glButton.setAttribute("aria-pressed", String(glOn)); + } + + return { root: tools.root, bar: tools.bar, on, toggle, save, sync }; +} diff --git a/A00_Common/map/map_layer_list.ts b/A00_Common/map/map_layer_list.ts new file mode 100644 index 000000000..a2e24f9e0 --- /dev/null +++ b/A00_Common/map/map_layer_list.ts @@ -0,0 +1,221 @@ +/* ============================================================================= + * map_layer_list.ts + * 공용 지도 겹 목록(PLAN 624-2 · 요구 530 · 결정 A② · A⑧ · A⑨) — 예정노선(D03) · B05 평면이 같이 씀. + * 겹 = 열쇠 · 아이콘 · 이름 · 처음 값(`on` = 예정노선) · 단추 묶음(바탕 · 물 · 길 · 구역 · 사이 나눔선). + * 페이지 표(`MAP_LAYER_PAGES`) = 보일 겹(숨길 겹) · 처음 켬(없으면 겹의 `on`) · 켬끔 취향 열쇠. + * 선 겹 꼴 · 보호구역 색 · 산사태위험 범례 = GL 꼴(`map_layer_specs`) · 범례(`map_legend`) · 옛 캔버스가 같이 읽음. + * 쓰는 법 = 겹 줄 `createMapLayerBar({ page })`(`map_layer_bar`) · 새 겹 = `GROUPS` 한 줄 + 꼴 한 벌. + * ========================================================================== */ + +import type { IconName } from "@ui/ui_template_icons"; +import type { LegendItem } from "@ui/ui_template_flow_legend"; +import { readByKey } from "../b_page_state"; + +export type MapLayerKey = + | "satellite" + | "hillshade" + | "contour" + | "slope" + | "stream" + | "water" + | "road" + | "forestRoad" + | "forestRoadFile" + | "nationalRoad" + | "ecoForest" + | "ecology" + | "cadastral" + | "zones" + | "soils" + | "landslide" + | "landslideZones" + | "faults" + | "lineaments" + | "zeroOrder" + | "bands" + | "building" + | "place"; + +/** 올린 임도망 고름(605-2) — 묶음 하나 또는 그 안의 선 하나(지도 강조). */ +export interface RoadPick { + bundle: string; + line?: number; +} + +export interface LayerDef { + key: MapLayerKey; + icon: IconName; + title: string; + /** 예정노선 처음 값 — 다른 페이지는 `MAP_LAYER_PAGES[page].on`. */ + on: boolean; +} + +/** 단추 묶음(사이 나눔선) — 바탕 · 물 · 길 · 구역. 「지금」 겹 처음 값 = 끔(`_분석_440-C` 1-4). */ +export const GROUPS: readonly (readonly LayerDef[])[] = [ + [ + { key: "satellite", icon: "satellite", title: "위성사진", on: true }, + { key: "hillshade", icon: "sun", title: "음영기복(2 m 지형 · 산줄기 · 골)", on: false }, + { key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true }, + { key: "slope", icon: "angle", title: "급경사 색(기울기 % 범위 · 범례 ⚙ 로 바꿈)", on: false }, + ], + [ + { key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true }, + { key: "water", icon: "ripple", title: "물 면(도엽 실폭하천 · 저수지 등)", on: false }, + { key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false }, + { + key: "forestRoad", + icon: "route", + title: "기존 임도 짐작(도엽 도로 소로 · 미분류 = 주황 점선 · 정식 임도망도 아님)", + on: false, + }, + { + key: "forestRoadFile", + icon: "map", + title: + "올린 임도망도(산림청 시군구 SHP = 밝은 분홍 실선 · 왼쪽 「위치」 칸에서 올리고 묶음 고름 · 지움)", + on: false, + }, + { + key: "nationalRoad", + icon: "flag", + title: + "국유림 임도(산림청 전국 국가임도 · 간선 · 작업 · 산불 색 · 지방 · 민간임도 없음 · 참고용)", + on: false, + }, + ], + [ + { key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false }, + { + key: "zones", + icon: "shield", + title: "보호 · 규제 구역(브이월드 여덟 종 · 참고용)", + on: false, + }, + { + key: "soils", + icon: "leaf", + title: "산림입지도(토양형 · 산림청 산림입지토양도 1:25,000 · 줌 10 이상 · 참고용)", + on: false, + }, + { + key: "ecoForest", + icon: "trees", + title: "경제림육성단지(산림청 2014 · 국유 · 사유 테두리 · 참고용)", + on: false, + }, + { + key: "ecology", + icon: "plant", + title: "생태자연도(환경부 · 1~3 등급 · 별도관리 · 참고용)", + on: false, + }, + { + key: "landslide", + icon: "alert", + title: "산사태위험지도(1~5 등급 · 행안부 생활안전지도 · 참고용)", + on: false, + }, + { + key: "landslideZones", + icon: "alertOctagon", + title: "산사태취약지역(시도 지정 현황 · 점 + 지번 필지 테두리 · 참고용)", + on: false, + }, + { key: "faults", icon: "bolt", title: "단층(브이월드 지질 · 참고용)", on: false }, + { + key: "lineaments", + icon: "lineDashed", + title: "지질구조선(브이월드 지질 · 참고용)", + on: false, + }, + { + key: "zeroOrder", + icon: "arrowsJoin", + title: "0차곡(지형 추정 골짜기 머리 · 위 집수 0.1~1 ha · 곡두 통과 피함)", + on: false, + }, + { + key: "bands", + icon: "borderOuter", + title: "계류 100 m · 도로 300 m 띠(타당성평가)", + on: false, + }, + { key: "building", icon: "building", title: "건물(도엽 · 하류 민가 · 인가)", on: false }, + { key: "place", icon: "mapPin", title: "지명(도엽 계곡 · 령 · 부락)", on: false }, + ], +]; +export const LAYERS: readonly LayerDef[] = GROUPS.flat(); + +/** 지도가 있는 페이지. */ +export type MapPage = "d03" | "b05"; + +/** 페이지 표 — 숨길 겹 · 처음 켬(없음 = 겹의 `on`) · 켬끔 취향 열쇠. */ +export const MAP_LAYER_PAGES: Record< + MapPage, + { hide: readonly MapLayerKey[]; on?: readonly MapLayerKey[]; pref: string } +> = { + d03: { hide: [], pref: "aislo:pref:d03.layers" }, + // A② 처음 = 지금 그대로(등고선 · 하천) · A⑨ 띠(타당성) · 올린 임도망도 숨김 + b05: { + hide: ["bands", "forestRoadFile"], + on: ["contour", "stream"], + pref: "aislo:pref:b05.layers", + }, +}; + +/** 그 페이지에 보일 겹 묶음(빈 묶음 뺌). */ +export const pageGroups = (page: MapPage): LayerDef[][] => + GROUPS.map((group) => + group.filter((def) => !MAP_LAYER_PAGES[page].hide.includes(def.key)), + ).filter((group) => group.length); + +/** 켬끔 취향 — 없거나 깨지면 페이지 처음 값 · 숨긴 겹 = 끔. */ +export function readLayerPref(page: MapPage): Record { + const { hide, on: first, pref } = MAP_LAYER_PAGES[page]; + const on = Object.fromEntries( + LAYERS.map((def) => [def.key, first ? first.includes(def.key) : def.on]), + ) as Record; + try { + const saved = JSON.parse(readByKey(pref) ?? "{}") as Partial>; + for (const def of LAYERS) + if (typeof saved[def.key] === "boolean") on[def.key] = saved[def.key] as boolean; + } catch { + // 깨진 값 = 처음 값 + } + for (const key of hide) on[key] = false; + return on; +} + +/** 보호구역 종류 → 그림 색 값(서버 `ZONE_KINDS` 열쇠). */ +export const ZONE_COLORS: Record = { + forest: "--draw-map-zone-forest", + baekdu: "--draw-map-zone-baekdu", + water: "--draw-map-zone-water", + park: "--draw-map-zone-park", + heritage: "--draw-map-zone-heritage", + wetland: "--draw-map-zone-wetland", + wildlife: "--draw-map-zone-wildlife", + greenbelt: "--draw-map-zone-greenbelt", +}; +/** 산사태위험지도 범례(1 등급 = 가장 위험 · 색 = WMS 그림 색). */ +export const LANDSLIDE_LEGEND: LegendItem[] = [1, 2, 3, 4, 5].map((grade) => ({ + label: `산사태 ${grade}등급`, + color: `var(--draw-map-landslide-grade-${grade})`, +})); +/** 선 겹 — 이름 · 그림 꼴(서버 `LINE_KINDS` 열쇠 = `kind` · 없으면 0차곡). 새 선 겹 = 여기 한 줄. */ +export type LineLayerKey = "faults" | "lineaments" | "zeroOrder"; +export const LINE_LAYERS: Record< + LineLayerKey, + { label: string; kind?: string; color: string; width: number; dash: number[] } +> = { + faults: { label: "단층", kind: "faults", color: "--draw-map-fault", width: 2.4, dash: [] }, + lineaments: { + label: "지질구조선", + kind: "lineaments", + color: "--draw-map-lineament", + width: 1.8, + dash: [8, 4], + }, + zeroOrder: { label: "0차곡", color: "--draw-map-zero-order", width: 1.8, dash: [4, 3] }, +}; +export const LINE_KEYS = Object.keys(LINE_LAYERS) as LineLayerKey[]; diff --git a/A00_Common/map/map_layer_specs.ts b/A00_Common/map/map_layer_specs.ts new file mode 100644 index 000000000..de0589b5e --- /dev/null +++ b/A00_Common/map/map_layer_specs.ts @@ -0,0 +1,317 @@ +/* ============================================================================= + * map_layer_specs.ts + * 공용 지도 GL 겹 꼴(PLAN 586-2 · 624-2 · 요구 530) — 바탕 겹 스물의 MapLibre 꼴 · 차례(아래 → 위): + * 위성 · 그림 겹(음영 · 급경사 · 산사태위험 · 생태자연도) · 면 겹(산림입지 · 경제림 · 보호구역 · 띠 · 물 면 · 건물) · + * 선 겹(지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 · 국유림 임도 · 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지) · + * 점 · 글(산사태취약 · 지명 · 등고 수치). + * 자료 = 받는 쪽이 지물에 붙인 `_color` · 고정 소스(위성 · 등고 · 산림 조각) 이름 = `map_forest_layers` · `ui_template_gl_map`. + * 카메라 · 지도 세우기는 페이지 몫(D03 `UI_MapGl.createGlBase` · B05 `createTmGl`) — 여기는 꼴만. + * ========================================================================== */ + +import type { ExpressionSpecification, FilterSpecification } from "maplibre-gl"; +import { GL_FONT, type GlSpec as GlMapSpec } from "@ui/ui_template_gl_map"; +import { themeColor } from "@ui/ui_template_palette"; +import { forestAreaSpecs, forestRasterSpecs, forestRoadSpecs } from "./map_forest_layers"; +import { + LINE_KEYS, + LINE_LAYERS, + type LineLayerKey, + type MapLayerKey, + type RoadPick, +} from "./map_layer_list"; + +/** 자료 소스(임도 짐작 = 도로 소스를 거름 · 위성 = 조각). */ +export type GlSource = Exclude; + +const GL_FONT_BOLD = [`${GL_FONT} SemiBold`, GL_FONT]; + +type GlSpec = GlMapSpec; + +const HALO = "rgba(0, 0, 0, 0.55)"; +const ROUND = { "line-join": "round", "line-cap": "round" } as const; +const COLOR: ExpressionSpecification = ["get", "_color"]; +/** 등고 조각 굵은 선(서버가 줌마다 정함) — 가는 선은 모두라 굵은 선은 두 번 겹쳐 짙음(캔버스처럼). */ +const BOLD = ["==", ["get", "k"], 1] as unknown as FilterSpecification; +/** 캔버스 점선(px) → MapLibre 점선(선 굵기 배수). */ +const dash = (px: number[], width: number): number[] => px.map((v) => v / width); + +/** 선 겹 — 검은 테두리 + 선(점선이면 둘 다 같은 px 무늬). */ +function haloLine( + key: MapLayerKey, + source: GlSource, + color: string, + width: number, + pattern: number[] = [], + halo = { color: HALO, width: width + 1.6 }, + filter?: FilterSpecification, +): GlSpec[] { + const line = (id: string, paint: Record, w: number): GlSpec => + ({ + key, + id, + type: "line", + source, + ...(filter ? { filter } : {}), + layout: ROUND, + paint: { + ...paint, + "line-width": w, + ...(pattern.length ? { "line-dasharray": dash(pattern, w) } : {}), + }, + }) as GlSpec; + return [ + line(`${key}-halo`, { "line-color": halo.color }, halo.width), + line(key, { "line-color": color }, width), + ]; +} + +/** 면 겹 — 채움 + 테두리(캔버스 `fillLayer` 처럼 테두리 투명도 0.9). */ +function fillEdge( + key: MapLayerKey, + source: GlSource, + fill: string | ExpressionSpecification, + opacity: number | ExpressionSpecification, + edge: { color: string | ExpressionSpecification; width: number; dash?: number[] }, +): GlSpec[] { + return [ + { + key, + id: key, + type: "fill", + source, + paint: { "fill-color": fill, "fill-opacity": opacity }, + } as GlSpec, + { + key, + id: `${key}-edge`, + type: "line", + source, + layout: ROUND, + paint: { + "line-color": edge.color, + "line-width": edge.width, + "line-opacity": 0.9, + ...(edge.dash ? { "line-dasharray": dash(edge.dash, edge.width) } : {}), + }, + } as GlSpec, + ]; +} + +/** 겹 차례(아래 → 위)와 꼴 — 옛 캔버스(D03 `UI_LayerPaint`)의 색 · 굵기 · 투명도 · 차례 그대로 · `light` = 위성 밝기 상한. */ +export function mapLayerSpecs(light: number, pick: RoadPick | null): GlSpec[] { + const contour = themeColor("--draw-map-contour", "#ffd68c"); + const place = themeColor("--draw-map-place", "#f8fafc"); + const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48"); + const raster = ( + key: "satellite" | "hillshade" | "slope", + opacity: number, + extra: Record = {}, + ): GlSpec => + ({ + key, + id: key, + type: "raster", + source: key, + paint: { "raster-opacity": opacity, "raster-fade-duration": 0, ...extra }, + }) as GlSpec; + const lineKind = (key: LineLayerKey): GlSpec[] => { + const { color, width, dash } = LINE_LAYERS[key]; + return haloLine(key, key, themeColor(color, "#f43f5e"), width, dash); + }; + return [ + // 위성 = 조금 어둡고 연하게(요구 475 · 노선 선 대비) — 밝기 상한은 사용자 조절 + raster("satellite", 1, { "raster-brightness-max": light, "raster-saturation": SAT_SATURATION }), + raster("hillshade", 1), // 위성 위 = 「곱하기」와 같은 검은 그늘 그림(받는 쪽이 바꿈) + raster("slope", 0.55), + ...forestRasterSpecs(), // 산사태위험 · 생태자연도 = 조각 그림(622-2) + ...forestAreaSpecs(), // 산림입지도 · 경제림 = 벡터 조각 + ...fillEdge("zones", "zones", COLOR, 0.22, { color: COLOR, width: 1.6 }), + ...fillEdge("bands", "bands", COLOR, ["case", ["==", ["get", "_band"], "road"], 0.16, 0.18], { + color: COLOR, + width: 1.2, + dash: [6, 4], + }), + { + key: "cadastral", + id: "cadastral", + type: "line", + source: "cadastral", + layout: ROUND, + paint: { + "line-color": themeColor("--draw-map-parcel", "#f0abfc"), + "line-width": 0.8, + "line-opacity": 0.8, + }, + } as GlSpec, + ...((): GlSpec[] => { + const water = themeColor("--draw-map-water", "#0ea5e9"); + return fillEdge("water", "water", water, 0.45, { color: water, width: 1 }); + })(), + ...fillEdge("building", "building", themeColor("--draw-map-building", "#fb7185"), 0.7, { + color: HALO, + width: 0.6, + }), + { + key: "contour", + id: "contour-minor", + type: "line", + source: "contour", + "source-layer": "contour", + layout: ROUND, + paint: { "line-color": contour, "line-width": 0.8, "line-opacity": 0.7 }, + } as GlSpec, + { + key: "contour", + id: "contour-index", + type: "line", + source: "contour", + "source-layer": "contour", + filter: BOLD, + layout: ROUND, + paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 }, + } as GlSpec, + { + key: "contour", + id: "contour-label", + type: "symbol", + source: "contour", + "source-layer": "contour", + filter: BOLD, + layout: { + "symbol-placement": "line", + "symbol-spacing": 320, + "text-field": ["to-string", ["get", "h"]], + "text-font": [GL_FONT], + "text-size": 10, + "text-max-angle": 30, + "text-padding": 2, + }, + paint: { + "text-color": contour, + "text-halo-color": "rgba(0, 0, 0, 0.6)", + "text-halo-width": 1.5, + }, + } as GlSpec, + ...haloLine("road", "road", themeColor("--draw-map-road", "#f8fafc"), 1.6, [], { + color: HALO, + width: 3.2, + }), + ...haloLine( + "forestRoad", + "road", + themeColor("--draw-map-forest-road", "#f59e0b"), + 2, + [7, 4], + { color: HALO, width: 3.6 }, + [ + "in", + ["get", "도로구분"], + ["literal", ["소로", "미분류"]], + ] as unknown as FilterSpecification, + ), + ...haloLine( + "forestRoadFile", + "forestRoadFile", + themeColor("--draw-map-forest-road-file", "#ff2fd0"), + 2.4, + [], + { color: HALO, width: 4 }, + ), + ...forestRoadSpecs(), // 국유림 임도 = 벡터 조각 + // 고른 묶음 · 선 강조(605-2) — 같은 자료에서 걸러 한 겹 더 굵게 + ...["-pick-halo", "-pick"].map( + (tail, i) => + ({ + key: "forestRoadFile", + id: `forestRoadFile${tail}`, + type: "line", + source: "forestRoadFile", + filter: pickFilter(pick), + layout: ROUND, + paint: { + "line-color": i ? themeColor("--draw-map-sketch", "#ffe94d") : HALO, + "line-width": i ? 4.2 : 7.4, + }, + }) as GlSpec, + ), + { + key: "stream", + id: "stream", + type: "line", + source: "stream", + layout: ROUND, + paint: { + "line-color": themeColor("--draw-map-stream", "#38bdf8"), + "line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4], + }, + } as GlSpec, + ...LINE_KEYS.flatMap(lineKind), + // 산사태취약 — 필지 = 「이 필지 안 어딘가」 테두리만 · 점 = 지정 자리 + ...haloLine( + "landslideZones", + "landslideZones", + landslideZone, + 1.8, + [6, 3], + { color: "rgba(255, 255, 255, 0.7)", width: 3.2 }, + ["!=", ["geometry-type"], "Point"] as unknown as FilterSpecification, + ), + { + key: "landslideZones", + id: "landslideZones-point", + type: "circle", + source: "landslideZones", + filter: ["==", ["geometry-type"], "Point"], + paint: { + "circle-radius": 5, + "circle-color": landslideZone, + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 1.5, + }, + } as GlSpec, + { + key: "place", + id: "place-dot", + type: "circle", + source: "place", + paint: { "circle-radius": 2.5, "circle-color": place }, + } as GlSpec, + { + key: "place", + id: "place", + type: "symbol", + source: "place", + layout: { + "text-field": ["get", "name"], + "text-font": GL_FONT_BOLD, + "text-size": 11, + "text-anchor": "bottom", + "text-offset": [0, -0.36], + "text-padding": 2, + }, + paint: { + "text-color": place, + "text-halo-color": "rgba(0, 0, 0, 0.7)", + "text-halo-width": 1.5, + }, + } as GlSpec, + ]; +} + +/** 고른 묶음 · 선만 거름(고름 없음 = 아무것도 안 맞는 거름). */ +export function pickFilter(pick: RoadPick | null): FilterSpecification { + const all: unknown[] = ["all", ["==", ["get", "__bundle"], pick?.bundle ?? ""]]; + if (pick?.line !== undefined) all.push(["==", ["get", "__line"], pick.line]); + return all as unknown as FilterSpecification; +} + +/** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */ +export const MAP_THEME_NAMES = [ + "--draw-map-contour", + "--draw-map-road", + "--draw-map-stream", + "--draw-map-parcel", +] as const; + +/** 위성 채도(−1~1) — 조금 연하게. */ +const SAT_SATURATION = -0.3; diff --git a/A00_Common/map/map_legend.ts b/A00_Common/map/map_legend.ts new file mode 100644 index 000000000..e2dd3cc67 --- /dev/null +++ b/A00_Common/map/map_legend.ts @@ -0,0 +1,36 @@ +/* ============================================================================= + * map_legend.ts + * 공용 지도 범례(PLAN 569-2 · 622-2 · 624-2 · 요구 530) — 켠 겹 범례 칸 하나(줄마다 `createLegend`) + 페이지가 붙이는 덧 줄. + * 조각 겹 범례(산사태위험 · 국유림 임도 · 경제림 · 생태자연도) = 받을 자료 없음 → 켬끔만 보고 여기서 만듦(`tileLegendRows`). + * 받은 자료 범례(수 · 넓이 · 급경사 범위)는 받는 페이지 몫 — 줄을 만들어 `show` 에 넘김. + * 쓰는 법 = `const legend = createMapLegend("d03-plan__legend")` → `legend.show(rows, [덧 줄])` · 줄 · 덧 없으면 숨김. + * ========================================================================== */ + +import { createLegend, type LegendItem } from "@ui/ui_template_flow_legend"; +import { forestLegendRows } from "./map_forest_layers"; +import { LANDSLIDE_LEGEND, type MapLayerKey } from "./map_layer_list"; + +export interface MapLegend { + root: HTMLElement; + show: (rows: LegendItem[][], tail?: HTMLElement[]) => void; +} + +/** 켠 조각 겹 범례 — 산사태위험 다음 산림 셋. */ +export const tileLegendRows = (on: Record): LegendItem[][] => [ + ...(on.landslide ? [LANDSLIDE_LEGEND] : []), + ...forestLegendRows(on), +]; + +/** `className` = 자리 · 꼴(페이지 CSS). */ +export function createMapLegend(className: string): MapLegend { + const root = document.createElement("div"); + root.className = className; + root.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이"; + return { + root, + show(rows, tail = []) { + root.replaceChildren(...rows.map((row) => createLegend(row)), ...tail); + root.hidden = !rows.length && !tail.length; + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts index b23ea6a82..c8bc37042 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -1,14 +1,12 @@ /* ============================================================================= * D03_PlanAnalysis_UI_LayerDefs.ts - * D03 바탕 겹 정의(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 586-2) — 겹 단추 묶음 · 처음 값 · 보호구역 · 토양형 색 · - * 산사태위험지도 범례 · 선 겹(단층 · 지질구조선 · 0차곡) 꼴 · 켬끔 취향 읽기 · 면 겹 채우기 · 선 긋기 · 그림 겹 놓기 · - * GL 바탕 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림). + * D03 바탕 겹 받기 · 옛 캔버스 도움(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 586-2 · 624-2) — 임도 짐작 구분 · 물 면 · + * 면 겹 채우기 · 선 긋기 · 그림 겹 놓기 · GL 바탕 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림). + * 겹 목록 · 처음 값 · 켬끔 취향 · 선 겹 꼴 · 보호구역 색 = 공용 `A00_Common/map/map_layer_list`(624-2). * 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기 차례 · 범례). * ========================================================================== */ -import type { IconName } from "@ui/ui_template_icons"; -import type { LegendItem } from "@ui/ui_template_flow_legend"; -import { readByKey } from "../A00_Common/b_page_state"; +import type { MapLayerKey } from "../A00_Common/map/map_layer_list"; import { drawPreparedLayer, normalizedToScreen, @@ -20,172 +18,15 @@ import type { PlanRaster } from "./D03_PlanAnalysis_Api"; import type { ScreenMap } from "./D03_PlanAnalysis_UI_Map"; import type { GlImage } from "./D03_PlanAnalysis_UI_MapGl"; -export type PlanLayerKey = - | "satellite" - | "hillshade" - | "contour" - | "slope" - | "stream" - | "water" - | "road" - | "forestRoad" - | "forestRoadFile" - | "nationalRoad" - | "ecoForest" - | "ecology" - | "cadastral" - | "zones" - | "soils" - | "landslide" - | "landslideZones" - | "faults" - | "lineaments" - | "zeroOrder" - | "bands" - | "building" - | "place"; +/** 겹 목록 · 페이지 표 = 공용 `map_layer_list`(624-2) — 옛 이름으로 다시 내놓음(캔버스 · 조각 · 임도망 목록이 읽음). */ +export type PlanLayerKey = MapLayerKey; +export { + LINE_KEYS, + LINE_LAYERS, + ZONE_COLORS, + type RoadPick, +} from "../A00_Common/map/map_layer_list"; -/** 올린 임도망 고름(605-2) — 묶음 하나 또는 그 안의 선 하나(지도 강조). */ -export interface RoadPick { - bundle: string; - line?: number; -} - -export interface LayerDef { - key: PlanLayerKey; - icon: IconName; - title: string; - on: boolean; -} - -/** 단추 묶음(사이 나눔선) — 바탕 · 물 · 길 · 구역. 「지금」 겹 처음 값 = 끔(`_분석_440-C` 1-4). */ -export const GROUPS: readonly (readonly LayerDef[])[] = [ - [ - { key: "satellite", icon: "satellite", title: "위성사진", on: true }, - { key: "hillshade", icon: "sun", title: "음영기복(2 m 지형 · 산줄기 · 골)", on: false }, - { key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true }, - { key: "slope", icon: "angle", title: "급경사 색(기울기 % 범위 · 범례 ⚙ 로 바꿈)", on: false }, - ], - [ - { key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true }, - { key: "water", icon: "ripple", title: "물 면(도엽 실폭하천 · 저수지 등)", on: false }, - { key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false }, - { - key: "forestRoad", - icon: "route", - title: "기존 임도 짐작(도엽 도로 소로 · 미분류 = 주황 점선 · 정식 임도망도 아님)", - on: false, - }, - { - key: "forestRoadFile", - icon: "map", - title: - "올린 임도망도(산림청 시군구 SHP = 밝은 분홍 실선 · 왼쪽 「위치」 칸에서 올리고 묶음 고름 · 지움)", - on: false, - }, - { - key: "nationalRoad", - icon: "flag", - title: - "국유림 임도(산림청 전국 국가임도 · 간선 · 작업 · 산불 색 · 지방 · 민간임도 없음 · 참고용)", - on: false, - }, - ], - [ - { key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false }, - { - key: "zones", - icon: "shield", - title: "보호 · 규제 구역(브이월드 여덟 종 · 참고용)", - on: false, - }, - { - key: "soils", - icon: "leaf", - title: "산림입지도(토양형 · 산림청 산림입지토양도 1:25,000 · 줌 10 이상 · 참고용)", - on: false, - }, - { - key: "ecoForest", - icon: "trees", - title: "경제림육성단지(산림청 2014 · 국유 · 사유 테두리 · 참고용)", - on: false, - }, - { - key: "ecology", - icon: "plant", - title: "생태자연도(환경부 · 1~3 등급 · 별도관리 · 참고용)", - on: false, - }, - { - key: "landslide", - icon: "alert", - title: "산사태위험지도(1~5 등급 · 행안부 생활안전지도 · 참고용)", - on: false, - }, - { - key: "landslideZones", - icon: "alertOctagon", - title: "산사태취약지역(시도 지정 현황 · 점 + 지번 필지 테두리 · 참고용)", - on: false, - }, - { key: "faults", icon: "bolt", title: "단층(브이월드 지질 · 참고용)", on: false }, - { - key: "lineaments", - icon: "lineDashed", - title: "지질구조선(브이월드 지질 · 참고용)", - on: false, - }, - { - key: "zeroOrder", - icon: "arrowsJoin", - title: "0차곡(지형 추정 골짜기 머리 · 위 집수 0.1~1 ha · 곡두 통과 피함)", - on: false, - }, - { - key: "bands", - icon: "borderOuter", - title: "계류 100 m · 도로 300 m 띠(타당성평가)", - on: false, - }, - { key: "building", icon: "building", title: "건물(도엽 · 하류 민가 · 인가)", on: false }, - { key: "place", icon: "mapPin", title: "지명(도엽 계곡 · 령 · 부락)", on: false }, - ], -]; -export const LAYERS: readonly LayerDef[] = GROUPS.flat(); -/** 보호구역 종류 → 그림 색 값(서버 `ZONE_KINDS` 열쇠). */ -export const ZONE_COLORS: Record = { - forest: "--draw-map-zone-forest", - baekdu: "--draw-map-zone-baekdu", - water: "--draw-map-zone-water", - park: "--draw-map-zone-park", - heritage: "--draw-map-zone-heritage", - wetland: "--draw-map-zone-wetland", - wildlife: "--draw-map-zone-wildlife", - greenbelt: "--draw-map-zone-greenbelt", -}; -/** 산사태위험지도 범례(1 등급 = 가장 위험 · 색 = WMS 그림 색). */ -export const LANDSLIDE_LEGEND: LegendItem[] = [1, 2, 3, 4, 5].map((grade) => ({ - label: `산사태 ${grade}등급`, - color: `var(--draw-map-landslide-grade-${grade})`, -})); -/** 선 겹 — 이름 · 그림 꼴(서버 `LINE_KINDS` 열쇠 = `kind` · 없으면 0차곡). 새 선 겹 = 여기 한 줄. */ -export type LineLayerKey = "faults" | "lineaments" | "zeroOrder"; -export const LINE_LAYERS: Record< - LineLayerKey, - { label: string; kind?: string; color: string; width: number; dash: number[] } -> = { - faults: { label: "단층", kind: "faults", color: "--draw-map-fault", width: 2.4, dash: [] }, - lineaments: { - label: "지질구조선", - kind: "lineaments", - color: "--draw-map-lineament", - width: 1.8, - dash: [8, 4], - }, - zeroOrder: { label: "0차곡", color: "--draw-map-zero-order", width: 1.8, dash: [4, 3] }, -}; -export const LINE_KEYS = Object.keys(LINE_LAYERS) as LineLayerKey[]; /** 도로 중 기존 임도로 짐작하는 구분. */ export const FOREST_ROAD_KINDS = new Set(["소로", "미분류"]); /** 물 면 = 도엽 수계(E) 면 셋. */ @@ -283,22 +124,6 @@ export function drawRaster( context.drawImage(raster.image, 0, 0); } -/** 켬끔 취향(`key`) — 없거나 깨지면 처음 값. */ -export function readPref(key: string): Record { - const on = Object.fromEntries(LAYERS.map((def) => [def.key, def.on])) as Record< - PlanLayerKey, - boolean - >; - try { - const saved = JSON.parse(readByKey(key) ?? "{}") as Partial>; - for (const def of LAYERS) - if (typeof saved[def.key] === "boolean") on[def.key] = saved[def.key] as boolean; - } catch { - // 깨진 값 = 처음 값 - } - return on; -} - /** GL 자료 — 지물마다 그릴 색(`_color`) · 덧 속성을 붙인 사본. */ export function glTagged( collection: GeoJsonCollection, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts index e05c6eb77..27658bfda 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts @@ -2,6 +2,7 @@ * D03_PlanAnalysis_UI_LayerLegend.ts * D03 바탕 겹 받은 자료 꼴 · 범례 줄 · 겹별 수(PLAN 569-2 · 581 · 584-1 · 584-3) — 켠 겹의 받은 범위 안 수 · 넓이. * 급경사 = 고른 범위(603)마다 % · ° · 비율(1 % 칸 수로 바로 셈). + * 범례 칸 · 조각 겹 범례 = 공용 `A00_Common/map/map_legend`(624-2) · 여기는 받은 자료 줄만. * 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기). * ========================================================================== */ @@ -16,15 +17,14 @@ import type { PlanZones, } from "./D03_PlanAnalysis_Api"; import { - LANDSLIDE_LEGEND, LINE_KEYS, LINE_LAYERS, ZONE_COLORS, - hectare, type LineLayerKey, - type PlanLayerKey, -} from "./D03_PlanAnalysis_UI_LayerDefs"; -import { forestLegendRows } from "./D03_PlanAnalysis_UI_ForestLayers"; + type MapLayerKey, +} from "../A00_Common/map/map_layer_list"; +import { tileLegendRows } from "../A00_Common/map/map_legend"; +import { hectare } from "./D03_PlanAnalysis_UI_LayerDefs"; import { SLOPE_MAX_PCT, bandRatios, @@ -111,13 +111,12 @@ function landslideZoneRow({ info }: Prepared["landslideZones"]): LegendItem[] { } export function legendRows( - on: Record, + on: Record, data: Partial, ): LegendItem[][] { const rows: LegendItem[][] = []; if (on.slope && data.slope) rows.push(slopeRow(data.slope)); - if (on.landslide) rows.push(LANDSLIDE_LEGEND); // GL = 조각(받기 없음 · 622-2) - rows.push(...forestLegendRows(on)); + rows.push(...tileLegendRows(on)); // 조각 겹 = 받기 없음(622-2 · 공용 범례) if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones)); if (on.zones && data.zones) { const items = Object.entries(data.zones.summary) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index 26718c2fa..476535e1b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -1,7 +1,7 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Layers.ts - * D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 요구 395-2 · 440-8 · 442 · 443) — 지도 왼쪽 위 아이콘 한 줄 - * (공용 지도 도구줄 `createMapTools` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로). + * D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 624-2 · 요구 395-2 · 440-8 · 442 · 443) — 지도 왼쪽 위 아이콘 한 줄 + * (공용 겹 줄 `A00_Common/map/map_layer_bar` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로). * * 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) · * 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류) · @@ -9,28 +9,29 @@ * 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 산사태위험지도 · 산사태취약지역(시도 표 점 · 지번 필지 테두리) · * 선 겹(단층 · 지질구조선 · 0차곡) · * 계류 100 m · 도로 300 m 띠 · 건물 · 지명). - * 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억. + * 겹 목록 · 처음 켬(위성 · 등고선 · 세류) · 취향(`aislo:pref:d03.layers`) = 공용 `map_layer_list` 페이지 표 「d03」. * 받기 = 켤 때 · 받은 지형 범위 지문(`load(have)` · 페이지가 지형면을 다 읽은 뒤 넘김)이 바뀌면 차례 +1 · 켠 겹 모두 다시 · * 옛 차례 답은 버림(475) · 도엽 선 겹(등고 · 세류 · 물 면 · 도로 · 건물 · 지명) = 보이는 도엽 조각(606 · `UI_SheetTiles`) · 지형을 안 받았으면 단추를 막음 · * 받기 실패 = 그 겹만 꺼짐 + 알림 · 받는 일은 페이지 서클에 셈(`track`). * 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 → * 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선. * 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. - * 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`). + * 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · 칸 = 공용 `map_legend` · 줄 = `D03_PlanAnalysis_UI_LayerLegend`). * 급경사 = 서버 값 그림을 고른 범위대로 칠함 · 범례 ⚙ → 범위 모달(603 · `D03_PlanAnalysis_UI_SlopeBands`). * GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을 * MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 · * WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈). * 받은 산림 · 환경 자료(622-2) = 국유림 임도 · 경제림 · 생태자연도(조각만 · 받기 없음) · 산림입지도 · 산사태위험(GL = 조각 · - * 옛 캔버스 = 지금 받기 그대로) — 꼴 · 범례 = `D03_PlanAnalysis_UI_ForestLayers`. + * 옛 캔버스 = 지금 받기 그대로) — 꼴 · 범례 = 공용 `A00_Common/map/map_forest_layers`. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; -import { createLegend } from "@ui/ui_template_flow_legend"; -import { createIconButton } from "@ui/ui_template_button"; -import { createMapTools } from "@ui/ui_template_map_tools"; import { themeColor } from "@ui/ui_template_palette"; import { readByKey, writeByKey } from "../A00_Common/b_page_state"; +import { soilColor, tileOnly, type TileOnlyKey } from "../A00_Common/map/map_forest_layers"; +import { createMapLayerBar } from "../A00_Common/map/map_layer_bar"; +import { LAYERS, LINE_LAYERS, ZONE_COLORS, type RoadPick } from "../A00_Common/map/map_layer_list"; +import { createMapLegend } from "../A00_Common/map/map_legend"; import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { createNormalizer, @@ -57,23 +58,16 @@ import { } from "./D03_PlanAnalysis_Api"; import { FOREST_ROAD_KINDS, - GROUPS, - LAYERS, - LINE_LAYERS, WATER_KEYS, - ZONE_COLORS, glImage, glTagged, loadImage, - readPref, shadeImage, type PlanLayerKey, - type RoadPick, } from "./D03_PlanAnalysis_UI_LayerDefs"; import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend"; import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint"; import { createGlBase, glFont, type GlImage } from "./D03_PlanAnalysis_UI_MapGl"; -import { soilColor, tileOnly, type TileOnlyKey } from "./D03_PlanAnalysis_UI_ForestLayers"; import { createSatLightRow, readSatLight } from "./D03_PlanAnalysis_UI_SatLight"; import { createSheetTiles, @@ -90,7 +84,6 @@ import { } from "./D03_PlanAnalysis_UI_SlopeBands"; import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; -const PREF_KEY = "aislo:pref:d03.layers"; /** 옛 캔버스로 그림("1") — 처음 = GL. */ const CANVAS_PREF_KEY = "aislo:pref:d03.canvas"; /** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */ @@ -137,7 +130,6 @@ export function createPlanLayers(opts: { track: (job: Promise) => Promise; }): PlanLayers { const { projectId, map, track } = opts; - const on = readPref(PREF_KEY); const data: Partial = {}; const pending = new Map>(); let meta: VWorldMeta | null = null; @@ -152,6 +144,22 @@ export function createPlanLayers(opts: { useGl(false); }); let glOn = readByKey(CANVAS_PREF_KEY) !== "1"; + /** 겹 줄 · 켬끔 · 취향 = 공용 겹 목록(624-2 · 페이지 표 「d03」). */ + const layerBar = createMapLayerBar({ + page: "d03", + glOn, + onToggle: (key) => { + void ensure(key); + sync(); + map.redrawBase(); + }, + onGl: () => { + writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0"); + useGl(!glOn); + if (!glOn) showToast(NO_SATELLITE_NOTE, "info"); + }, + }); + const { on, toggle } = layerBar; /** 도엽 조각(606) — 조각이 오면 그 겹만 합친 자료로 다시 얹음. */ const tiles = createSheetTiles({ projectId, @@ -166,78 +174,34 @@ export function createPlanLayers(opts: { }, }); - const buttons = new Map(); - const groups = GROUPS.map((group) => - group.map((def) => { - const button = createIconButton({ - icon: def.icon, - title: def.title, - size: 32, - pressed: on[def.key], - onClick: () => toggle(def.key), - }); - button.dataset.layer = def.key; - buttons.set(def.key, button); - return button; - }), - ); - const glButton = createIconButton({ - icon: "gauge", - title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)", - size: 32, - pressed: glOn, - onClick: () => { - writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0"); - useGl(!glOn); - if (!glOn) showToast(NO_SATELLITE_NOTE, "info"); - }, - }); - glButton.dataset.layer = "gl"; /** GL ↔ 옛 캔버스 — 못 켜면 캔버스(알림은 `onFail`). */ function useGl(want: boolean): void { glOn = want && map.setGl(gl); if (!glOn) map.setGl(null); - glButton.setAttribute("aria-pressed", String(glOn)); if (!glOn) void Promise.all(LAYERS.map((def) => ensure(def.key))); // 산사태위험 한 장(622-2) sync(); map.redrawBase(); } - groups.push([glButton]); - const tools = createMapTools({ label: "지도 겹", groups }); - const bar = tools.root; + const bar = layerBar.root; bar.classList.add("d03-plan__layers"); // 571-1 — 오른쪽 평면 도구줄이 이 줄 오른쪽부터(CSS `--d03-layers-w`) new ResizeObserver(() => - map.root.style.setProperty("--d03-layers-w", `${tools.bar.offsetWidth}px`), - ).observe(tools.bar); - const legend = document.createElement("div"); - legend.className = "d03-plan__legend"; - legend.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이"; + map.root.style.setProperty("--d03-layers-w", `${layerBar.bar.offsetWidth}px`), + ).observe(layerBar.bar); + const legendBox = createMapLegend("d03-plan__legend"); + const legend = legendBox.root; /** 받음 — 위성 · 산림 조각 = GL 조각(받을 것 없음 · 옛 캔버스 = 안 그림) · 산사태위험 = GL 이면 조각(622-2). */ const loadedOf = (key: PlanLayerKey): boolean => tileOnly(key) || (glOn && key === "landslide") || !!data[dataKey(key)]; function sync(): void { - for (const def of LAYERS) { - const button = buttons.get(def.key)!; - button.setAttribute("aria-pressed", String(on[def.key])); - button.disabled = !meta; - const title = !meta - ? `${def.title} — 지형 받기 뒤 보임` - : tileOnly(def.key) && !glOn - ? `${def.title} — 옛 그림(그래픽 카드 끔)엔 안 보임` - : def.title; - button.title = title; - button.setAttribute("aria-label", title); - } + layerBar.sync({ ready: !!meta, glOn }); for (const def of LAYERS) gl.setVisible(def.key, on[def.key]); gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain)); - const rows = meta ? legendRows(on, data) : []; const light = meta && glOn && on.satellite; // 옛 캔버스 = 위성 없음 - legend.replaceChildren(...rows.map((row) => createLegend(row)), ...(light ? [lightRow] : [])); + legendBox.show(meta ? legendRows(on, data) : [], light ? [lightRow] : []); if (meta && on.slope && data.slope) legend.firstElementChild?.append(gear); - legend.hidden = !rows.length && !light; } /** 위성 밝기(요구 475) — 위성 켠 동안 범례 맨 아래 한 줄 · 바로 반영. */ @@ -279,14 +243,6 @@ export function createPlanLayers(opts: { sync(); useGl(glOn); - function toggle(key: PlanLayerKey): void { - on[key] = !on[key]; - writeByKey(PREF_KEY, JSON.stringify(on)); - void ensure(key); - sync(); - map.redrawBase(); - } - /** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */ function ensure(key: PlanLayerKey): Promise { if (!meta || !on[key] || loadedOf(key)) return Promise.resolve(); @@ -366,7 +322,7 @@ export function createPlanLayers(opts: { gl.setRoadPick(null); } on.forestRoadFile = !!info; - writeByKey(PREF_KEY, JSON.stringify(on)); + layerBar.save(); } async function deleteRoad(bundle: string): Promise { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts index d460e3e67..1c501c919 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts @@ -1,47 +1,37 @@ /* ============================================================================= * D03_PlanAnalysis_UI_MapGl.ts - * D03 GL 바탕(PLAN 586-1 · 586-2 · 요구 445 B · 448) — 바탕 겹 스물을 MapLibre(WebGL · BSD-3)로: - * 위성 조각(서버 대리 · 서버 기억 z6~19) · 그림 겹(음영 · 급경사 · 산사태위험 = image) · - * 면 겹(산림입지 · 보호구역 · 띠 · 물 면 · 건물 = fill) · 선 겹(지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 · - * 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지 = line · 점선 포함) · 점(산사태취약 · 지명 = circle) · - * 글(등고 수치 · 지명 = symbol · 겹치면 뺌 · 글꼴 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림 — - * 꼴에 글꼴 주소가 없으면 MapLibre 가 모든 글자를 화면 글꼴로 그림 · 글꼴 조각 파일 없음). + * D03 GL 바탕(PLAN 586-1 · 586-2 · 624-2 · 요구 445 B · 448) — 바탕 겹 스물을 MapLibre(WebGL · BSD-3)로. + * 겹 꼴 · 차례 = 공용 `A00_Common/map/map_layer_specs`(624-2 · B05 평면과 같은 꼴) · 여기는 D03 지도 세우기만: + * 고정 소스(위성 조각 z6~19 · 등고선 615 줌별 벡터 조각 · 산림 조각 다섯 622-2) · 받는 동안 서클 · 임도망 고름 · 위성 밝기. + * 글 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림 — 꼴에 글꼴 주소가 없으면 MapLibre 가 모든 글자를 화면 글꼴로. * 그은 선 · 재기 · 고르기 · 누름 = 위 캔버스 그대로(위에 겹침). * 카메라 주인 = D03 화면(`ViewState` · 돌림) 하나 — 여기는 따라가기만 · 조작은 안 받음. * 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 색 = 받는 쪽이 지물에 붙인 `_color`. - * 등고선 = 줌별 벡터 조각(615 · 위성처럼 고정 소스 · 가는 선 = 모두 · 굵은 선 · 글 = `k` 1) · 받는 동안 서클. - * 받은 산림 · 환경 자료(622-2) = 같은 꼴 고정 소스 다섯(`D03_PlanAnalysis_UI_ForestLayers`) · 받는 동안 서클. * 그래픽 카드 · WebGL 이 없거나 잃으면 `onFail` — 부르는 쪽이 옛 캔버스로. - * 지도 세우기 · 자료 올리기 · 카메라 = 공용 부품 `ui_template_gl_map`(589-2) · 여기는 겹 꼴만. + * 지도 세우기 · 자료 올리기 · 카메라 = 공용 부품 `ui_template_gl_map`(589-2). * ========================================================================== */ -import type { ExpressionSpecification, FilterSpecification, Map as MapLibreMap } from "maplibre-gl"; +import type { Map as MapLibreMap } from "maplibre-gl"; import { contourTileSource, createGlMap, GL_FONT, satelliteTileSource, type GlImage, - type GlSpec as GlMapSpec, } from "@ui/ui_template_gl_map"; -import { themeColor } from "@ui/ui_template_palette"; import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; -import type { PlanLayerKey, RoadPick } from "./D03_PlanAnalysis_UI_LayerDefs"; +import { forestSources, TRACKED_SOURCES } from "../A00_Common/map/map_forest_layers"; import { - forestAreaSpecs, - forestRasterSpecs, - forestRoadSpecs, - forestSources, - TRACKED_SOURCES, -} from "./D03_PlanAnalysis_UI_ForestLayers"; + MAP_THEME_NAMES, + mapLayerSpecs, + pickFilter, + type GlSource, +} from "../A00_Common/map/map_layer_specs"; +import type { PlanLayerKey, RoadPick } from "./D03_PlanAnalysis_UI_LayerDefs"; import { SAT_LIGHT_DEFAULT } from "./D03_PlanAnalysis_UI_SatLight"; -export { GL_FONT, type GlImage }; +export type { GlImage }; -/** 자료 소스(임도 짐작 = 도로 소스를 거름 · 위성 = 조각). */ -export type GlSource = Exclude; - -const GL_FONT_BOLD = [`${GL_FONT} SemiBold`, GL_FONT]; /** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */ export const glFont = (weight: string, text: string): Promise => document.fonts.load(`${weight} 24px "${GL_FONT}"`, text || "가").catch(() => null); @@ -62,304 +52,6 @@ export interface GlBase { resize: () => void; } -type GlSpec = GlMapSpec; - -const HALO = "rgba(0, 0, 0, 0.55)"; -const ROUND = { "line-join": "round", "line-cap": "round" } as const; -const COLOR: ExpressionSpecification = ["get", "_color"]; -/** 등고 조각 굵은 선(서버가 줌마다 정함) — 가는 선은 모두라 굵은 선은 두 번 겹쳐 짙음(캔버스처럼). */ -const BOLD = ["==", ["get", "k"], 1] as unknown as FilterSpecification; -/** 캔버스 점선(px) → MapLibre 점선(선 굵기 배수). */ -const dash = (px: number[], width: number): number[] => px.map((v) => v / width); - -/** 선 겹 — 검은 테두리 + 선(점선이면 둘 다 같은 px 무늬). */ -function haloLine( - key: PlanLayerKey, - source: GlSource, - color: string, - width: number, - pattern: number[] = [], - halo = { color: HALO, width: width + 1.6 }, - filter?: FilterSpecification, -): GlSpec[] { - const line = (id: string, paint: Record, w: number): GlSpec => - ({ - key, - id, - type: "line", - source, - ...(filter ? { filter } : {}), - layout: ROUND, - paint: { - ...paint, - "line-width": w, - ...(pattern.length ? { "line-dasharray": dash(pattern, w) } : {}), - }, - }) as GlSpec; - return [ - line(`${key}-halo`, { "line-color": halo.color }, halo.width), - line(key, { "line-color": color }, width), - ]; -} - -/** 면 겹 — 채움 + 테두리(캔버스 `fillLayer` 처럼 테두리 투명도 0.9). */ -function fillEdge( - key: PlanLayerKey, - source: GlSource, - fill: string | ExpressionSpecification, - opacity: number | ExpressionSpecification, - edge: { color: string | ExpressionSpecification; width: number; dash?: number[] }, -): GlSpec[] { - return [ - { - key, - id: key, - type: "fill", - source, - paint: { "fill-color": fill, "fill-opacity": opacity }, - } as GlSpec, - { - key, - id: `${key}-edge`, - type: "line", - source, - layout: ROUND, - paint: { - "line-color": edge.color, - "line-width": edge.width, - "line-opacity": 0.9, - ...(edge.dash ? { "line-dasharray": dash(edge.dash, edge.width) } : {}), - }, - } as GlSpec, - ]; -} - -/** 겹 차례(아래 → 위)와 꼴 — 캔버스 `D03_PlanAnalysis_UI_Layers` 의 색 · 굵기 · 투명도 · 차례 그대로. */ -function layerSpecs(light: number, pick: RoadPick | null): GlSpec[] { - const contour = themeColor("--draw-map-contour", "#ffd68c"); - const place = themeColor("--draw-map-place", "#f8fafc"); - const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48"); - const raster = ( - key: "satellite" | "hillshade" | "slope", - opacity: number, - extra: Record = {}, - ): GlSpec => - ({ - key, - id: key, - type: "raster", - source: key, - paint: { "raster-opacity": opacity, "raster-fade-duration": 0, ...extra }, - }) as GlSpec; - const lineKind = (key: "faults" | "lineaments" | "zeroOrder", width: number, pattern: number[]) => - haloLine(key, key, themeColor(LINE_COLOR[key], "#f43f5e"), width, pattern); - return [ - // 위성 = 조금 어둡고 연하게(요구 475 · 노선 선 대비) — 밝기 상한은 사용자 조절 - raster("satellite", 1, { "raster-brightness-max": light, "raster-saturation": SAT_SATURATION }), - raster("hillshade", 1), // 위성 위 = 「곱하기」와 같은 검은 그늘 그림(받는 쪽이 바꿈) - raster("slope", 0.55), - ...forestRasterSpecs(), // 산사태위험 · 생태자연도 = 조각 그림(622-2) - ...forestAreaSpecs(), // 산림입지도 · 경제림 = 벡터 조각 - ...fillEdge("zones", "zones", COLOR, 0.22, { color: COLOR, width: 1.6 }), - ...fillEdge("bands", "bands", COLOR, ["case", ["==", ["get", "_band"], "road"], 0.16, 0.18], { - color: COLOR, - width: 1.2, - dash: [6, 4], - }), - { - key: "cadastral", - id: "cadastral", - type: "line", - source: "cadastral", - layout: ROUND, - paint: { - "line-color": themeColor("--draw-map-parcel", "#f0abfc"), - "line-width": 0.8, - "line-opacity": 0.8, - }, - } as GlSpec, - ...((): GlSpec[] => { - const water = themeColor("--draw-map-water", "#0ea5e9"); - return fillEdge("water", "water", water, 0.45, { color: water, width: 1 }); - })(), - ...fillEdge("building", "building", themeColor("--draw-map-building", "#fb7185"), 0.7, { - color: HALO, - width: 0.6, - }), - { - key: "contour", - id: "contour-minor", - type: "line", - source: "contour", - "source-layer": "contour", - layout: ROUND, - paint: { "line-color": contour, "line-width": 0.8, "line-opacity": 0.7 }, - } as GlSpec, - { - key: "contour", - id: "contour-index", - type: "line", - source: "contour", - "source-layer": "contour", - filter: BOLD, - layout: ROUND, - paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 }, - } as GlSpec, - { - key: "contour", - id: "contour-label", - type: "symbol", - source: "contour", - "source-layer": "contour", - filter: BOLD, - layout: { - "symbol-placement": "line", - "symbol-spacing": 320, - "text-field": ["to-string", ["get", "h"]], - "text-font": [GL_FONT], - "text-size": 10, - "text-max-angle": 30, - "text-padding": 2, - }, - paint: { - "text-color": contour, - "text-halo-color": "rgba(0, 0, 0, 0.6)", - "text-halo-width": 1.5, - }, - } as GlSpec, - ...haloLine("road", "road", themeColor("--draw-map-road", "#f8fafc"), 1.6, [], { - color: HALO, - width: 3.2, - }), - ...haloLine( - "forestRoad", - "road", - themeColor("--draw-map-forest-road", "#f59e0b"), - 2, - [7, 4], - { color: HALO, width: 3.6 }, - [ - "in", - ["get", "도로구분"], - ["literal", ["소로", "미분류"]], - ] as unknown as FilterSpecification, - ), - ...haloLine( - "forestRoadFile", - "forestRoadFile", - themeColor("--draw-map-forest-road-file", "#ff2fd0"), - 2.4, - [], - { color: HALO, width: 4 }, - ), - ...forestRoadSpecs(), // 국유림 임도 = 벡터 조각 - // 고른 묶음 · 선 강조(605-2) — 같은 자료에서 걸러 한 겹 더 굵게 - ...["-pick-halo", "-pick"].map( - (tail, i) => - ({ - key: "forestRoadFile", - id: `forestRoadFile${tail}`, - type: "line", - source: "forestRoadFile", - filter: pickFilter(pick), - layout: ROUND, - paint: { - "line-color": i ? themeColor("--draw-map-sketch", "#ffe94d") : HALO, - "line-width": i ? 4.2 : 7.4, - }, - }) as GlSpec, - ), - { - key: "stream", - id: "stream", - type: "line", - source: "stream", - layout: ROUND, - paint: { - "line-color": themeColor("--draw-map-stream", "#38bdf8"), - "line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4], - }, - } as GlSpec, - ...lineKind("faults", 2.4, []), - ...lineKind("lineaments", 1.8, [8, 4]), - ...lineKind("zeroOrder", 1.8, [4, 3]), - // 산사태취약 — 필지 = 「이 필지 안 어딘가」 테두리만 · 점 = 지정 자리 - ...haloLine( - "landslideZones", - "landslideZones", - landslideZone, - 1.8, - [6, 3], - { color: "rgba(255, 255, 255, 0.7)", width: 3.2 }, - ["!=", ["geometry-type"], "Point"] as unknown as FilterSpecification, - ), - { - key: "landslideZones", - id: "landslideZones-point", - type: "circle", - source: "landslideZones", - filter: ["==", ["geometry-type"], "Point"], - paint: { - "circle-radius": 5, - "circle-color": landslideZone, - "circle-stroke-color": "#ffffff", - "circle-stroke-width": 1.5, - }, - } as GlSpec, - { - key: "place", - id: "place-dot", - type: "circle", - source: "place", - paint: { "circle-radius": 2.5, "circle-color": place }, - } as GlSpec, - { - key: "place", - id: "place", - type: "symbol", - source: "place", - layout: { - "text-field": ["get", "name"], - "text-font": GL_FONT_BOLD, - "text-size": 11, - "text-anchor": "bottom", - "text-offset": [0, -0.36], - "text-padding": 2, - }, - paint: { - "text-color": place, - "text-halo-color": "rgba(0, 0, 0, 0.7)", - "text-halo-width": 1.5, - }, - } as GlSpec, - ]; -} - -/** 고른 묶음 · 선만 거름(고름 없음 = 아무것도 안 맞는 거름). */ -function pickFilter(pick: RoadPick | null): FilterSpecification { - const all: unknown[] = ["all", ["==", ["get", "__bundle"], pick?.bundle ?? ""]]; - if (pick?.line !== undefined) all.push(["==", ["get", "__line"], pick.line]); - return all as unknown as FilterSpecification; -} - -/** 선 겹 색 값(캔버스 `LINE_LAYERS` 와 같음). */ -const LINE_COLOR = { - faults: "--draw-map-fault", - lineaments: "--draw-map-lineament", - zeroOrder: "--draw-map-zero-order", -} as const; - -/** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */ -const THEME_NAMES = [ - "--draw-map-contour", - "--draw-map-road", - "--draw-map-stream", - "--draw-map-parcel", -] as const; - -/** 위성 채도(−1~1) — 조금 연하게. */ -const SAT_SATURATION = -0.3; - /** `track` = 받는 일을 진행 서클에 셈(등고 · 산림 조각 받는 동안). */ export function createGlBase( projectId: string, @@ -369,13 +61,13 @@ export function createGlBase( let light = SAT_LIGHT_DEFAULT; let pick: RoadPick | null = null; const base = createGlMap({ - specs: () => layerSpecs(light, pick), + specs: () => mapLayerSpecs(light, pick), fixed: { satellite: satelliteTileSource(projectId), contour: contourTileSource(projectId), ...forestSources(projectId), }, - themeNames: THEME_NAMES, + themeNames: MAP_THEME_NAMES, className: "d03-plan__gl", onFail, }); diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index a5a393273..e884b07f9 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -58,10 +58,11 @@ def test_d03_parts(): paint = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts") # 580 — 그림 나눔 assert "map.stations" in paint and "map.slopes" in paint # 569-2 · 581-1 · 581-2 — 바탕 겹 단추(공용 지도 도구줄 한 줄 · 묶음 셋) · 처음 켬 셋 · 취향 기억 - defs = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts") - assert "createMapTools({" in layers and "createIconButton({" in layers - assert "createToolbar(" not in layers - assert '"aislo:pref:d03.layers"' in layers and "writeByKey(PREF_KEY" in layers + defs = _src("A00_Common/map/map_layer_list.ts") # 624-2 공용 겹 목록 + bar = _src("A00_Common/map/map_layer_bar.ts") + assert "createMapTools({" in bar and "createIconButton({" in bar and "createMapLayerBar({" in layers + assert "createToolbar(" not in layers and "createToolbar(" not in bar + assert '"aislo:pref:d03.layers"' in defs and "writeByKey(MAP_LAYER_PAGES[page].pref" in bar keys = re.findall(r'key: "(\w+)",\s+icon: "\w+",\s+title:\s+"[^"]+",\s+on: (true|false)', defs) assert keys == [ ("satellite", "true"), @@ -73,9 +74,12 @@ def test_d03_parts(): ("road", "false"), ("forestRoad", "false"), ("forestRoadFile", "false"), + ("nationalRoad", "false"), ("cadastral", "false"), ("zones", "false"), ("soils", "false"), + ("ecoForest", "false"), + ("ecology", "false"), ("landslide", "false"), ("landslideZones", "false"), ("faults", "false"), diff --git a/resources/tester/test_581_2_plan_soils.py b/resources/tester/test_581_2_plan_soils.py index b8d798427..1026d760b 100644 --- a/resources/tester/test_581_2_plan_soils.py +++ b/resources/tester/test_581_2_plan_soils.py @@ -85,7 +85,7 @@ def test_soils_fail_raises(tmp_path, monkeypatch) -> None: def test_screen_def_and_colors() -> None: - defs = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8") + defs = (ROOT / "A00_Common" / "map" / "map_layer_list.ts").read_text("utf-8") # 624-2 entry = re.search(r'key: "soils",\s*icon: "(\w+)",[^}]*on: (\w+)', defs) assert entry and entry.group(2) == "false" # 처음 값 끔 icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8") diff --git a/resources/tester/test_584_1_plan_geo_lines.py b/resources/tester/test_584_1_plan_geo_lines.py index bb9ae2cf1..dd80691f4 100644 --- a/resources/tester/test_584_1_plan_geo_lines.py +++ b/resources/tester/test_584_1_plan_geo_lines.py @@ -191,7 +191,7 @@ def test_zero_order_no_dem(tmp_path) -> None: def test_screen_defs_icons_colors() -> None: - defs = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8") + defs = (ROOT / "A00_Common" / "map" / "map_layer_list.ts").read_text("utf-8") # 624-2 icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8") theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8") for key, color in ( diff --git a/resources/tester/test_584_3_plan_forest_road_zones.py b/resources/tester/test_584_3_plan_forest_road_zones.py index 9526c0e00..7586f991c 100644 --- a/resources/tester/test_584_3_plan_forest_road_zones.py +++ b/resources/tester/test_584_3_plan_forest_road_zones.py @@ -324,7 +324,7 @@ def test_zone_layer_saved_and_no_cadastral(tmp_path, monkeypatch) -> None: def test_screen_defs() -> None: folder = ROOT / "D03_PlanAnalysis" - defs = (folder / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8") + defs = (ROOT / "A00_Common" / "map" / "map_layer_list.ts").read_text("utf-8") # 624-2 icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8") theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8") paint = (folder / "D03_PlanAnalysis_UI_LayerPaint.ts").read_text( diff --git a/resources/tester/test_606_1_sat_light.py b/resources/tester/test_606_1_sat_light.py index 6263c3cc9..c4f0de6e5 100644 --- a/resources/tester/test_606_1_sat_light.py +++ b/resources/tester/test_606_1_sat_light.py @@ -9,7 +9,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] read = lambda rel: (ROOT / rel).read_text(encoding="utf-8") # noqa: E731 -MAPGL = read("D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts") +MAPGL = read("A00_Common/map/map_layer_specs.ts") # 624-2 GL 겹 꼴 = 공용 LIGHT = read("D03_PlanAnalysis/D03_PlanAnalysis_UI_SatLight.ts") LAYERS = read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") diff --git a/resources/tester/test_615_contour_tiles.py b/resources/tester/test_615_contour_tiles.py index 6ad44dae9..c4af9c1e7 100644 --- a/resources/tester/test_615_contour_tiles.py +++ b/resources/tester/test_615_contour_tiles.py @@ -94,4 +94,5 @@ def test_screen_uses_fixed_vector_source() -> None: assert "/plan/contour/{z}/{x}/{y}.pbf" in gl base = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_MapGl.ts").read_text(encoding="utf-8") assert "contour: contourTileSource(projectId)" in base - assert base.count('"source-layer": "contour"') == 3 + specs = (ROOT / "A00_Common" / "map" / "map_layer_specs.ts").read_text(encoding="utf-8") + assert specs.count('"source-layer": "contour"') == 3 # 624-2 GL 겹 꼴 = 공용 diff --git a/resources/tester/test_622_2_forest_layers.py b/resources/tester/test_622_2_forest_layers.py index 7b89f5d95..d758fa61b 100644 --- a/resources/tester/test_622_2_forest_layers.py +++ b/resources/tester/test_622_2_forest_layers.py @@ -8,6 +8,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] D03 = ROOT / "D03_PlanAnalysis" +MAP = ROOT / "A00_Common" / "map" # 624-2 공용 지도 부품 def _read(path: Path) -> str: @@ -18,7 +19,7 @@ def test_fixed_sources_follow_plan_622_routes() -> None: gl = _read(ROOT / "ui_template" / "ui_template_gl_map.ts") assert "export function tileSource" in gl assert "/plan/${path}/{z}/{x}/{y}.${ext}" in gl - forest = _read(D03 / "D03_PlanAnalysis_UI_ForestLayers.ts") + forest = _read(MAP / "map_forest_layers.ts") for name in ("forest_road", "forest_soil", "eco_forest"): assert f'vt("{name}")' in forest assert f'...vector("{name}")' in forest or f'"source-layer": "{name}"' in forest @@ -33,7 +34,7 @@ def test_fixed_sources_follow_plan_622_routes() -> None: def test_layer_list_five_off_by_default() -> None: - defs = _read(D03 / "D03_PlanAnalysis_UI_LayerDefs.ts") + defs = _read(MAP / "map_layer_list.ts") icons = _read(ROOT / "ui_template" / "ui_template_icons.ts") for key in ("nationalRoad", "soils", "ecoForest", "ecology", "landslide"): entry = re.search(rf'key: "{key}",\s*icon: "(\w+)",[^}}]*on: (\w+)', defs) @@ -43,7 +44,7 @@ def test_layer_list_five_off_by_default() -> None: def test_colors_are_draw_map_tokens() -> None: theme = _read(ROOT / "ui_template" / "ui_template_theme.css") - forest = _read(D03 / "D03_PlanAnalysis_UI_ForestLayers.ts") + forest = _read(MAP / "map_forest_layers.ts") for token in set(re.findall(r'"(--draw-map-[\w-]+)"', forest)): assert f"{token}:" in theme, token assert all(f"--draw-map-soil-{n}:" in theme for n in range(1, 7)) From e073e575f3d81631b36fee1d16d2414398a8c791 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:19:14 +0900 Subject: [PATCH 08/18] =?UTF-8?q?feat(D03):=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EA=B2=B9=20=EA=B8=B8=20=EC=9E=84=EB=8F=84=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=EB=8F=84=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=ED=98=95=20=EC=9B=90=EC=B2=9C=20=EC=9D=B8=EC=9E=90=20?= =?UTF-8?q?=C2=B7=20=EB=B0=9B=EC=9D=80=20=EA=B2=B9=20D03=20=EB=AA=AB=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20(PLAN=20624-1=20=C2=B7=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20530=20=C2=B7=20531)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 겹 길(`/plan/layers/*`)이 프로젝트 종류를 안 가림 — 예정노선 = 받은 범위 · 수치지형도 · 임도 = B04 배경 지도 상자 · 설계 지형 - `terrain=sheet|lidar|design` 인자(raster · zero-order) · 응답 `source` · 파생 저장본 열쇠 = 원천 격자 파일 이름 - 라이다 0차곡 = 격자 통째(2 m 솎음 · 파생 저장본) · 수치지형도 = 창고 도엽 선 그대로 - 받은 겹(지적 · 보호구역 · 산림입지 · 단층 · 지질구조선 · 산사태) 자리 = `D03_PlanAnalysis/layers/` — B04 `processed/` 파일 덮음 0 · D 옛 자리 파일은 한 번 복사 - 새 `D03_PlanAnalysis_TerrainSource.py` · 라우터 661 → 658 줄 · 시험 `test_624_1_layers_any_project.py` Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Layers.py | 16 +- D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 69 +++--- D03_PlanAnalysis/D03_PlanAnalysis_Store.py | 14 +- .../D03_PlanAnalysis_TerrainSource.py | 179 ++++++++++++++++ .../D03_PlanAnalysis_ZeroOrder.py | 58 +++++- .../tester/test_624_1_layers_any_project.py | 196 ++++++++++++++++++ 6 files changed, 485 insertions(+), 47 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_TerrainSource.py create mode 100644 resources/tester/test_624_1_layers_any_project.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py index b943400a0..a0d249f54 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py @@ -3,19 +3,20 @@ - 도엽 겹(하천중심선 · 도로중심선 …) = 지형 받기 병합본 · 없으면 지난 병합 도엽에서 그 겹만 풀어 냄 - 지적도 = 브이월드 WFS LX맵(`lt_c_landinfobasemap`) · B04 `request_vworld_wfs` 를 1 km 조각으로 · 한 번 1,000 필지 한도 → 꽉 차면 넷으로 다시 쪼갬 · `pnu` 로 겹친 필지 거름 · - 저장 = `processed/연속지적도_bounds.geojson`(B04 `/geojson?layer=지적도` 자리) + 곁 열쇠 + 저장 = `<받은 겹 자리>/연속지적도_bounds.geojson` + 곁 열쇠 · 받은 겹 자리 = D03 몫 + `D03_PlanAnalysis/layers/`(B04 `processed/` 와 이름 겹침 0 · `_TerrainSource` · 624-1) - 급경사 = 평활 안 한 사업 DEM(`models_plan/dtm_sheet_<방법>.npz`) · E07 `slope_rad` · 기울기 % 값 그림 한 장(1 % 단계 · 회색) + 1 % 칸 수 · 범위 색칠 · 비율 = 화면(PLAN 603) · 파생 저장본(열쇠 = 격자 지문) - 음영기복 = 같은 DEM · B07 `hillshade_png`(PLAN 581-1) - 보호 · 규제 구역 = 브이월드 WFS 여덟 종(겹 이름 = `_분석_440-C` 표 1) · - 받은 범위로 잘라 종류 · 이름 · 넓이만 · 저장 = `processed/보호구역_bounds.geojson` + 곁 열쇠 · + 받은 범위로 잘라 종류 · 이름 · 넓이만 · 저장 = `layers/보호구역_bounds.geojson` + 곁 열쇠 · 종류 하나 실패 = 그 종류만 빠짐 - 산림입지도 = 브이월드 WFS `lt_c_fsdifrsts`(원자료 산림청 산림입지토양도 · PLAN 581-2) · 받은 범위로 잘라 토양형 코드 · 이름 · 넓이만 · - 저장 = `processed/산림입지도_bounds.geojson` + 곁 열쇠 + 저장 = `layers/산림입지도_bounds.geojson` + 곁 열쇠 - 선 겹 = 브이월드 WFS 단층 `lt_l_gimsfault` · 지질구조선 `lt_l_gimslinea`(PLAN 584-1) · - 받은 범위로 잘라 가닥 · 길이만 · 저장 = `processed/<이름>_bounds.geojson` + 곁 열쇠 + 받은 범위로 잘라 가닥 · 길이만 · 저장 = `layers/<이름>_bounds.geojson` + 곁 열쇠 - 0차곡 = `D03_PlanAnalysis_ZeroOrder` - 계류 100 m · 도로 300 m 띠 = 도엽 하천중심선(세류 뺌) · 도로중심선(미분류 · 소로 · 면리간도로 뺌) 합친 띠 · 받은 범위로 자름 · 파생 저장본(열쇠 = 두 파일 지문) @@ -253,10 +254,11 @@ def hillshade_layer_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict def slope_layer( - project_root: Path, models_dir: Path, epsg: int, kind: str = "slope" + project_root: Path, models_dir: Path, epsg: int, kind: str = "slope", npz: Path | None = None ) -> dict[str, Any]: - """급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 판) 같으면 저장본.""" - npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz" + """급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 판) 같으면 저장본 · + `npz` = 지형 원천 격자(624-1 · 열쇠의 파일 이름 = 원천) · 없으면 D 수치지형도.""" + npz = npz or models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz" stamp = file_stamp(npz) if stamp is None: raise FileNotFoundError("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.") diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index d42ce2119..5dde3db34 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -30,7 +30,7 @@ from pathlib import Path from typing import Any from uuid import UUID, uuid4 -from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFile +from fastapi import APIRouter, File, HTTPException, Query, Request, Response, UploadFile from pydantic import BaseModel from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path @@ -48,6 +48,7 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Network as network from D03_PlanAnalysis import D03_PlanAnalysis_SheetTiles as sheet_tiles from D03_PlanAnalysis import D03_PlanAnalysis_Store as store from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain +from D03_PlanAnalysis import D03_PlanAnalysis_TerrainSource as terrain_source from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero_order logger = logging.getLogger(__name__) @@ -282,13 +283,9 @@ async def plan_progress(project_id: UUID) -> dict[str, Any]: return {"status": "success", **jobs} -async def _layer_root(project_id: UUID) -> tuple[Path, int, terrain.Box]: - """(실경로, 좌표계, 받은 범위) — 지형 받기 전이면 404.""" - root, epsg, _, _ = await _plan_ends(project_id) - box = terrain.received_box(root, epsg) - if box is None: - raise HTTPException(status_code=404, detail="지형 받기 뒤 보입니다.") - return root, epsg, box +async def _layer_root(project_id: UUID, source: str | None = None) -> terrain_source.Frame: + """겹 틀 — 임도 프로젝트도 · 지형 원천(624-1 · `TerrainSource.layer_frame`).""" + return await terrain_source.layer_frame(project_id, source, _plan_ends) SHEET_CLIP_KIND = "plan_sheet" @@ -335,7 +332,7 @@ async def plan_layer_sheet(project_id: UUID, key: str, request: Request) -> Resp 받은 범위로 자름(475 · 범위가 늘면 새 사본).""" from B04_PreProcess.B04_PreProcess_Router_GIS import _display_copy - root, epsg, box = await _layer_root(project_id) + root, epsg, box, *_ = await _layer_root(project_id) try: path = await asyncio.to_thread(layers.ensure_sheet_layer, root, key) except KeyError as error: @@ -389,8 +386,7 @@ async def plan_contour_tile(project_id: UUID, z: int, x: int, y: int) -> Respons @router.get("/{project_id}/plan/layers/cadastral", response_model=None) async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response: """지적도 GeoJSON(브이월드 LX맵 · 참고용) — 받은 범위를 덮지 않으면 지금 받음.""" - root, epsg, box = await _layer_root(project_id) - processed = root / terrain.STAGE / "processed" + _, epsg, box, processed, *_ = await _layer_root(project_id) try: got = await asyncio.to_thread(layers.ensure_cadastral, processed, box, epsg) except RuntimeError as error: @@ -399,13 +395,16 @@ async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response: @router.get("/{project_id}/plan/layers/raster", response_model=None) -async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any] | Response: +async def plan_layer_raster( + project_id: UUID, kind: str = "slope", source: str | None = Query(None, alias="terrain") +) -> dict[str, Any] | Response: """급경사 기울기 % 값 그림 + 1 % 칸 수(603 · PNG 바이트 + 머리 `X-Plan-Raster` 616-4) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 · - 산사태위험지도(`landslide` · 행안부 WMS) — 받은 범위를 덮으면 저장본.""" + 산사태위험지도(`landslide` · 행안부 WMS) — 받은 범위를 덮으면 저장본 · + `terrain` = 지형 원천(sheet · lidar · design · 624-1).""" + frame = await _layer_root(project_id, source) if kind == "landslide": - root, epsg, box = await _layer_root(project_id) - processed = root / terrain.STAGE / "processed" + _, epsg, box, processed, *_ = frame try: value = await asyncio.to_thread(landslide.ensure_landslide, processed, box, epsg) except landslide.MissingKeyError as error: @@ -416,25 +415,24 @@ async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, return {"status": "success", "kind": kind, **value, "box": box_lonlat} if kind not in layers.RASTER_KINDS: raise HTTPException(status_code=400, detail=f"없는 그림 겹: {kind}") - root, epsg, _ = await _layer_root(project_id) + root, epsg, _, _, source, _ = frame + npz = terrain_source.need_dem(frame) try: - value = await asyncio.to_thread( - layers.slope_layer, root, terrain.models_dir_of(root), epsg, kind - ) + value = await asyncio.to_thread(layers.slope_layer, root, npz.parent, epsg, kind, npz) except FileNotFoundError as error: raise HTTPException(status_code=404, detail=str(error)) from error if kind == "slope": png, meta = layers.raster_bytes(value) - head = {"X-Plan-Raster": json.dumps({"kind": kind, **meta}), "Content-Encoding": "identity"} + meta = {"kind": kind, "source": source, **meta} + head = {"X-Plan-Raster": json.dumps(meta), "Content-Encoding": "identity"} return Response(png, media_type="image/png", headers=head) # PNG = 다시 압축 안 함 - return {"status": "success", "kind": kind, **value} + return {"status": "success", "kind": kind, "source": source, **value} @router.get("/{project_id}/plan/layers/zones", response_model=None) async def plan_layer_zones(project_id: UUID, request: Request) -> Response: """보호 · 규제 구역 GeoJSON(여덟 종 · `summary` = 종류별 개수 · 넓이) — 범위 밖이면 받음.""" - root, epsg, box = await _layer_root(project_id) - processed = root / terrain.STAGE / "processed" + _, epsg, box, processed, *_ = await _layer_root(project_id) try: got = await asyncio.to_thread(layers.ensure_zones, processed, box, epsg) except RuntimeError as error: @@ -445,8 +443,7 @@ async def plan_layer_zones(project_id: UUID, request: Request) -> Response: @router.get("/{project_id}/plan/layers/soils", response_model=None) async def plan_layer_soils(project_id: UUID, request: Request) -> Response: """산림입지도 GeoJSON(토양형 · `summary` = 토양형별 개수 · 넓이) — 범위 밖이면 받음.""" - root, epsg, box = await _layer_root(project_id) - processed = root / terrain.STAGE / "processed" + _, epsg, box, processed, *_ = await _layer_root(project_id) try: got = await asyncio.to_thread(layers.ensure_soils, processed, box, epsg) except RuntimeError as error: @@ -457,7 +454,7 @@ async def plan_layer_soils(project_id: UUID, request: Request) -> Response: @router.get("/{project_id}/plan/layers/bands", response_model=None) async def plan_layer_bands(project_id: UUID) -> dict[str, Any]: """계류 100 m · 도로 300 m 띠(넓이 · 받은 범위 비율) — 두 도엽 겹 지문 같으면 저장본.""" - root, epsg, box = await _layer_root(project_id) + root, epsg, box, *_ = await _layer_root(project_id) try: value = await asyncio.to_thread(layers.band_layer, root, epsg, box) except FileNotFoundError as error: @@ -470,8 +467,7 @@ async def plan_layer_lines(project_id: UUID, kind: str, request: Request) -> Res """선 겹 GeoJSON(단층 · 지질구조선 · `summary` = 가닥 · 길이 m) — 범위 밖이면 받음.""" if kind not in layers.LINE_KINDS: raise HTTPException(status_code=400, detail=f"없는 선 겹: {kind}") - root, epsg, box = await _layer_root(project_id) - processed = root / terrain.STAGE / "processed" + _, epsg, box, processed, *_ = await _layer_root(project_id) try: got = await asyncio.to_thread(layers.ensure_lines, processed, box, epsg, kind) except RuntimeError as error: @@ -484,22 +480,24 @@ async def plan_layer_zero_order( project_id: UUID, min_m2: float = zero_order.ZERO_ORDER_MIN_M2, max_m2: float = zero_order.ZERO_ORDER_MAX_M2, + source: str | None = Query(None, alias="terrain"), ) -> Response: """0차곡 선(지형 추정 · 위 집수 `min_m2` ~ `max_m2`) — 창고 도엽 선 · 다른 임계는 저장본 · 창고에 없어 셈이 필요하면 서버 본체 밖 일꾼(616-3 · 다른 겹 요청을 안 막음).""" if not 0 < min_m2 < max_m2: raise HTTPException(status_code=400, detail="0 < min_m2 < max_m2 여야 합니다.") - root, epsg, _ = await _layer_root(project_id) - args = (root, terrain.models_dir_of(root), epsg, min_m2, max_m2) + frame = await _layer_root(project_id, source) + work, light, full = terrain_source.zero_order_calls(frame, min_m2, max_m2) pool = zero_order.heavy_pool() try: - value = await asyncio.to_thread(zero_order.zero_order_layer, *args, False) + value = await asyncio.to_thread(work, *light) if value is None: - loop = asyncio.get_running_loop() - value = await loop.run_in_executor(pool, zero_order.zero_order_layer, *args) + value = await asyncio.get_running_loop().run_in_executor(pool, work, *full) except FileNotFoundError as error: raise HTTPException(status_code=404, detail=str(error)) from error - body = json.dumps({"status": "success", **value}, ensure_ascii=False) # 큰 선 묶음 = 바로 글로 + body = json.dumps( + {"status": "success", "source": frame.source, **value}, ensure_ascii=False + ) # 큰 선 묶음 = 바로 글로 return Response(body, media_type="application/json") @@ -591,8 +589,7 @@ async def plan_layer_forest_roads_upload( async def plan_layer_landslide_zones(project_id: UUID) -> dict[str, Any]: """산사태취약지역(시도 표 · 점 + 지번 맞는 필지 테두리 · 표 없는 시도) — 지적도 못 받으면 점만.""" - root, epsg, box = await _layer_root(project_id) - processed = root / terrain.STAGE / "processed" + root, epsg, box, processed, *_ = await _layer_root(project_id) try: got = await asyncio.to_thread(layers.ensure_cadastral, processed, box, epsg) cadastral = got["path"] diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Store.py b/D03_PlanAnalysis/D03_PlanAnalysis_Store.py index d8217a2ae..f6a7f02cb 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Store.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Store.py @@ -64,7 +64,8 @@ def save_plan(root: str | Path, plan: dict[str, Any]) -> None: atomic_write_json(plan_path(root), _clean(plan)) -async def _project(cursor: Any, project_id: UUID | str) -> dict[str, Any]: +async def _project(cursor: Any, project_id: UUID | str, plan_only: bool = True) -> dict[str, Any]: + """프로젝트 줄 — `plan_only` = 예정노선 아니면 400(지도 겹 길은 종류 안 가림 · 624-1).""" await cursor.execute( "SELECT project_kind, project_year, storage_path FROM projects" " WHERE id = %s AND deleted_at IS NULL", @@ -73,7 +74,7 @@ async def _project(cursor: Any, project_id: UUID | str) -> dict[str, Any]: project = await cursor.fetchone() if not project: raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") - if project["project_kind"] != "PLAN" or project["project_year"] is None: + if plan_only and (project["project_kind"] != "PLAN" or project["project_year"] is None): raise HTTPException(status_code=400, detail="예정노선 프로젝트가 아닙니다.") if not project["storage_path"]: raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.") @@ -91,6 +92,15 @@ async def load_plan(project_id: UUID | str) -> tuple[Path, dict[str, Any]]: return root, read_plan(root) or empty_plan() +async def project_row(project_id: UUID | str) -> dict[str, Any]: + """{project_kind, project_year, storage_path} — 종류 안 가림 · 없음 404.""" + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + return await _project(cursor, project_id, plan_only=False) + + def plan_units(plan: dict[str, Any]) -> list[dict[str, Any]]: """조각마다 {id · line · line_name · year · epsg · from_m · to_m · length_m · planned · stations} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_TerrainSource.py b/D03_PlanAnalysis/D03_PlanAnalysis_TerrainSource.py new file mode 100644 index 000000000..7481ba5f7 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_TerrainSource.py @@ -0,0 +1,179 @@ +"""D03 지도 겹의 프로젝트 틀 · 지형 원천 (요구 530 · 531 · PLAN 624-1). + +겹 길(`/plan/layers/*`)은 프로젝트 종류를 안 가림: + +- 예정노선(PLAN) = 받은 범위 · 지형 = 수치지형도(`models_plan/dtm_sheet_<방법>.npz`) · + 라이다 = D 에 LAS 올리기(624-4) 전이라 없음(404) +- 그 밖(임도 등) = 범위 = B04 배경 지도 상자(`vworld_white_meta.json` · 없으면 원천 격자 상자) · + 지형 = 설계 지형(stage 1 params · 기본) · `lidar` = 라이다 확정 지표면 · + `sheet` = 수치지형도 서피스 · + 평활 고르기 = 서버 표본기와 같음(`B05_Profile_Router_Terrain.dtm_grid_npz`) +- 원천 인자 `terrain=sheet|lidar|design`(빈 값 = design) · 파생 저장본 열쇠 = 격자 파일 이름(원천) +- 받은 겹 저장 = 종류 상관없이 `D03_PlanAnalysis/layers/`(B04 `processed/` 파일과 이름 겹침 0) · + D 옛 자리(`B04_PreProcess/processed/`) 파일은 새 자리에 없을 때 한 번 복사(다시 받지 않게) + +계산 자리 = ③ 서버 단독. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any, Callable, NamedTuple + +import numpy as np +from fastapi import HTTPException + +from common_util.common_util_storage import resolve_stored_project_path +from config.config_system_terrain import SHEET_SURFACE_DEFAULT_METHOD +from D03_PlanAnalysis import D03_PlanAnalysis_Landslide as landslide +from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers +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 + +Box = tuple[float, float, float, float] + +SOURCES = ("sheet", "lidar", "design") +LAYER_DIR = Path("D03_PlanAnalysis") / "layers" +OLD_DIR = Path("B04_PreProcess") / "processed" +ROAD_MODELS = Path("B04_PreProcess") / "models" +ROAD_META = ("vworld_white_meta.json", "vworld_satellite_meta.json") + + +def _legacy_names() -> list[str]: + names = [layers.CADASTRAL_FILE, layers.CADASTRAL_KEY, layers.ZONE_FILE, layers.ZONE_KEY] + names += [layers.SOIL_FILE, layers.SOIL_KEY, landslide.LANDSLIDE_FILE] + for label, _ in layers.LINE_KINDS.values(): + names += [f"{label}_bounds.geojson", f"{label}_bounds.key.json"] + return names + + +class Frame(NamedTuple): + root: Path + epsg: int + box: Box + store: Path # 받은 겹 자리(D03 몫) + source: str # 실제 원천 — sheet · lidar + dem: Path | None # 원천 격자(없으면 None) + + +def check_source(choice: str | None) -> str: + if choice in (None, ""): + return "design" + if choice not in SOURCES: + raise HTTPException(status_code=400, detail=f"없는 지형 원천: {choice}") + return choice + + +def layer_store(root: Path, plan: bool) -> Path: + """받은 겹 자리 — D 는 옛 자리 파일을 처음 한 번 복사(덮지 않음).""" + store = Path(root) / LAYER_DIR + store.mkdir(parents=True, exist_ok=True) + if plan: + for name in _legacy_names(): + old, new = Path(root) / OLD_DIR / name, store / name + if old.is_file() and not new.exists(): + shutil.copy2(old, new) + return store + + +def plan_frame(root: Path, epsg: int, box: Box, choice: str | None) -> Frame: + """예정노선 — 수치지형도 하나(design = sheet) · lidar = 없음.""" + source = check_source(choice) + source = "sheet" if source == "design" else source + npz = terrain.models_dir_of(Path(root)) / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz" + return Frame( + Path(root), epsg, box, layer_store(root, True), source, npz if source == "sheet" else None + ) + + +def road_dem(models: Path, params: dict[str, Any], source: str) -> tuple[str, Path | None]: + """(실제 원천, 격자) — 고른 원천 params 의 `dtm_<필터>`(평활이면 `_smooth` 먼저) · + 라이다 없는 프로젝트의 lidar = None.""" + from common_util.common_util_surface_confirmation import ( + analysis_terrain_params, + design_terrain, + ) + + want = None if source == "design" else ("las" if source == "lidar" else "sheet") + picked = analysis_terrain_params(params, want) + got = "sheet" if design_terrain(picked) == "sheet" else "lidar" + if source != "design" and got != source: + return source, None + npz = Path(models) / f"dtm_{picked.get('source_filter') or ''}.npz" + smooth = npz.with_name(f"{npz.stem}_smooth.npz") + npz = smooth if picked.get("smooth") and smooth.is_file() else npz + return got, npz if npz.is_file() else None + + +def grid_box(npz: Path) -> Box: + with np.load(npz) as dem: + x, y = np.asarray(dem["x"], float), np.asarray(dem["y"], float) + half = abs(float(x[1] - x[0])) / 2 if len(x) > 1 else 0.5 + return (x[0] - half, y[0] - half, x[-1] + half, y[-1] + half) + + +def road_box(root: Path, dem: Path | None) -> Box | None: + """B04 배경 지도 상자(작업 좌표계 m) · 없으면 격자 상자.""" + for name in ROAD_META: + try: + meta = json.loads((Path(root) / OLD_DIR / name).read_text(encoding="utf-8")) + return (meta["x_min"], meta["y_min"], meta["x_max"], meta["y_max"]) + except (OSError, ValueError, KeyError, TypeError): + continue + return grid_box(dem) if dem is not None else None + + +async def road_frame(row: dict[str, Any], project_id: Any, choice: str | None) -> Frame: + """임도 등 — 범위 · 좌표계 · 설계 지형 params 로 원천 격자.""" + from pyproj import CRS + + from common_util.common_util_crs import identify_epsg, resolve_project_crs + from common_util.common_util_surface_confirmation import get_surface_confirmation_params + from config.config_db import get_db_pool + + source = check_source(choice) + root = Path(resolve_stored_project_path(row["storage_path"])) + async with get_db_pool().acquire() as connection: + params = await get_surface_confirmation_params(connection, str(project_id)) + got, dem = road_dem(root / ROAD_MODELS, params, source) + box = road_box(root, dem) + if box is None: + raise HTTPException(status_code=404, detail="지형 전처리 뒤 보입니다.") + text = resolve_project_crs(root) + epsg = identify_epsg(CRS.from_user_input(text), text) + if epsg is None: + raise HTTPException(status_code=404, detail="프로젝트 좌표계를 모릅니다.") + return Frame(root, epsg, tuple(float(v) for v in box), layer_store(root, False), got, dem) + + +def need_dem(frame: Frame) -> Path: + if frame.dem is None: + what = "라이다" if frame.source == "lidar" else "수치지형도" + raise HTTPException(status_code=404, detail=f"{what} 지형이 없습니다.") + return frame.dem + + +async def layer_frame(project_id: Any, source: str | None, plan_ends: Callable) -> Frame: + """겹 틀 — 예정노선 = 받은 범위(받기 전 404) · 그 밖 = `road_frame`.""" + row = await store.project_row(project_id) + if row["project_kind"] != "PLAN": + return await road_frame(row, project_id, source) + root, epsg, _, _ = await plan_ends(project_id) + box = terrain.received_box(root, epsg) + if box is None: + raise HTTPException(status_code=404, detail="지형 받기 뒤 보입니다.") + return plan_frame(root, epsg, box, source) + + +def zero_order_calls(frame: Frame, min_m2: float, max_m2: float) -> tuple[Callable, tuple, tuple]: + """(셈, 가벼운 인자 = 저장본만, 무거운 인자) — 수치지형도 = 창고 도엽 선(범위 = 원천 격자 + 상자) · 라이다 = 격자 통째.""" + npz = need_dem(frame) + if frame.source == "sheet": + args = (frame.root, npz.parent, frame.epsg, min_m2, max_m2) + return zero_order.zero_order_layer, (*args, False, npz), (*args, True, npz) + args = (frame.root, npz, frame.epsg, min_m2, max_m2) + return zero_order.dem_zero_order_layer, (*args, False), args diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_ZeroOrder.py b/D03_PlanAnalysis/D03_PlanAnalysis_ZeroOrder.py index 32324686f..885b91935 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_ZeroOrder.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_ZeroOrder.py @@ -450,10 +450,12 @@ def zero_order_layer( min_m2: float = ZERO_ORDER_MIN_M2, max_m2: float = ZERO_ORDER_MAX_M2, compute: bool = True, + npz: Path | None = None, ) -> dict[str, Any] | None: """0차곡 겹 — 받은 범위의 창고 도엽 선(기본 임계) 또는 누적(다른 임계 · 지문 같으면 저장본) · - 경위도 선 + 가닥 · 길이 합 m. `compute=False` = 셈이 필요하면 None(일꾼으로 넘김).""" - npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz" + 경위도 선 + 가닥 · 길이 합 m. `compute=False` = 셈이 필요하면 None(일꾼으로 넘김) · + `npz` = 범위를 줄 수치지형도 격자(624-1 · 임도 프로젝트) · 없으면 D `models_plan`.""" + npz = npz or models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz" if file_stamp(npz) is None: raise FileNotFoundError("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.") box = _project_box(npz) @@ -497,6 +499,58 @@ def zero_order_layer( return value +DEM_ZERO_ORDER_KIND = "plan_zero_order_dem" + + +def dem_zero_order_layer( + project_root: Path, + npz: Path, + epsg: int, + min_m2: float = ZERO_ORDER_MIN_M2, + max_m2: float = ZERO_ORDER_MAX_M2, + compute: bool = True, +) -> dict[str, Any] | None: + """0차곡 겹 — 라이다 지형 격자 통째(624-1 · 창고 없음) · 2 m 로 솎아 흐름누적 · 선 · + 파생 저장본(열쇠 = 격자 파일 이름 · 지문 · 임계). `compute=False` = 저장본 없으면 None.""" + import shapely + + stamp = file_stamp(npz) + if stamp is None: + raise FileNotFoundError("지형 격자가 없습니다.") + key = derived_key( + DEM_ZERO_ORDER_KIND, + ZERO_ORDER_VERSION, + npz.name, + stamp, + int(epsg), + float(min_m2), + float(max_m2), + ZERO_ORDER_CELL_M, + ZERO_ORDER_MIN_LEN_M, + SIMPLIFY_M, + ) + saved = read_derived(project_root, DEM_ZERO_ORDER_KIND, key) + if saved is not None or not compute: + return saved["value"] if saved is not None else None + with np.load(npz) as dem: + x, y = np.asarray(dem["x"], float), np.asarray(dem["y"], float) + cell = float(dem["resolution"][0]) if "resolution" in dem.files else float(x[1] - x[0]) + step = max(1, round(ZERO_ORDER_CELL_M / cell)) + z = np.asarray(dem["z"], float)[::step, ::step] + valid = np.asarray(dem["valid_mask"], bool)[::step, ::step] + x, y, cell = x[::step], y[::step], cell * step + lines = zero_order_lines(z, valid, x, y, cell, min_m2, max_m2) + shapes: list[Any] = [] + if lines: + index = np.repeat(np.arange(len(lines)), [len(line) for line in lines]) + geoms = shapely.linestrings(np.concatenate(lines), indices=index) + shapes = list(shapely.simplify(geoms, SIMPLIFY_M)) + value = _layer_value(shapes, epsg, min_m2, max_m2) + value["cell_m"] = cell + write_derived(project_root, DEM_ZERO_ORDER_KIND, key, value) + return value + + def _clipped_lines(paths: list[Path], box: tuple[float, ...]) -> list[Any]: """도엽 선 이어 붙여 받은 범위로 자름 — 잘려 짧아진 토막(`ZERO_ORDER_MIN_LEN_M` 미만) 거름.""" import shapely diff --git a/resources/tester/test_624_1_layers_any_project.py b/resources/tester/test_624_1_layers_any_project.py new file mode 100644 index 000000000..a025515e2 --- /dev/null +++ b/resources/tester/test_624_1_layers_any_project.py @@ -0,0 +1,196 @@ +"""D03 지도 겹 길 — 임도 프로젝트도 받음 · 지형 원천 인자 · 받은 겹 자리(요구 530 · PLAN 624-1). + +- 예정노선(D) · 임도(B) 둘 다 급경사 · 음영 · 지적 받음 · 원천 머리(`source`) +- 원천별 파생 저장본 열쇠 다름 · 없는 원천 404 · 틀린 원천 400 +- 받은 겹 = `D03_PlanAnalysis/layers/` — B04 `processed/` 파일 안 덮음 · 새 파일 0 +- D 옛 자리 파일은 한 번 복사(다시 안 받음) · D 급경사 값 = 옛 길 값 +- 라이다 0차곡 = 격자 통째 · 저장본 + +./venv/Scripts/python.exe -m pytest resources/tester/test_624_1_layers_any_project.py -q +""" + +import json +from concurrent.futures import ThreadPoolExecutor +from contextlib import asynccontextmanager + +import numpy as np +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers +from D03_PlanAnalysis import D03_PlanAnalysis_Router as router_mod +from D03_PlanAnalysis import D03_PlanAnalysis_TerrainSource as source +from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero_order + +PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2" +EPSG = 5186 +X0, Y0 = 200000.0, 450000.0 +BOX = (X0, Y0, X0 + 400.0, Y0 + 400.0) +B04_CADASTRAL = b'{"type": "FeatureCollection", "features": [], "b04": true}' + + +def _dem(path, tilt: float, n: int = 60, cell: float = 2.0) -> None: + x = X0 + np.arange(n) * cell + y = Y0 + np.arange(n) * cell + z = np.add.outer(np.zeros(n), np.arange(n) * cell * tilt) + path.parent.mkdir(parents=True, exist_ok=True) + np.savez(path, x=x, y=y, z=z, valid_mask=np.ones(z.shape, bool), resolution=[cell]) + + +class _Pool: + @asynccontextmanager + async def acquire(self): + yield None + + +class FakeWfs: + def __init__(self) -> None: + self.calls = 0 + + def __call__(self, box, epsg, typename): + self.calls += 1 + lon, lat = 127.0, 37.0 + ring = [[lon, lat], [lon + 1e-4, lat], [lon + 1e-4, lat + 1e-4], [lon, lat]] + geom = {"type": "Polygon", "coordinates": [ring]} + return [{"type": "Feature", "geometry": geom, "properties": {"pnu": str(box)}}] + + +@pytest.fixture +def world(tmp_path, monkeypatch): + """D 프로젝트(수치지형도) · B 프로젝트(라이다 csf 확정 + 수치지형도 서피스 · B04 지적도).""" + import common_util.common_util_crs as crs + import common_util.common_util_surface_confirmation as confirm + import config.config_db as config_db + + d_root, b_root = tmp_path / "D", tmp_path / "B" + _dem(d_root / "B04_PreProcess" / "models_plan" / "dtm_sheet_laplace.npz", 0.3) + b_models = b_root / "B04_PreProcess" / "models" + _dem(b_models / "dtm_csf.npz", 0.6) + _dem(b_models / "dtm_csf_smooth.npz", 0.5) + _dem(b_models / "dtm_sheet_laplace_smooth.npz", 0.2) + processed = b_root / "B04_PreProcess" / "processed" + processed.mkdir(parents=True) + (processed / layers.CADASTRAL_FILE).write_bytes(B04_CADASTRAL) + meta = {"x_min": BOX[0], "y_min": BOX[1], "x_max": BOX[2], "y_max": BOX[3]} + (processed / "vworld_white_meta.json").write_text(json.dumps(meta), "utf-8") + kinds = {"D": "PLAN", "B": "ROAD"} + state = {"pid": "D"} + params = {"source_filter": "csf", "method": "dtm", "smooth": True} + + async def project_row(project_id): + return {"project_kind": kinds[state["pid"]], "storage_path": state["pid"]} + + async def plan_ends(project_id, center=None, reset=False): + return d_root, EPSG, [], None + + async def surface_params(connection, project_id): + return dict(params) + + monkeypatch.setattr(router_mod.store, "project_row", project_row) + monkeypatch.setattr(router_mod, "_plan_ends", plan_ends) + monkeypatch.setattr(router_mod.terrain, "received_box", lambda root, epsg: BOX) + monkeypatch.setattr(source, "resolve_stored_project_path", lambda p: str(tmp_path / p)) + monkeypatch.setattr(confirm, "get_surface_confirmation_params", surface_params) + monkeypatch.setattr(config_db, "get_db_pool", lambda: _Pool()) + monkeypatch.setattr(crs, "resolve_project_crs", lambda root: f"EPSG:{EPSG}") + wfs = FakeWfs() + monkeypatch.setattr(layers, "_wfs", wfs) + monkeypatch.setattr(zero_order, "heavy_pool", lambda: ThreadPoolExecutor(1)) + app = FastAPI() + app.include_router(router_mod.router) + client = TestClient(app) + + def get(pid: str, path: str, **query): + state["pid"] = pid + return client.get(f"/api/projects/{PID}/plan/layers/{path}", params=query) + + return {"get": get, "D": d_root, "B": b_root, "wfs": wfs, "params": params} + + +def _raster(res) -> dict: + assert res.status_code == 200, res.text + return json.loads(res.headers["X-Plan-Raster"]) + + +def test_both_kinds_get_slope_with_source(world) -> None: + get = world["get"] + assert _raster(get("D", "raster", kind="slope"))["source"] == "sheet" + assert _raster(get("B", "raster", kind="slope"))["source"] == "lidar" # 설계 지형 = 라이다 + assert _raster(get("B", "raster", kind="slope", terrain="sheet"))["source"] == "sheet" + shade = get("B", "raster", kind="hillshade", terrain="lidar").json() + assert shade["source"] == "lidar" and len(shade["box"]) == 4 + assert get("D", "raster", kind="slope", terrain="lidar").status_code == 404 # 624-4 전 + assert get("B", "raster", kind="slope", terrain="las").status_code == 400 + + +def test_source_keys_differ(world) -> None: + get = world["get"] + for terrain in ("lidar", "sheet", "design"): + get("B", "raster", kind="slope", terrain=terrain) + saved = sorted((world["B"] / "_derived" / layers.SLOPE_KIND).glob("*.json")) + assert len(saved) == 2 # design = lidar 같은 격자 → 같은 열쇠 + lidar = _raster(get("B", "raster", kind="slope", terrain="lidar")) + sheet = _raster(get("B", "raster", kind="slope", terrain="sheet")) + assert lidar["counts"] != sheet["counts"] + + +def test_road_lidar_missing_when_sheet_only(world) -> None: + world["params"].update(source_filter="sheet_laplace") # 라이다 없는 임도 + assert get_status(world, "lidar") == 404 + assert get_status(world, "design") == 200 + + +def get_status(world, terrain: str) -> int: + return world["get"]("B", "raster", kind="slope", terrain=terrain).status_code + + +def test_road_layers_never_touch_b04_files(world) -> None: + processed = world["B"] / "B04_PreProcess" / "processed" + before = {p.name: p.read_bytes() for p in processed.iterdir()} + res = world["get"]("B", "cadastral") + assert res.status_code == 200 and res.json()["features"] + after = {p.name: p.read_bytes() for p in processed.iterdir()} + assert after == before # 같은 이름 덮음 0 · 새 파일 0 + mine = world["B"] / source.LAYER_DIR / layers.CADASTRAL_FILE + assert mine.is_file() and mine.read_bytes() != B04_CADASTRAL + + +def test_plan_old_files_copied_once(world) -> None: + """D 옛 자리(B04 processed) 지적도 + 열쇠 = 새 자리로 복사 · 다시 받지 않음 · 값 같음.""" + old = world["D"] / "B04_PreProcess" / "processed" + old.mkdir(parents=True) + body = {"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}}]} + (old / layers.CADASTRAL_FILE).write_text(json.dumps(body), "utf-8") + key = {"box": list(BOX), "epsg": EPSG, "count": 1, "failed": 0, "failed_tiles": []} + (old / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8") + res = world["get"]("D", "cadastral") + assert res.status_code == 200 and res.json() == body and world["wfs"].calls == 0 + assert (world["D"] / source.LAYER_DIR / layers.CADASTRAL_KEY).is_file() + + +def test_plan_slope_same_as_before(world, tmp_path) -> None: + """D 급경사 = 옛 길(`slope_layer(root, models_plan, epsg)`) 값 그대로.""" + res = world["get"]("D", "raster", kind="slope") + meta = _raster(res) + old = layers.slope_layer(world["D"], world["D"] / "B04_PreProcess" / "models_plan", EPSG) + png, old_meta = layers.raster_bytes(old) + assert res.content == png and meta == {"kind": "slope", "source": "sheet", **old_meta} + + +def test_lidar_zero_order_whole_grid(world) -> None: + """라이다 0차곡 = 격자 통째(1 m → 2 m 솎음) · 저장본 · 수치지형도 = 창고 길.""" + n, cell = 200, 1.0 + x, y = X0 + np.arange(n) * cell, Y0 + np.arange(n) * cell + valley = np.abs(x - x.mean())[None, :] * 0.3 + (y - Y0)[:, None] * 0.2 + npz = world["B"] / "B04_PreProcess" / "models" / "dtm_csf_smooth.npz" + np.savez(npz, x=x, y=y, z=valley, valid_mask=np.ones(valley.shape, bool), resolution=[cell]) + res = world["get"]("B", "zero-order", terrain="lidar") + got = res.json() + assert res.status_code == 200 and got["source"] == "lidar" and got["cell_m"] == 2.0 + assert got["count"] >= 1 and got["length_m"] > 0 + again = zero_order.dem_zero_order_layer(world["B"], npz, EPSG, compute=False) + assert again is not None and again["count"] == got["count"] + frame = source.plan_frame(world["D"], EPSG, BOX, None) + work, light, _ = source.zero_order_calls(frame, 1.0, 2.0) + assert work is zero_order.zero_order_layer and light[-1] == frame.dem From 6c25b864ac13c1070464378b7fc7e9b24665c93d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:28:50 +0900 Subject: [PATCH 09/18] =?UTF-8?q?feat(=EA=B3=B5=EC=9A=A9=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84):=20=EB=8F=84=EA=B5=AC=EC=A4=84=20=ED=91=9C=20=C2=B7?= =?UTF-8?q?=20=EC=A7=80=ED=98=95=20=EC=B9=A9=20=E2=80=94=20D03=20=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=20=EB=8F=84=EA=B5=AC=EC=A4=84=EC=9D=84=20=ED=91=9C?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EB=81=9D=EC=97=90=20=EC=A7=80=ED=98=95=20?= =?UTF-8?q?=EC=B9=A9(=EC=88=98=EC=B9=98=EC=A7=80=ED=98=95=EB=8F=84=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84=20=C2=B7=20=EB=9D=BC=EC=9D=B4=EB=8B=A4=20?= =?UTF-8?q?=EB=A7=89=ED=9E=98)=20(PLAN=20624-2=20=C2=B7=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20530=20=C2=B7=20=EA=B2=B0=EC=A0=95=20A=E2=91=A3=20?= =?UTF-8?q?=C2=B7=20A=E2=91=A4=20=C2=B7=20A=E2=91=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - map_toolbar = 단추 열쇠 · 할 일 · 보일 페이지(d03 · b05) 표 · mapToolGroups(page, parts) — D03 단추 차례 · 묶음 그대로 - map_terrain_chip = 분할 고르기(수치지형도 · 라이다) · 이름 D03 「분석 지형」 · B05 「설계 지형」 · 없는 원천 막힘 — 바꾸는 일은 onChange(624-1 · 624-4 · 626-1) - 시험 test_624_2_map_parts 새로 · 553-4 돌리기 자리 확인 = 표 Co-Authored-By: Claude Opus 5.5 --- A00_Common/map/map_terrain_chip.ts | 62 +++++++++++++++ A00_Common/map/map_toolbar.ts | 66 ++++++++++++++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts | 22 ++++-- resources/tester/test_553_4_de_parts.py | 13 +++- resources/tester/test_624_2_map_parts.py | 84 +++++++++++++++++++++ 5 files changed, 237 insertions(+), 10 deletions(-) create mode 100644 A00_Common/map/map_terrain_chip.ts create mode 100644 A00_Common/map/map_toolbar.ts create mode 100644 resources/tester/test_624_2_map_parts.py diff --git a/A00_Common/map/map_terrain_chip.ts b/A00_Common/map/map_terrain_chip.ts new file mode 100644 index 000000000..f58523450 --- /dev/null +++ b/A00_Common/map/map_terrain_chip.ts @@ -0,0 +1,62 @@ +/* ============================================================================= + * map_terrain_chip.ts + * 공용 지도 지형 칩(PLAN 624-2 · 요구 530 · 결정 A④ · A⑤ · D2) — 도구줄 끝 분할 고르기 하나: 수치지형도 · 라이다. + * 이름 = 예정노선 「분석 지형」 · B05 「설계 지형」(바꾸면 뒤 단계 모두 다시 셈 · 확인창은 B05 몫). + * 없는 원천 = 막힘 + 까닭 글(D03 라이다 = D 프로젝트에 LAS 올린 뒤 · 624-4). + * 화면 칩만 — 바꾸는 일(서버 원천 인자 624-1 · B05 설계 지형 바꿈 626-1)은 `onChange` 로 페이지가 붙임. + * 쓰는 법 = `createTerrainChip({ page: "d03", value: "sheet", have: ["sheet"] })` → `{ root, set, setHave }` · root 를 도구줄 `terrain` 칸에. + * ========================================================================== */ + +import { createSegment } from "@ui/ui_template_segment"; +import type { MapPage } from "./map_layer_list"; + +export type TerrainSource = "sheet" | "las"; + +export const TERRAIN_NAMES: Record = { sheet: "수치지형도", las: "라이다" }; + +const CAPTION: Record = { + d03: "분석 지형 — 급경사 · 음영 · 0차곡 · 긋기 높이에 쓰는 지형", + b05: "설계 지형 — 바꾸면 종단 · 횡단 · 배수 · 토공을 모두 다시 셈", +}; +const MISSING: Record = { + sheet: "수치지형도 — 이 프로젝트 범위에 도엽 지형 없음", + las: "라이다 — 올린 LAS 없음", +}; + +export interface TerrainChip { + root: HTMLElement; + /** 고름을 바꿈(onChange 는 안 부름). */ + set: (value: TerrainSource) => void; + /** 고를 수 있는 원천 — 없는 것은 막힘. */ + setHave: (have: readonly TerrainSource[]) => void; +} + +export function createTerrainChip(opts: { + page: MapPage; + value: TerrainSource; + have: readonly TerrainSource[]; + onChange?: (value: TerrainSource) => void; +}): TerrainChip { + const sources = Object.keys(TERRAIN_NAMES) as TerrainSource[]; + const segment = createSegment({ + options: sources.map((value) => ({ value, label: TERRAIN_NAMES[value] })), + value: opts.value, + size: 32, + ariaLabel: CAPTION[opts.page], + onChange: (value) => opts.onChange?.(value as TerrainSource), + }); + const root = segment.root; + root.dataset.act = "terrain"; + root.title = CAPTION[opts.page]; + const setHave = (have: readonly TerrainSource[]): void => { + for (const value of sources) { + const item = root.querySelector(`[data-value="${value}"]`)!; + item.disabled = !have.includes(value); + item.title = item.disabled + ? MISSING[value] + : `${CAPTION[opts.page]} · ${TERRAIN_NAMES[value]}`; + } + }; + setHave(opts.have); + return { root, set: (value) => segment.set(value), setHave }; +} diff --git a/A00_Common/map/map_toolbar.ts b/A00_Common/map/map_toolbar.ts new file mode 100644 index 000000000..b01ddb7cc --- /dev/null +++ b/A00_Common/map/map_toolbar.ts @@ -0,0 +1,66 @@ +/* ============================================================================= + * map_toolbar.ts + * 공용 지도 도구줄 표(PLAN 624-2 · 요구 530 「상단 아이콘 셋 같게 · 필요 없는 것 숨김」) — 예정노선(D03) · B05 평면이 같이 씀. + * 한 줄 = 열쇠(`ui_template_map_tools` 단추 이름 · 아이콘 · 마우스 글은 거기) · 할 일 · 보일 페이지. + * 묶음 = 나눔선 · 차례 = 도구줄 차례 · 단추 아닌 칸(간격 고르기 · 지형 칩)은 이름을 여기 둠. + * 누름 동작 · 페이지 문구는 페이지 몫 — 만든 요소를 `parts` 로 넘기면 그 자리에 · 안 넘긴 단추는 기본 단추(돌리기 둘 = 공용 돌림이 `data-act` 로 찾음). + * 거리 재기 = 단추 자리만 같고 동작은 페이지 몫(결정 A⑩) · 지형 칩 = `map_terrain_chip`. + * 쓰는 법 = `createMapTools({ label, hint, groups: mapToolGroups("d03", { undo: undoButton, terrain: chip.root }) })`. + * ========================================================================== */ + +import type { MapToolAct } from "@ui/ui_template_map_tools"; +import type { MapPage } from "./map_layer_list"; + +export type MapToolKey = MapToolAct | "snap-step" | "terrain"; + +export interface MapToolRow { + key: MapToolKey; + /** 단추 아닌 칸 이름(단추는 `MAP_TOOLS` 이름). */ + name?: string; + does: string; + pages: readonly MapPage[]; +} + +const BOTH: readonly MapPage[] = ["d03", "b05"]; +const B05: readonly MapPage[] = ["b05"]; + +export const MAP_TOOLBAR: readonly (readonly MapToolRow[])[] = [ + [ + { key: "undo", does: "편집 한 걸음 되돌림", pages: BOTH }, + { key: "redo", does: "되돌린 걸음 다시", pages: BOTH }, + ], + [ + { key: "view3d", does: "3D 큰 창(동작 = 페이지 몫)", pages: BOTH }, + { key: "rotate-ccw", does: "지도 반시계로 돌림(Ctrl+휠도)", pages: BOTH }, + { key: "rotate-cw", does: "지도 시계로 돌림", pages: BOTH }, + ], + [ + { key: "contour-minor", does: "라이다 1 m 등고선 켬끔(라이다일 때만 열림)", pages: B05 }, + { key: "stations", does: "측점 눈금 · 번호 켬끔", pages: BOTH }, + { key: "slopes", does: "구간 기울기 글 켬끔", pages: BOTH }, + { key: "footprint", does: "도로 끝 · 비탈 끝(절/성토선) 켬끔", pages: B05 }, + { key: "measure", does: "거리 재기(동작 = 페이지 몫 · A⑩)", pages: BOTH }, + ], + [ + { key: "station-snap", does: "곡선 가운데(MC)를 간격 배수에 맞춤", pages: B05 }, + { key: "snap-step", name: "측점 스냅 간격", does: "스냅 간격 고르기", pages: B05 }, + ], + [{ key: "terrain", name: "지형 칩", does: "지형 원천 보기 · 고르기", pages: BOTH }], +]; + +const isAct = (key: MapToolKey): key is MapToolAct => key !== "snap-step" && key !== "terrain"; + +/** 그 페이지 도구줄 묶음 — 넘긴 요소 · 기본 단추(단추 열쇠만) · 빈 묶음 뺌. */ +export function mapToolGroups( + page: MapPage, + parts: Partial> = {}, +): (MapToolAct | HTMLElement)[][] { + return MAP_TOOLBAR.map((group) => + group + .filter((row) => row.pages.includes(page)) + .flatMap((row): (MapToolAct | HTMLElement)[] => { + const made = parts[row.key]; + return made ? [made] : isAct(row.key) ? [row.key] : []; + }), + ).filter((group) => group.length); +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index de6d27bda..9e9fbc041 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -1,7 +1,8 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Map.ts - * D03 메인 평면(PLAN 530-3 · 562-3) — 위성 바탕 · 위에 겹(바탕 겹 569-2 · 그리기 · 거리재기) · 도구줄 아홉 - * (공용 `createMapTools` 568-3 — [?] · 되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 측점 · 기울기 · 거리재기). + * D03 메인 평면(PLAN 530-3 · 562-3) — 위성 바탕 · 위에 겹(바탕 겹 569-2 · 그리기 · 거리재기) · 도구줄 아홉 + 지형 칩 + * (공용 `createMapTools` 568-3 — [?] · 되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 측점 · 기울기 · 거리재기 · + * 차례 · 보임 = 공용 도구줄 표 `A00_Common/map/map_toolbar` 624-2 · 지형 칩 `map_terrain_chip`). * * 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `createMetricProjector` · `screenToMetric` · 맞춤 * `computeRouteView` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` · @@ -19,6 +20,8 @@ import { perfTime } from "@ui/ui_template_gl_map"; import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools"; import { routeColor, themeColor } from "@ui/ui_template_palette"; +import { createTerrainChip } from "../A00_Common/map/map_terrain_chip"; +import { mapToolGroups } from "../A00_Common/map/map_toolbar"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { computeMapRect, @@ -226,11 +229,16 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { "Ctrl+휠 = 돌리기", ], }, - groups: [ - [undoButton, redoButton], - [viewButton, "rotate-ccw", "rotate-cw"], - [markButton("stations", "측점"), markButton("slopes", "구간 길이 · 기울기"), measureButton], - ], + // 624-2 — 단추 차례 · 보임 = 공용 도구줄 표(페이지 「d03」) · 지형 칩 = 수치지형도(라이다 = 624-4) + groups: mapToolGroups("d03", { + undo: undoButton, + redo: redoButton, + view3d: viewButton, + stations: markButton("stations", "측점"), + slopes: markButton("slopes", "구간 길이 · 기울기"), + measure: measureButton, + terrain: createTerrainChip({ page: "d03", value: "sheet", have: ["sheet"] }).root, + }), }); const toolbar = tools.root; toolbar.classList.add("d03-plan__tools"); diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index e884b07f9..be0296afe 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -35,7 +35,9 @@ def test_d03_parts(): tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts") layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts - assert "createProgressCircle({ size: 16, inline: true })" in ts # 604-3 받기 = 위치 칸 제목 서클 + assert ( + "createProgressCircle({ size: 16, inline: true })" in ts + ) # 604-3 받기 = 위치 칸 제목 서클 site = _src( "D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts" ) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품 @@ -52,7 +54,8 @@ def test_d03_parts(): assert "createToolbar(" not in tools and "createIconButton(" not in tools for act in ("undo", "redo", "view3d", "measure", "stations", "slopes"): assert f'mapToolButton("{act}"' in tools or f'markButton("{act}"' in tools, act - assert '"rotate-ccw", "rotate-cw"' in tools + table = _src("A00_Common/map/map_toolbar.ts") # 624-2 — 차례 · 보임 = 공용 도구줄 표 + assert 'mapToolGroups("d03"' in tools and re.search(r'key: "rotate-ccw".*pages: BOTH', table) for b05_only in ("footprint", "station-snap", "contour-minor"): assert f'"{b05_only}"' not in tools, b05_only paint = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts") # 580 — 그림 나눔 @@ -60,7 +63,11 @@ def test_d03_parts(): # 569-2 · 581-1 · 581-2 — 바탕 겹 단추(공용 지도 도구줄 한 줄 · 묶음 셋) · 처음 켬 셋 · 취향 기억 defs = _src("A00_Common/map/map_layer_list.ts") # 624-2 공용 겹 목록 bar = _src("A00_Common/map/map_layer_bar.ts") - assert "createMapTools({" in bar and "createIconButton({" in bar and "createMapLayerBar({" in layers + assert ( + "createMapTools({" in bar + and "createIconButton({" in bar + and "createMapLayerBar({" in layers + ) assert "createToolbar(" not in layers and "createToolbar(" not in bar assert '"aislo:pref:d03.layers"' in defs and "writeByKey(MAP_LAYER_PAGES[page].pref" in bar keys = re.findall(r'key: "(\w+)",\s+icon: "\w+",\s+title:\s+"[^"]+",\s+on: (true|false)', defs) diff --git a/resources/tester/test_624_2_map_parts.py b/resources/tester/test_624_2_map_parts.py new file mode 100644 index 000000000..509dcf635 --- /dev/null +++ b/resources/tester/test_624_2_map_parts.py @@ -0,0 +1,84 @@ +"""PLAN 624-2 — 공용 지도 부품(A00_Common/map) 지킴: 도구줄 표 · 겹 목록 페이지 표 · 범례 · 지형 칩 · D03 은 부르기만. + +./venv/Scripts/python.exe -m pytest resources/tester/test_624_2_map_parts.py -q +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MAP = ROOT / "A00_Common" / "map" +D03 = ROOT / "D03_PlanAnalysis" + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _toolbar_rows() -> list[list[tuple[str, set[str]]]]: + """표 → 묶음마다 (열쇠, 보일 페이지).""" + src = _read(MAP / "map_toolbar.ts") + pages = {"BOTH": {"d03", "b05"}, "B05": {"b05"}} + body = src[src.index("MAP_TOOLBAR") : src.index("const isAct")] + groups = re.findall(r"\[\s*(\{.*?\})\s*,?\s*\]", body, re.S) + return [ + [(k, pages[p]) for k, p in re.findall(r'key: "([\w-]+)".*?pages: (\w+)', g, re.S)] + for g in groups + ] + + +def test_toolbar_table_d03_same_as_before_plus_terrain() -> None: + d03 = [[k for k, p in g if "d03" in p] for g in _toolbar_rows()] + d03 = [g for g in d03 if g] + # 옛 D03 도구줄 세 묶음 그대로 + 끝에 지형 칩 + assert d03 == [ + ["undo", "redo"], + ["view3d", "rotate-ccw", "rotate-cw"], + ["stations", "slopes", "measure"], + ["terrain"], + ], d03 + b05 = {k for g in _toolbar_rows() for k, p in g if "b05" in p} + assert {"contour-minor", "footprint", "station-snap", "snap-step", "terrain"} <= b05 + tools = _read(ROOT / "ui_template" / "ui_template_map_tools.ts") + for key in {k for g in _toolbar_rows() for k, _ in g} - {"snap-step", "terrain"}: + assert f'"{key}": {{' in tools or f"{key}: {{" in tools, key # 아이콘 · 이름 = MAP_TOOLS + + +def test_layer_pages() -> None: + src = _read(MAP / "map_layer_list.ts") + assert 'd03: { hide: [], pref: "aislo:pref:d03.layers" }' in src + b05 = re.search(r"b05: \{(.*?)\},", src, re.S).group(1) + assert 'hide: ["bands", "forestRoadFile"]' in b05 # A⑨ + assert 'on: ["contour", "stream"]' in b05 # A② 지금 그대로 + assert "for (const key of hide) on[key] = false;" in src + + +def test_common_parts_use_kit_and_no_page_imports() -> None: + for path in MAP.glob("*.ts"): + src = _read(path) + assert len(src.splitlines()) <= 700, path.name + for page in ("D03_PlanAnalysis", "B05_Profile", "B04_PreProcess"): + assert f"../../{page}" not in src and f"../{page}" not in src, (path.name, page) + for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'): + assert raw not in src, (path.name, raw) + chip = _read(MAP / "map_terrain_chip.ts") + assert "createSegment({" in chip and 'root.dataset.act = "terrain"' in chip + assert "createLegend(row)" in _read(MAP / "map_legend.ts") + assert "createMapTools({" in _read(MAP / "map_layer_bar.ts") + + +def test_d03_calls_common_only() -> None: + layers = _read(D03 / "D03_PlanAnalysis_UI_Layers.ts") + assert ( + 'createMapLayerBar({\n page: "d03"' in layers + and 'createMapLegend("d03-plan__legend")' in layers + ) + assert len(layers.splitlines()) < 683 # 줄이기만 + gl = _read(D03 / "D03_PlanAnalysis_UI_MapGl.ts") + assert "mapLayerSpecs(light, pick)" in gl and "function haloLine" not in gl + defs = _read(D03 / "D03_PlanAnalysis_UI_LayerDefs.ts") + assert "export const GROUPS" not in defs and "readPref" not in defs + ui_map = _read(D03 / "D03_PlanAnalysis_UI_Map.ts") + assert 'mapToolGroups("d03", {' in ui_map + assert 'createTerrainChip({ page: "d03", value: "sheet", have: ["sheet"] })' in ui_map + assert not (D03 / "D03_PlanAnalysis_UI_ForestLayers.ts").exists() From 7fbc4b51a9e90ea5466b4d168d61b954dd54e832 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:30:05 +0900 Subject: [PATCH 10/18] =?UTF-8?q?fix(=EA=B3=B5=EC=9A=A9=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84):=20=EC=A7=80=ED=98=95=20=EC=B9=A9=20=EA=B0=92=20las?= =?UTF-8?q?=20=E2=86=92=20lidar=20=E2=80=94=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EA=B2=B9=20=EA=B8=B8=20terrain=3D=20=EA=B0=92(624-1)=EA=B3=BC?= =?UTF-8?q?=20=EA=B0=99=EA=B2=8C=20(PLAN=20624-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/map/map_terrain_chip.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/A00_Common/map/map_terrain_chip.ts b/A00_Common/map/map_terrain_chip.ts index f58523450..b647f3c57 100644 --- a/A00_Common/map/map_terrain_chip.ts +++ b/A00_Common/map/map_terrain_chip.ts @@ -10,9 +10,13 @@ import { createSegment } from "@ui/ui_template_segment"; import type { MapPage } from "./map_layer_list"; -export type TerrainSource = "sheet" | "las"; +/** 서버 겹 길 `terrain=` 값과 같음(624-1 · 빈 값 = 설계 지형). */ +export type TerrainSource = "sheet" | "lidar"; -export const TERRAIN_NAMES: Record = { sheet: "수치지형도", las: "라이다" }; +export const TERRAIN_NAMES: Record = { + sheet: "수치지형도", + lidar: "라이다", +}; const CAPTION: Record = { d03: "분석 지형 — 급경사 · 음영 · 0차곡 · 긋기 높이에 쓰는 지형", @@ -20,7 +24,7 @@ const CAPTION: Record = { }; const MISSING: Record = { sheet: "수치지형도 — 이 프로젝트 범위에 도엽 지형 없음", - las: "라이다 — 올린 LAS 없음", + lidar: "라이다 — 올린 LAS 없음", }; export interface TerrainChip { From fea37b4ce8f1b937c5bd8ad63e7d19d726c0f681 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:30:32 +0900 Subject: [PATCH 11/18] =?UTF-8?q?feat(B05):=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EB=8B=A4=EC=8B=9C=20=EC=B6=94=EC=A0=81=20POST=20/r?= =?UTF-8?q?oute/follow=20=C2=B7=20=EA=B3=A0=EC=B9=9C=20=EC=98=88=EC=A0=95?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20expected=5Froute=5Fedit.csv=20(PLAN=20626-?= =?UTF-8?q?2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20530=20D3=20=C2=B7=20D4=20?= =?UTF-8?q?=C2=B7=20D8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 `B05_Profile_Router_RouteFollow.py` — 고친 예정노선(TM · 경위도) → 평면선(노드 · 곡선 · 편집표 · GET /route/plan 칸 이름) · 저장 0 - 같은 함수 — `Route_Polyline_Write.followed_table` 을 체인 초기 폴리라인(`_ensure_planned_initial`)과 함께 씀 · 셈 몫 `polyline_table` 을 쓰기에서 나눔 - [저장] — 노선 초안 `expected`(없음 그대로 · [] 지움 · 점 묶음 씀) → `B05_Profile/route/expected_route_edit.csv` · GET /route/plan 덧칸 `expected_edited` - [초기화] — 고친 예정노선 지움 · `expected_route.csv` · 초기값 · 추천 저장본 · `/route/replan` · `as_initial` 그대로 - 시험 — 새 `test_626_2_route_follow`(follow = 체인 초기선 · 저장 0 · 경위도 · edit csv 씀 · 지움 · initial_snapshot 지문 · 초기화 지움) Co-Authored-By: Claude Opus 5.5 --- .../B05_Profile_Route_Polyline_Write.py | 50 +++- B05_Profile/B05_Profile_Route_Save.py | 2 + B05_Profile/B05_Profile_Router_Lifecycle.py | 3 + B05_Profile/B05_Profile_Router_Replan.py | 35 ++- B05_Profile/B05_Profile_Router_RouteFollow.py | 81 +++++++ common_util/common_util_route_geometry.py | 5 + resources/tester/test_626_2_route_follow.py | 221 ++++++++++++++++++ 7 files changed, 387 insertions(+), 10 deletions(-) create mode 100644 B05_Profile/B05_Profile_Router_RouteFollow.py create mode 100644 resources/tester/test_626_2_route_follow.py diff --git a/B05_Profile/B05_Profile_Route_Polyline_Write.py b/B05_Profile/B05_Profile_Route_Polyline_Write.py index bf4cfbab1..2cfc901ce 100644 --- a/B05_Profile/B05_Profile_Route_Polyline_Write.py +++ b/B05_Profile/B05_Profile_Route_Polyline_Write.py @@ -146,6 +146,33 @@ def _write_planned_polyline( 섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다. """ + result, table = polyline_table( + points, + radius_m, + simplify=simplify, + curve_flags=curve_flags, + radii=radii, + station_snap=station_snap, + station_snap_step=station_snap_step, + locks=locks, + arcs=arcs, + ) + return _write_table(path, result, table) + + +def polyline_table( + points: list[tuple[float, float]], + radius_m: float, + *, + simplify: bool = True, + curve_flags: list[bool] | None = None, + radii: list[float | None] | None = None, + station_snap: bool | None = None, + station_snap_step: float | None = None, + locks: list[str | None] | None = None, + arcs: list[float | None] | None = None, +) -> tuple[PlannedPolyline, dict[str, Any]]: + """`_write_planned_polyline` 의 셈 몫 — (폴리라인, 곡선표) · 파일 안 씀(626-2).""" edited = curve_flags is not None or radii is not None made = ( node_polyline(points, radius_m, curve_flags, radii, locks, arcs) @@ -163,8 +190,6 @@ def _write_planned_polyline( radii=radii, ) edits = node_edits(points, curve_flags, radii, locks, arcs) if edited else None - write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices]) - write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes]) table: dict[str, Any] = { "min_radius_m": round(radius_m, 3), "curves": [c.as_dict() for c in result.curves], @@ -175,6 +200,27 @@ def _write_planned_polyline( table["station_snap_step"] = float(station_snap_step) if edits is not None: table["edits"] = edits + return result, table + + +def followed_table( + points: list[tuple[float, float]], radius_m: float +) -> tuple[PlannedPolyline, dict[str, Any]]: + """예정노선 추적(383-1) — 점 묶음 → 곡선 MC 를 일반측점에 맞춘 폴리라인 + 곡선표(스냅 켬). + + 체인 초기 폴리라인(`_ensure_planned_initial`) · `/route/follow`(626-2)가 같이 씀 · 못 맞추면 + 지금 선. + """ + from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial + + fit = snapped_initial(points, radius_m) + shape = {"simplify": False, "curve_flags": fit[1], "radii": fit[2]} if fit else {} + return polyline_table(fit[0] if fit else points, radius_m, station_snap=True, **shape) + + +def _write_table(path: Path, result: PlannedPolyline, table: dict[str, Any]) -> dict: + write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices]) + write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes]) _curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8") return { "nodes": len(result.nodes), diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index 3a1941e61..dcc942999 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -75,6 +75,8 @@ class RouteDraftInput(BaseModel): stationSnap: bool = False # noqa: N815 stationSnapStep: float | None = None # noqa: N815 — 390-5 terrain: Literal["las", "sheet"] | None = None + #: 고친 예정노선(626-2) — 없음 = 그대로 · [] = 지움 · 점 묶음 = `expected_route_edit.csv` + expected: list[tuple[float, float]] | None = None class RouteSaveRequest(RouteReplanRequest): diff --git a/B05_Profile/B05_Profile_Router_Lifecycle.py b/B05_Profile/B05_Profile_Router_Lifecycle.py index 7adb46a34..86b1d917d 100644 --- a/B05_Profile/B05_Profile_Router_Lifecycle.py +++ b/B05_Profile/B05_Profile_Router_Lifecycle.py @@ -272,6 +272,7 @@ async def _reset_route_design(project_id: UUID, session: dict[str, Any] | None) restore_snapshot_files, wipe_edited_masters, ) + from common_util.common_util_route_geometry import expected_route_edit_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params pool = get_db_pool() @@ -352,6 +353,8 @@ async def _reset_route_design(project_id: UUID, session: dict[str, Any] | None) "B05 초기화: 편집 정본 제거 %s (project_id=%s)", removed, project_id ) await run_auto_design_chain(project_id, surface_model_id=surface_model_id) + if project_root: # 고친 예정노선(626-2)은 초기값이 아님 — 스냅샷에 섞여 있어도 지움 + expected_route_edit_path(project_root).unlink(missing_ok=True) async with pool.acquire() as connection: latest = await get_latest_route(connection, project_id) diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 5e4acb4e7..a8df095cd 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -20,6 +20,7 @@ GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계) POST /api/projects/{id}/route/replan → 노선 초안 저장(노선 저장 체인) POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(추천 저장본만 씀) + POST /api/projects/{id}/route/follow → 고친 예정노선 다시 추적(저장 0 · 626-2 · 나눈 파일) """ import asyncio @@ -35,13 +36,14 @@ from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice -from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial from B05_Profile.B05_Profile_Engine_Sections import load_station_snap, load_station_snap_step from B05_Profile.B05_Profile_Route_Polyline_Write import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀 _curves_path, _nodes_path, _read_curves, _write_planned_polyline, + _write_table, + followed_table, ) from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀 @@ -54,6 +56,7 @@ from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 from common_util.common_util_initial_snapshot import design_route_csv_path from common_util.common_util_route_geometry import ( expected_route_csv_path, + expected_route_edit_path, planned_route_initial_path, planned_route_working_path, read_planned_route_csv, @@ -195,6 +198,15 @@ async def _road_settings(project_id: UUID) -> tuple[str, int | None, str]: return grade_class, design_speed, terrain +def write_expected_edit(project_root: Path, points: list[tuple[float, float]]) -> None: + """고친 예정노선 → `expected_route_edit.csv`(626-2 · 점 2 미만 = 지움).""" + target = expected_route_edit_path(project_root) + if len(points) < 2: + target.unlink(missing_ok=True) + return + write_route_csv(target, [{"x": x, "y": y} for x, y in points]) + + def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None: """계획노선 **초기 폴리라인**이 없거나 낡았으면 예상노선을 폴리라인화해 세운다. @@ -211,11 +223,7 @@ def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None: if len(points) < 2: return None # 383-1 — 곡선 MC = 일반측점(기본 제약) + [측점 스냅] 켬 · 못 맞추면 지금 선 + 켬 - fit = snapped_initial(points, radius_m) - shape = {"simplify": False, "curve_flags": fit[1], "radii": fit[2]} if fit else {} - summary = _write_planned_polyline( - target, fit[0] if fit else points, radius_m, station_snap=True, **shape - ) + summary = _write_table(target, *followed_table(points, radius_m)) logger.info( "계획노선 초기 폴리라인 생성: %s (노드 %d · 곡선 %d · 위반 %d · 정점 %d)", target, @@ -426,6 +434,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: "status": "success", "project_id": str(project_id), "expected": expected, + # 고친 예정노선(626-2 · [저장] 때) — 없으면 빈 목록 · 원본 `expected` 는 그대로 + "expected_edited": await asyncio.to_thread( + _vertices_of, expected_route_edit_path(project_root) + ), "planned": planned, "nodes": [node.as_dict() for node in outline.nodes], # 곡선 성분 — 화면이 **곡선 시작·끝점**을 손잡이로 그리고 반지름 칸을 띄우는 재료다 @@ -580,6 +592,10 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any return JSONResponse( status_code=409, content={"status": "error", "message": result["error"]} ) + # 고친 예정노선(626-2) — 초안에 오면만 · [] = 지움 · `expected_route.csv` 는 안 씀 + expected = getattr(draft, "expected", None) + if expected is not None: + await asyncio.to_thread(write_expected_edit, project_root, expected) choice = request.initial current = ( await asyncio.to_thread( @@ -611,5 +627,8 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any } -# `POST /route/initial` 은 나눈 파일에서 같은 `router` 에 붙는다 — 끝에서 불러 등록. -from B05_Profile import B05_Profile_Router_RouteInitial # noqa: E402, F401 +# `POST /route/initial` · `/route/follow` 는 나눈 파일에서 같은 `router` 에 붙음 — 끝에서 등록. +from B05_Profile import ( # noqa: E402 + B05_Profile_Router_RouteFollow, # noqa: F401 + B05_Profile_Router_RouteInitial, # noqa: F401 +) diff --git a/B05_Profile/B05_Profile_Router_RouteFollow.py b/B05_Profile/B05_Profile_Router_RouteFollow.py new file mode 100644 index 000000000..d9ebbfb2a --- /dev/null +++ b/B05_Profile/B05_Profile_Router_RouteFollow.py @@ -0,0 +1,81 @@ +"""예정노선 다시 추적(626-2 · 요구 530) — 고친 예정노선 → 평면선(노드 · 곡선 · 편집표) · 저장 0. + +계산 자리 ③ 서버 단독 — 체인 초기 폴리라인(`_ensure_planned_initial`)과 같은 함수 +`followed_table`(지형 안 씀 · 순수 기하). 화면은 받은 노드를 편집기 한 걸음으로 싣고 페이지 [저장] +길로 보냄(고친 예정노선 = 초안 `expected` → `expected_route_edit.csv`). `/route/replan` · +`as_initial` · `expected_route.csv` · 초기값 · 추천 저장본은 안 건드림. + + POST /api/projects/{id}/route/follow + 본문 {points: [[x, y], …], crs: "tm" | "lonlat"} ("lonlat" = [경도, 위도]) + → {status, planned, nodes, curves, node_edits, station_snap, min_radius_m, + curve_count, violation_count} (GET /route/plan 칸 이름 · 사업지 TM) + 400 점 2 미만 · 404 프로젝트 없음 · 409 좌표계 모름(lonlat) +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Literal +from uuid import UUID + +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg +from B05_Profile.B05_Profile_Route_Polyline_Write import followed_table +from B05_Profile.B05_Profile_Router_Replan import ( + _PROJECT_PATH_MISSING, + _plan_criteria, + _project_paths, + router, +) +from common_util.common_util_crs import lonlat_to_tm +from config.config_db import get_db_pool + + +class RouteFollowRequest(BaseModel): + points: list[tuple[float, float]] + crs: Literal["tm", "lonlat"] = "tm" + + +def follow_route_line(points: list[tuple[float, float]], radius_m: float) -> dict[str, Any]: + """점 묶음(TM) → 예정노선 추적 평면선 — 파일 꼴(csv 4자리)과 같은 값.""" + result, table = followed_table(points, radius_m) + return { + "planned": [[round(x, 4), round(y, 4)] for x, y in result.vertices], + "nodes": [node.as_dict() for node in result.nodes], + "curves": table["curves"], + "node_edits": table.get("edits"), + "station_snap": table["station_snap"], + "min_radius_m": round(radius_m, 2), + "curve_count": result.curve_count, + "violation_count": result.violation_count, + } + + +@router.post("/{project_id}/route/follow", response_model=None) +async def follow_route(project_id: UUID, body: RouteFollowRequest) -> dict[str, Any] | JSONResponse: + """고친 예정노선을 다시 추적한 평면선 — 아무것도 안 씀(626-2).""" + if len(body.points) < 2: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "예정노선은 점이 2개 이상이어야 합니다."}, + ) + if await _project_paths(project_id) is None: + return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) + points = [(float(x), float(y)) for x, y in body.points] + if body.crs == "lonlat": + async with get_db_pool().acquire() as connection: + epsg = await get_surface_crs_epsg(connection, project_id, 0) + if epsg is None: + return JSONResponse( + status_code=409, + content={ + "status": "error", + "message": "사업지 좌표계를 몰라 경위도를 바꿀 수 없습니다.", + }, + ) + points = [lonlat_to_tm(lon, lat, epsg) for lon, lat in points] + radius_m = (await _plan_criteria(project_id))[0] + line = await asyncio.to_thread(follow_route_line, points, radius_m) + return {"status": "success", "project_id": str(project_id), **line} diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py index fe3d3260c..d8e666b95 100644 --- a/common_util/common_util_route_geometry.py +++ b/common_util/common_util_route_geometry.py @@ -224,6 +224,11 @@ def expected_route_csv_path(project_root: Path) -> Path: return Path(project_root) / "B05_Profile" / "route" / "expected_route.csv" +def expected_route_edit_path(project_root: Path) -> Path: + """고친 예정노선 CSV 자리(626-2) — 페이지 [저장] 때만 씀 · [초기화]면 지움 · 원본 그대로.""" + return Path(project_root) / "B05_Profile" / "route" / "expected_route_edit.csv" + + def write_route_csv(path: Path, points: list[dict[str, float]]) -> None: """노선 정점을 CSV로 적는다. 열 이름은 `read_planned_route_csv()`가 아는 것.""" path.parent.mkdir(parents=True, exist_ok=True) diff --git a/resources/tester/test_626_2_route_follow.py b/resources/tester/test_626_2_route_follow.py new file mode 100644 index 000000000..60c515fd7 --- /dev/null +++ b/resources/tester/test_626_2_route_follow.py @@ -0,0 +1,221 @@ +"""626-2 예정노선 다시 추적 `POST /route/follow` · 고친 예정노선 `expected_route_edit.csv`. + +요구 530 D3 · D4 · D8. 못박는 것 + - follow = 체인 초기 폴리라인(`_ensure_planned_initial` 파일)과 같은 입력이면 같은 선 · 노드 · + 곡선 · 편집표. + - follow 는 아무것도 안 씀(프로젝트 폴더 그대로) · 경위도 입력 = TM 입력과 같은 선. + - [저장](노선 초안 `expected`) = `expected_route_edit.csv` 씀 · `expected_route.csv` · + 초기 폴리라인 · `initial_snapshot/` 그대로 · 없음 = 그대로 · [] = 지움 · + GET `/route/plan` `expected_edited`. + - [초기화] = 고친 예정노선 지움(스냅샷에 섞여 있어도). + - follow 길은 `/route/replan` · `as_initial` 을 안 부름. +""" + +from __future__ import annotations + +import asyncio +import csv +import hashlib +import json +import math +from contextlib import asynccontextmanager +from pathlib import Path +from uuid import uuid4 + +import pytest + +from B05_Profile import B05_Profile_Router_Replan as replan +from B05_Profile import B05_Profile_Router_RouteFollow as follow +from common_util.common_util_crs import tm_to_lonlat +from common_util.common_util_initial_snapshot import snapshot_dir +from common_util.common_util_route_geometry import ( + expected_route_csv_path, + expected_route_edit_path, + planned_route_initial_path, + write_route_csv, +) + +ROOT = Path(__file__).resolve().parents[2] +RADIUS_M = 30.0 +EPSG = 5186 +# 예정노선 점 묶음 꼴(3 m 간격 · 굽은 산길) — 사업지 TM +LINE = [(200000.0 + 3 * i, 450000.0 + 40 * math.sin(i / 20)) for i in range(160)] + + +def _patch(monkeypatch, root: Path) -> None: + async def paths(_pid): + return root, "stored" + + async def criteria(_pid): + return RADIUS_M, 0.0, 0.0 + + monkeypatch.setattr(replan, "_project_paths", paths) + monkeypatch.setattr(replan, "_plan_criteria", criteria) + monkeypatch.setattr(follow, "_project_paths", paths) + monkeypatch.setattr(follow, "_plan_criteria", criteria) + + +def _rows(path: Path) -> list[list[float]]: + with path.open(encoding="utf-8") as file: + return [[float(r["x"]), float(r["y"])] for r in csv.DictReader(file)] + + +def _tree(root: Path) -> dict[str, str]: + return { + str(p.relative_to(root)): hashlib.sha1(p.read_bytes()).hexdigest() + for p in sorted(root.rglob("*")) + if p.is_file() + } + + +def test_follow_은_체인_초기선과_같은_선(tmp_path: Path) -> None: + write_route_csv(expected_route_csv_path(tmp_path), [{"x": x, "y": y} for x, y in LINE]) + assert replan._ensure_planned_initial(tmp_path, RADIUS_M) + target = planned_route_initial_path(tmp_path) + table = json.loads(replan._curves_path(target).read_text(encoding="utf-8")) + # 같은 입력 = 체인이 읽는 예정노선 파일 점(csv 4자리) + same = [tuple(p) for p in replan._vertices_of(expected_route_csv_path(tmp_path))] + line = follow.follow_route_line(same, RADIUS_M) + assert line["planned"] == _rows(target) + assert [[round(n["x"], 4), round(n["y"], 4)] for n in line["nodes"]] == _rows( + replan._nodes_path(target) + ) + assert line["curves"] == table["curves"] and line["curves"] + assert line["node_edits"] == table.get("edits") + assert line["station_snap"] is True and table["station_snap"] is True + + +def test_follow_은_안_씀_경위도도_같은_선(tmp_path: Path, monkeypatch) -> None: + _patch(monkeypatch, tmp_path) + (tmp_path / "keep.txt").write_text("x", encoding="utf-8") + before = _tree(tmp_path) + body = follow.RouteFollowRequest(points=LINE) + tm = asyncio.run(follow.follow_route(uuid4(), body)) + assert tm["status"] == "success" and len(tm["nodes"]) >= 3 + + class _Pool: + @asynccontextmanager + async def acquire(self): + yield None + + async def epsg(*_a): + return EPSG + + monkeypatch.setattr(follow, "get_db_pool", lambda: _Pool()) + monkeypatch.setattr(follow, "get_surface_crs_epsg", epsg) + lonlat = [tm_to_lonlat(x, y, EPSG) for x, y in LINE] + geo = asyncio.run( + follow.follow_route(uuid4(), follow.RouteFollowRequest(points=lonlat, crs="lonlat")) + ) + assert len(geo["nodes"]) == len(tm["nodes"]) + assert all(math.dist(a, b) < 0.01 for a, b in zip(geo["planned"], tm["planned"], strict=True)) + assert _tree(tmp_path) == before # 저장 0 + short = asyncio.run(follow.follow_route(uuid4(), follow.RouteFollowRequest(points=LINE[:1]))) + assert short.status_code == 400 + + +def _save(root: Path, expected) -> None: + nodes = [LINE[0], LINE[80], LINE[-1]] + draft = {"nodes": nodes, "curveOn": [True] * 3, "stationSnap": True, "expected": expected} + body = replan.RouteSaveRequest(vertices=[], draft=draft) + out = asyncio.run(replan.replan_route(uuid4(), body)) + assert out["status"] == "success" + + +def test_저장은_고친_예정노선만_씀(tmp_path: Path, monkeypatch) -> None: + _patch(monkeypatch, tmp_path) + + async def recompute(*_a, **_k): + return {"route_id": 1} + + monkeypatch.setattr(replan, "_recompute", recompute) + write_route_csv(expected_route_csv_path(tmp_path), [{"x": x, "y": y} for x, y in LINE]) + replan._ensure_planned_initial(tmp_path, RADIUS_M) + (snapshot_dir(tmp_path) / "B05_Profile__route").mkdir(parents=True) + (snapshot_dir(tmp_path) / "B05_Profile__route" / "planned_route.csv").write_text("x") + keep = [expected_route_csv_path(tmp_path), planned_route_initial_path(tmp_path)] + fixed = {p: p.read_bytes() for p in keep} + shot = _tree(snapshot_dir(tmp_path)) + + edited = [list(p) for p in LINE[::10]] + _save(tmp_path, edited) + assert _rows(expected_route_edit_path(tmp_path)) == [ + [round(x, 4), round(y, 4)] for x, y in edited + ] + assert asyncio.run(replan.read_route_plan(uuid4()))["expected_edited"] == _rows( + expected_route_edit_path(tmp_path) + ) + _save(tmp_path, None) # 없음 = 그대로 + assert expected_route_edit_path(tmp_path).is_file() + _save(tmp_path, []) # 지움 + assert not expected_route_edit_path(tmp_path).exists() + assert asyncio.run(replan.read_route_plan(uuid4()))["expected_edited"] == [] + assert {p: p.read_bytes() for p in keep} == fixed + assert _tree(snapshot_dir(tmp_path)) == shot # 초기값 그대로 + + +def test_초기화는_고친_예정노선_지움(tmp_path: Path, monkeypatch) -> None: + from B05_Profile import B05_Profile_Router_Corridor as corridor + from B05_Profile import B05_Profile_Router_Lifecycle as lifecycle + from common_util import common_util_initial_snapshot as snapshot + + class _Cursor: + rowcount = 1 + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def execute(self, *_a): + pass + + class _Connection: + def cursor(self, *_a, **_k): + return _Cursor() + + async def begin(self): + pass + + async def commit(self): + pass + + async def rollback(self): + pass + + class _Pool: + @asynccontextmanager + async def acquire(self): + yield _Connection() + + def _async(value): + async def run(*_a, **_k): + return value + + return run + + # 추천 [적용]이 고친 예정노선째 스냅샷을 뜬 경우도 — 복원 뒤 지움 + stored = snapshot_dir(tmp_path) / "B05_Profile__route" + stored.mkdir(parents=True) + (stored / "expected_route_edit.csv").write_text("sequence,x,y\n", encoding="utf-8") + (snapshot_dir(tmp_path) / "db.json").write_text(json.dumps({"routes": [{"id": 1}]})) + write_route_csv(expected_route_edit_path(tmp_path), [{"x": 0, "y": 0}, {"x": 1, "y": 1}]) + monkeypatch.setattr(lifecycle, "get_db_pool", lambda: _Pool()) + monkeypatch.setattr(lifecycle, "get_project_storage_relative_path", _async("p")) + monkeypatch.setattr(lifecycle, "resolve_stored_project_path", lambda _p: str(tmp_path)) + monkeypatch.setattr(lifecycle, "get_latest_route", _async({"id": 9})) + monkeypatch.setattr(lifecycle, "start_stage", _async(None)) + monkeypatch.setattr(snapshot, "restore_initial_snapshot", _async(9)) + monkeypatch.setattr(corridor, "prune_corridor_files", lambda *_a: 0) + response = asyncio.run(lifecycle.reset_route_design(uuid4(), None)) + assert json.loads(response.body)["restored"] is True + assert not expected_route_edit_path(tmp_path).exists() + + +@pytest.mark.parametrize("name", ["B05_Profile_Router_RouteFollow.py"]) +def test_follow_길은_replan_as_initial_안_부름(name: str) -> None: + text = (ROOT / "B05_Profile" / name).read_text(encoding="utf-8") + code = text.split('"""', 2)[2] # 머리글 뺀 몸통 + assert "replan_route" not in code and "as_initial" not in code + assert "expected_route_csv_path" not in code and "write_" not in code From 85ab474320eb9af7299a49eb48420a70290721bd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:30:53 +0900 Subject: [PATCH 12/18] =?UTF-8?q?feat(D03=20=EA=B8=8B=EA=B8=B0):=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=C2=B7=EA=B8=B0=EC=9A=B8=EA=B8=B0=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=EB=B6=80=EC=B1=84=EA=BC=B4=20=C2=B7=20=EC=A0=95?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=20=EC=B1=84=EC=9B=80=20=C2=B7=20+/=E2=88=92?= =?UTF-8?q?=20=EB=85=B8=EB=93=9C=20=EC=83=89=20=C2=B7=20Shift=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20=C2=B7=20=EC=99=84?= =?UTF-8?q?=EB=A7=8C=ED=95=9C=20=EB=95=85=20=ED=9D=A0=20(PLAN=20625-2=20?= =?UTF-8?q?=C2=B7=20=EC=9A=94=EA=B5=AC=20530=20=C2=B7=20531)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 셈 logic/D03_PlanAnalysis_Fan.ts(화면 고르기 전용 · 파이썬 짝 없음 · B05 626-3 이 그대로 씀) — 이웃 노드 사이 허용 호 · 정방향 = 앞 구간 쪽(첫 구간 없음 · 갈래 = 붙은 선 · 거의 같으면 마우스 쪽) · 완만한 땅 = 어느 방향이나 · Shift = 호 위 마우스 방위(길이 그대로 · 기울기 실제 지형 · 호 밖 = 호 끝) - 미리보기 paintFan — 정방향 반투명 채움 · 역방향 테두리 · 노드 + 따뜻한 색 · − 찬 색 · 고리 = 고른 점 부호 색 - 놓인 꺾임점 = 들어온 구간 기울기 부호 색(저장 없이 셈) - Shift · 완만한 땅으로 찍은 노드 기록 = 길이 고정(저장 꼴 그대로) - 색 토큰 --draw-map-slope-up · --draw-map-slope-down · test_551_2 토큰 8 → 10 - 시험 test_625_draw(부채꼴 · 정방향 · +/− · 노드 2/4/6/8 · 완만한 땅 · Shift 실제 지형) Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts | 82 ++++++--- .../D03_PlanAnalysis_UI_DrawPaint.ts | 72 ++++++-- .../logic/D03_PlanAnalysis_Fan.ts | 129 ++++++++++++++ resources/tester/test_551_2_draw_tokens.py | 6 +- resources/tester/test_625_draw.py | 161 ++++++++++++++++++ ui_template/ui_template_theme.css | 2 + 6 files changed, 414 insertions(+), 38 deletions(-) create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan.ts create mode 100644 resources/tester/test_625_draw.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 64ee4a4a1..8a6828d02 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -13,6 +13,8 @@ * 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷: * 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 길이·기울기 고정(양각기 원 위 후보 중 * 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈). + * 길이·기울기 고정 부채꼴(625-2 · `logic/D03_PlanAnalysis_Fan`) — 정방향 = 앞 구간(갈래 첫 구간 = 붙은 선) · + * 완만한 땅 = 어느 방향이나 · Shift 누른 채 = 허용 호 위 마우스 방위(길이 그대로 · 기록 = 길이 고정). * 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 · * 여러 해 = 다 그은 뒤 그리기 칸 「연도 구역 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름. * 고르기 · 고치기 · 마커(쉴 때) = `D03_PlanAnalysis_UI_Edit`. @@ -24,13 +26,12 @@ import { showToast } from "@ui/ui_template_elements"; import { keyIs } from "../A00_Common/key_registry"; import { - compassCandidates, - compassPoint, fixedLengthPoint, fixedSlopePoint, groundSlopePct, segmentLength, } from "./logic/D03_PlanAnalysis_Draw"; +import { compassFan, fanBetween, fanPick, type Fan } from "./logic/D03_PlanAnalysis_Fan"; import { addBranch, appendVertex, @@ -75,6 +76,8 @@ export interface DrawPreview { note: string | null; blocked: boolean; snapped: boolean; + /** 길이만 지킨 점(Shift 사이 고르기 · 완만한 땅) — 노드 기록 = 길이 고정(B2). */ + lengthOnly: boolean; /** 다른 선 위(꺾임점 아님)에 붙음 — 점을 찍을 때 그 선에 꺾임점을 끼움. */ onLine: { line: number; segment: number } | null; } @@ -119,9 +122,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { let flip = false; // 끝에서 긋는 중 — 끝낼 때 뒤집음 let strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴 let preview: DrawPreview | null = null; - let candidates: Vertex[] = []; + let fan: Fan | null = null; let kind: DrawConstraint = "both"; // 625-1 · 기억 안 함(B9) - let hoverAt: { px: number; py: number; map: ScreenMap } | null = null; // Tab 뒤 미리보기 다시 + let hoverAt: { px: number; py: number; map: ScreenMap; shift: boolean } | null = null; // Tab · Shift 뒤 미리보기 다시 let lengthM = DRAW_DEFAULT_LENGTH_M; let slopePct = DRAW_DEFAULT_SLOPE_PCT; // 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 · 그 기록 @@ -187,8 +190,34 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { : null; } - /** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저. */ - function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null { + /** 앞 구간 시작 — 정방향 부채꼴(625-2 B5) · 첫 구간 = null · 갈래 첫 구간 = 붙은 선 들어온 쪽. */ + function prevOf(): Vertex | null { + const network = params.network(); + if (span) { + const vertices = network.lines[spanLine()]?.vertices ?? []; + const through = span.through; + return through.length > 1 + ? through[through.length - 2] + : through.length + ? vertices[span.from] + : (vertices[span.from - 1] ?? null); + } + const line = active === null ? undefined : network.lines[active]; + if (!line) return null; + if (line.vertices.length > 1) return line.vertices[line.vertices.length - 2]; + const parent = network.lines.find((other) => other.id === line.branch?.line)?.vertices; + if (!parent || !line.branch) return null; + const [node, before, after] = [ + parent[line.branch.vertex], + parent[line.branch.vertex - 1], + parent[line.branch.vertex + 1], + ]; + if (before) return before; + return node && after ? [2 * node[0] - after[0], 2 * node[1] - after[1]] : null; + } + + /** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저 · `shift` = 부채꼴 사이 고르기. */ + function previewAt(px: number, py: number, map: ScreenMap, shift = false): DrawPreview | null { const spanVertices = span ? params.network().lines[spanLine()]?.vertices : undefined; if (active === null && !spanVertices) return null; const from = spanVertices @@ -197,7 +226,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { if (!from) return null; const z = params.heightAt(); const cursor = map.toMetric(px, py); - candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : []; + fan = kind === "both" ? compassFan(from, lengthM, slopePct, z, prevOf(), cursor) : null; // 다시 긋기 = 원래 뒤 노드에만 붙음 const end = spanVertices?.[span!.to]; const snap = end @@ -207,6 +236,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { : snapAt(px, py, map); let point: Vertex | null = cursor; let note: string | null = null; + let lengthOnly = false; if (snap) { [point, note] = [snap.point, snap.note]; } else if (kind === "length") { @@ -214,8 +244,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { } else if (kind === "slope") { point = fixedSlopePoint(from, cursor, slopePct, z); if (!point) note = `이 방향엔 ±${fmt(Math.abs(slopePct))} % 자리 없음`; - } else if (kind === "both") { - point = compassPoint(from, cursor, lengthM, slopePct, z); + } else if (fan) { + point = shift ? fanBetween(fan, from, cursor, lengthM) : fanPick(fan, from, cursor, lengthM); + lengthOnly = shift || fan.flat; if (!point) note = `반지름 ${fmt(lengthM)} m 원 위에 ±${fmt(Math.abs(slopePct))} % 자리 없음`; } const at = point ?? cursor; @@ -227,6 +258,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { note, blocked: !point || length <= 0, snapped: !!snap, + lengthOnly: lengthOnly && !snap, onLine: snap?.onLine ?? null, }; } @@ -267,7 +299,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { flip = false; strokeStart = null; preview = null; - candidates = []; + fan = null; span = null; } @@ -390,7 +422,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { return false; } // 노드 기록 = 그때 방식 · 값(붙은 점 = 자유 · 599-2) - const record = preview.snapped ? nodeRecord("free", 0, 0) : nodeRecord(kind, lengthM, slopePct); + const record = preview.snapped + ? nodeRecord("free", 0, 0) + : nodeRecord(preview.lengthOnly ? "length" : kind, lengthM, slopePct); if (span) { if (!preview.snapped) { span.through.push(preview.point); @@ -447,18 +481,12 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { () => { const from = stroke === null ? null : network.lines[stroke]?.vertices.at(-1); if (from && preview) - paintPreview( - context, - map, - from, - preview, - kind === "both" ? { lengthM, candidates } : null, - ); + paintPreview(context, map, from, preview, fan ? { lengthM, fan } : null); }, dim, ); }, - press(px, py, map, button) { + press(px, py, map, button, shift) { if (!editable) return false; if (mode === "draw" && button === 0) { if (active === null && !span) { @@ -467,14 +495,14 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { params.onMode(); return started; } - preview = previewAt(px, py, map); + preview = previewAt(px, py, map, shift); return addPoint(); } return false; }, - hover(px, py, map) { - hoverAt = { px, py, map }; - preview = mode === "draw" ? previewAt(px, py, map) : null; + hover(px, py, map, shift) { + hoverAt = { px, py, map, shift }; + preview = mode === "draw" ? previewAt(px, py, map, shift) : null; }, leave() { hoverAt = null; @@ -508,7 +536,13 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { if (mode === "draw" && keyIs(event, "plan.drawKind")) { kind = kind === "both" ? "free" : "both"; kindFields.set({ kind }); - if (hoverAt) preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.map); + if (hoverAt) preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.map, hoverAt.shift); + return true; + } + // Shift 누름 = 마우스 안 움직여도 사이 고르기로(뗌은 다음 움직임 · 누름이 Shift 를 다시 봄) + if (mode === "draw" && event.key === "Shift" && hoverAt) { + hoverAt.shift = true; + preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.map, true); return true; } if (mode === "draw" && keyIs(event, "plan.drawFinish")) { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index 462468e09..e75e1096f 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -3,10 +3,13 @@ * D03 손 긋기 겹 그림 · 집기(PLAN 562-3 · 580) — `D03_PlanAnalysis_UI_Draw`(긋기) · * `D03_PlanAnalysis_UI_Edit`(고르기 · 고치기)가 같이 씀. 그림 = 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · * 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리. + * 길이·기울기 고정 부채꼴(625-2) — 정방향 = 채움 · 역방향 = 테두리 · 노드 · 놓인 꺾임점 · 미리보기 고리 = + * 기울기 부호 색(+ 오르막 `--draw-map-slope-up` 따뜻한 색 · − 내리막 `--draw-map-slope-down` 찬 색 · 막힘 = 경고 빨강). + * `paintFan` = ScreenMap 의 `toScreen` 만 씀 — B05 평면(626-3)이 그대로 씀. * 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`. * ========================================================================== */ -import { pointColor, routeColor } from "@ui/ui_template_palette"; +import { pointColor, routeColor, themeColor } from "@ui/ui_template_palette"; import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import { distanceAlong, @@ -17,6 +20,7 @@ import { stationChainages, type HeightAt, } from "./logic/D03_PlanAnalysis_Draw"; +import { fanPoint, type Fan } from "./logic/D03_PlanAnalysis_Fan"; import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit"; import { nodeOff } from "./logic/D03_PlanAnalysis_Span"; @@ -52,6 +56,54 @@ export function strokeCased( context.setLineDash([]); } +/** 기울기 부호 색(625-2 B3 · B4) — + 오르막 따뜻한 색 · − 내리막 찬 색 · null(지형 밖) = 흰색. */ +export function slopeColor(pct: number | null): string { + if (pct === null) return "#ffffff"; + return pct >= 0 + ? themeColor("--draw-map-slope-up", "#ff9e2c") + : themeColor("--draw-map-slope-down", "#29e0ff"); +} + +/** 부채꼴 호 그림 걸음(라디안 · 약 3°). */ +const FAN_STEP = Math.PI / 60; + +/** 길이·기울기 고정 부채꼴 — 허용 호(정방향 = 반투명 채움 · 역방향 = 테두리만) · 노드 = 부호 색. */ +export function paintFan( + context: CanvasRenderingContext2D, + map: Pick, + from: Vertex, + lengthM: number, + fan: Fan, +): void { + const colors = planColors(); + const center = map.toScreen(from); + for (const arc of fan.arcs) { + context.beginPath(); + context.moveTo(center[0], center[1]); + const steps = Math.max(2, Math.ceil((arc.to - arc.from) / FAN_STEP)); + for (let k = 0; k <= steps; k += 1) { + const [sx, sy] = map.toScreen( + fanPoint(from, lengthM, arc.from + ((arc.to - arc.from) * k) / steps), + ); + context.lineTo(sx, sy); + } + context.closePath(); + if (arc.forward) { + context.globalAlpha = 0.3; + context.fillStyle = colors.active; + context.fill(); + context.globalAlpha = 1; + } + strokeCased(context, colors.active, arc.forward ? 2 : 1.5); + } + for (const node of fan.nodes) { + const [cx, cy] = map.toScreen(node.point); + context.beginPath(); + context.arc(cx, cy, 4, 0, Math.PI * 2); + fillCased(context, slopeColor(node.sign)); + } +} + /** 지도 위 점 — 색 채움 + 검은 테. */ function fillCased(context: CanvasRenderingContext2D, color: string): void { context.fillStyle = color; @@ -161,8 +213,7 @@ export function paintNetwork( ); if (map.slopes) segmentLabels(context, map, line.vertices, z); }); - // 꺾임점 · 기록과 어긋난 노드(옮김) = 경고 고리(599-2) - context.fillStyle = "#ffffff"; + // 꺾임점 = 들어온 구간 기울기 부호 색(625-2 B4) · 기록과 어긋난 노드(옮김) = 경고 고리(599-2) context.strokeStyle = colors.warn; context.lineWidth = 2; for (const line of network.lines) @@ -171,6 +222,7 @@ export function paintNetwork( const [sx, sy] = map.toScreen(v); context.beginPath(); context.arc(sx, sy, 3, 0, Math.PI * 2); + context.fillStyle = slopeColor(groundSlopePct(line.vertices[i - 1], v, z)); context.fill(); if (!nodeOff(line, i, z)) return; context.beginPath(); @@ -274,13 +326,14 @@ export function paintMark( context.stroke(); } -/** 미리보기 — 점선 · 둘 다(양각기) 원 · 후보 · 글(거리 · 가상 종단 기울기 % · °). */ +/** 미리보기 — 점선 · 길이·기울기 고정 원 · 부채꼴 · 노드 · 글(거리 · 가상 종단 기울기 % · °) · + * 고리 = 고른 점 기울기 부호 색(놓인 뒤 꺾임점과 같은 색). */ export function paintPreview( context: CanvasRenderingContext2D, map: ScreenMap, from: Vertex, preview: DrawPreview, - compass: { lengthM: number; candidates: Vertex[] } | null, + compass: { lengthM: number; fan: Fan } | null, ): void { const colors = planColors(); const [ax, ay] = map.toScreen(from); @@ -289,12 +342,7 @@ export function paintPreview( context.beginPath(); context.arc(ax, ay, compass.lengthM * map.pxPerMeter, 0, Math.PI * 2); strokeCased(context, colors.active, 1.5, [3, 4]); - for (const c of compass.candidates) { - const [cx, cy] = map.toScreen(c); - context.beginPath(); - context.arc(cx, cy, 3.5, 0, Math.PI * 2); - fillCased(context, colors.active); - } + paintFan(context, map, from, compass.lengthM, compass.fan); } const color = preview.blocked ? colors.warn : colors.active; context.beginPath(); @@ -303,7 +351,7 @@ export function paintPreview( strokeCased(context, color, 2.5, [6, 4]); context.beginPath(); context.arc(bx, by, 5, 0, Math.PI * 2); - strokeCased(context, color, 2.5); + strokeCased(context, preview.blocked ? colors.warn : slopeColor(preview.slopePct), 2.5); const value = `${fmt(preview.lengthM)} m · ` + (preview.slopePct === null ? "지형 밖" : slopeText(preview.slopePct)); diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan.ts new file mode 100644 index 000000000..6fe797e0d --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan.ts @@ -0,0 +1,129 @@ +/* ============================================================================= + * D03_PlanAnalysis_Fan.ts + * D 예정노선 길이·기울기 고정 긋기 — 부채꼴 고르기 셈(PLAN 625-2 · 요구 530 · 531 B1~B7). + * 노드 = 반지름 L 원 위 |기울기| = s 자리(양각기 후보 · 562-1 짝 `compassCandidates`) · 부호 = 오르막 + · 내리막 −. + * 부채꼴 = 각도 차례 이웃 두 노드 사이 호 중 가운데가 |기울기| ≤ s 인 호(노드 2 · 4 · 6 · 8 개 모두 같은 규칙). + * 정방향 = 앞 구간 방향과 가장 가까운 호(앞 구간 없음 = 없음 · 거의 같으면 마우스 쪽) · 나머지 = 역방향. + * 완만한 땅(노드 0 · 원 전체 ≤ s) = 어느 방향이나(길이 고정처럼). + * Shift 사이 고르기 = 마우스 방위를 허용 호 안으로 묶음 · 길이 그대로 · 기울기 = 그 자리 실제 지형. + * 화면 고르기 전용 — 서버는 [저장] 때 꼭짓점으로 다시 셈(파이썬 짝 없음) · DOM · fetch · three 없음. + * B05 평면(626-3)이 그대로 씀. + * ========================================================================== */ + +import { + compassCandidates, + segmentLength, + type HeightAt, + type Vertex, +} from "./D03_PlanAnalysis_Draw"; + +const TAU = 2 * Math.PI; +/** 정방향 두 호 점수 차가 이보다 작으면 마우스 쪽 호(cos 차 · 약 3°). */ +const FORWARD_TIE = 0.05; + +/** 원 위 노드 — 방위(라디안 · 동쪽 0 · 반시계 · 작업 좌표 y 위) · 부호(+1 오르막 · −1 내리막). */ +export interface FanNode { + point: Vertex; + angle: number; + sign: 1 | -1; +} + +/** 허용 호 — `from` 에서 반시계로 `to`(to > from · 2π 넘을 수 있음). */ +export interface FanArc { + from: number; + to: number; + forward: boolean; +} + +export interface Fan { + nodes: FanNode[]; + arcs: FanArc[]; + /** 원 전체가 |기울기| ≤ s(노드 0) — 어느 방향이나. */ + flat: boolean; +} + +const norm = (angle: number): number => ((angle % TAU) + TAU) % TAU; +const bearing = (from: Vertex, to: Vertex): number => + norm(Math.atan2(to[1] - from[1], to[0] - from[0])); +/** 두 방위 사이 각(0 ~ π). */ +const gap = (a: number, b: number): number => Math.min(norm(a - b), norm(b - a)); + +/** 원 위 방위 자리. */ +export function fanPoint(from: Vertex, lengthM: number, angle: number): Vertex { + return [from[0] + Math.cos(angle) * lengthM, from[1] + Math.sin(angle) * lengthM]; +} + +/** 부채꼴 — `prev` = 앞 구간 시작(정방향 · 없으면 null) · `cursor` = 정방향이 거의 같을 때 가름. */ +export function compassFan( + from: Vertex, + lengthM: number, + slopePct: number, + heightAt: HeightAt, + prev: Vertex | null, + cursor: Vertex | null = null, +): Fan { + const z0 = heightAt(from[0], from[1]); + if (!(lengthM > 0) || !Number.isFinite(z0)) return { nodes: [], arcs: [], flat: false }; + const rise = (Math.abs(slopePct) / 100) * lengthM; + const allowed = (angle: number): boolean => { + const p = fanPoint(from, lengthM, angle); + return Math.abs(heightAt(p[0], p[1]) - z0) <= rise; // NaN = 지형 밖 = 거짓 + }; + const nodes: FanNode[] = compassCandidates(from, lengthM, slopePct, heightAt) + .map((point): FanNode => ({ + point, + angle: bearing(from, point), + sign: heightAt(point[0], point[1]) >= z0 ? 1 : -1, + })) + .sort((a, b) => a.angle - b.angle); + if (!nodes.length) return { nodes, arcs: [], flat: allowed(0) }; + const arcs: FanArc[] = []; + nodes.forEach((node, i) => { + const to = i + 1 < nodes.length ? nodes[i + 1].angle : nodes[0].angle + TAU; + if (allowed((node.angle + to) / 2)) arcs.push({ from: node.angle, to, forward: false }); + }); + if (prev && arcs.length && segmentLength(prev, from) > 1e-9) { + const heading = bearing(prev, from); + const mid = (arc: FanArc): number => (arc.from + arc.to) / 2; + const ranked = arcs + .map((arc) => ({ arc, score: Math.cos(mid(arc) - heading) })) + .sort((a, b) => b.score - a.score); + let pick = ranked[0].arc; + if (cursor && ranked.length > 1 && ranked[0].score - ranked[1].score < FORWARD_TIE) { + const toward = bearing(from, cursor); + if (gap(mid(ranked[1].arc), toward) < gap(mid(pick), toward)) pick = ranked[1].arc; + } + pick.forward = true; + } + return { nodes, arcs, flat: false }; +} + +/** 고르기 — 완만한 땅 = 마우스 방향 원 위(길이 고정처럼) · 아니면 마우스에 가장 가까운 노드 · 없으면 null. */ +export function fanPick(fan: Fan, from: Vertex, cursor: Vertex, lengthM: number): Vertex | null { + if (fan.flat) + return segmentLength(from, cursor) > 1e-9 + ? fanPoint(from, lengthM, bearing(from, cursor)) + : null; + let best: Vertex | null = null; + let bestD = Infinity; + for (const node of fan.nodes) { + const d = segmentLength(node.point, cursor); + if (d < bestD) [best, bestD] = [node.point, d]; + } + return best; +} + +/** Shift 사이 고르기 — 마우스 방위가 허용 호 안이면 그 방위 · 밖이면 가장 가까운 호 끝(= 노드) · 길이 그대로. */ +export function fanBetween(fan: Fan, from: Vertex, cursor: Vertex, lengthM: number): Vertex | null { + if (segmentLength(from, cursor) < 1e-9) return null; + const toward = bearing(from, cursor); + if (fan.flat) return fanPoint(from, lengthM, toward); + let best: number | null = null; + let bestGap = Infinity; + for (const arc of fan.arcs) { + if (norm(toward - arc.from) <= arc.to - arc.from) return fanPoint(from, lengthM, toward); + for (const end of [arc.from, arc.to]) + if (gap(end, toward) < bestGap) [best, bestGap] = [end, gap(end, toward)]; + } + return best === null ? null : fanPoint(from, lengthM, best); +} diff --git a/resources/tester/test_551_2_draw_tokens.py b/resources/tester/test_551_2_draw_tokens.py index 74130a9a1..f05b8e632 100644 --- a/resources/tester/test_551_2_draw_tokens.py +++ b/resources/tester/test_551_2_draw_tokens.py @@ -173,9 +173,11 @@ def test_map_drawing_colors_fixed_and_readable_on_dark_map(): """600-1 지도 위 그림 색 — 테마 안 따름(어두움 블록에 없음) · 어두운 바탕 `#1f2937` · 검은 테 둘 다 ≥ 3 : 1.""" blocks = _blocks(THEME.read_text(encoding="utf-8")) names = [ - k for k in blocks["light"] if re.fullmatch(r"--draw-map-(sketch|measure|warn|route-\d)", k) + k + for k in blocks["light"] + if re.fullmatch(r"--draw-map-(sketch|measure|warn|route-\d|slope-(up|down))", k) ] - assert len(names) == 8, names + assert len(names) == 10, names ground = _lum("#1f2937") for name in names: for mode in ("dark", "auto"): diff --git a/resources/tester/test_625_draw.py b/resources/tester/test_625_draw.py new file mode 100644 index 000000000..c0822d833 --- /dev/null +++ b/resources/tester/test_625_draw.py @@ -0,0 +1,161 @@ +"""D03 길이·기울기 고정 부채꼴 셈(PLAN 625-2 · 요구 530 · 531 B1~B7) — TS `logic/D03_PlanAnalysis_Fan.ts` 순수 시험. + +화면 고르기 전용(파이썬 짝 없음) — tsc 로 옮겨 Node 로 돌림. +- 경사면 65 % · 반지름 20 m · 9 % = 노드 4 · 호 2 · 호마다 + 하나 − 하나 · 부채꼴 폭 ≈ 2·arcsin(9/65). +- 꽃잎 땅 cos(nθ) = 노드 2 · 4 · 6 · 8 · 호 n. +- 정방향 = 앞 구간 쪽 호 · 첫 구간 없음 · 거의 같으면 마우스 쪽. +- 완만한 땅 = 노드 0 · 어느 방향이나 · Shift 사이 = 길이 그대로 · 기울기 = 실제 지형 · 호 밖 = 호 끝. + +./venv/Scripts/python.exe -m pytest resources/tester/test_625_draw.py -q +""" + +from __future__ import annotations + +import json +import math +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TS_FILES = [ + ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Draw.ts", + ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Fan.ts", +] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import * as fan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan.js"; +import { groundSlopePct, segmentLength } from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.js"; +const L = 20, S = 9, O = [0, 0]; +const plane = (x, y) => 0.65 * x; +const gentle = (x, y) => 0.01 * x; +// 꽃잎 — 원 위 높이 차 = 10·(cos nθ − 1)/2 · 시작점 0 +const petal = (n) => (x, y) => (Math.hypot(x, y) / L) * 10 * (Math.cos(n * Math.atan2(y, x)) - 1) / 2; +const at = (deg) => [Math.cos((deg * Math.PI) / 180) * 50, Math.sin((deg * Math.PI) / 180) * 50]; +const deg = (p) => ((Math.atan2(p[1], p[0]) * 180) / Math.PI + 360) % 360; +const view = (f) => ({ + nodes: f.nodes.map((n) => ({ deg: (n.angle * 180) / Math.PI, sign: n.sign })), + arcs: f.arcs.map((a) => ({ from: (a.from * 180) / Math.PI, to: (a.to * 180) / Math.PI, forward: a.forward })), + flat: f.flat, +}); +const pick = (f, z, p) => p && { deg: deg(p), length: segmentLength(O, p), slope: groundSlopePct(O, p, z) }; +const out = {}; +// 경사면 — 앞 구간 북쪽으로(앞 점 = 남쪽) · 첫 구간 · 동쪽(오르막) 앞 구간 = 마우스 쪽 +out.plane = view(fan.compassFan(O, L, S, plane, [0, -20])); +out.planeFirst = view(fan.compassFan(O, L, S, plane, null)); +out.planeTieSouth = view(fan.compassFan(O, L, S, plane, [-20, 0], at(-80))); +out.planeTieNorth = view(fan.compassFan(O, L, S, plane, [-20, 0], at(80))); +const f = fan.compassFan(O, L, S, plane, [0, -20]); +out.between = pick(f, plane, fan.fanBetween(f, O, at(93), L)); +out.betweenOut = pick(f, plane, fan.fanBetween(f, O, at(60), L)); +out.pick = pick(f, plane, fan.fanPick(f, O, at(60), L)); +out.pickReverse = pick(f, plane, fan.fanPick(f, O, at(-95), L)); +for (const n of [1, 2, 3, 4]) out[`petal${n}`] = view(fan.compassFan(O, L, S, petal(n), null)); +const g = fan.compassFan(O, L, S, gentle, [0, -20]); +out.gentle = view(g); +out.gentlePick = pick(g, gentle, fan.fanPick(g, O, at(200), L)); +out.gentleBetween = pick(g, gentle, fan.fanBetween(g, O, at(200), L)); +out.steep = view(fan.compassFan(O, L, S, (x, y) => 0.5 * Math.hypot(x, y), null)); +out.offTerrain = view(fan.compassFan(O, L, S, () => NaN, null)); +writeFileSync(process.argv[2], JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def r(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp = tmp_path_factory.mktemp("fan") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), *map(str, TS_FILES), "--outDir", str(out), "--rootDir", str(ROOT)] + + ["--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler"] + + ["--ignoreConfig"], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + 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 / "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 _inside(arc: dict, deg: float) -> bool: + return (deg - arc["from"]) % 360 <= arc["to"] - arc["from"] + + +def test_경사면_노드_넷_호_둘_부호_짝(r): + f = r["plane"] + assert len(f["nodes"]) == 4 and len(f["arcs"]) == 2 and not f["flat"] + width = 2 * math.degrees(math.asin(9 / 65)) + by_deg = {round(n["deg"], 3): n["sign"] for n in f["nodes"]} + for arc in f["arcs"]: + assert abs((arc["to"] - arc["from"]) - width) < 0.05 + ends = {by_deg[round(arc["from"], 3)], by_deg[round(arc["to"] % 360, 3)]} + assert ends == {1, -1} # 호마다 + 하나 − 하나 + assert _inside(arc, 90) or _inside(arc, 270) # 등고선 방향 + # + = 오르막(동쪽 반) · − = 서쪽 반 + for n in f["nodes"]: + assert (n["sign"] == 1) == (math.cos(math.radians(n["deg"])) > 0) + + +def test_정방향_앞_구간_쪽_첫_구간_없음_같으면_마우스_쪽(r): + forward = [a for a in r["plane"]["arcs"] if a["forward"]] + assert len(forward) == 1 and _inside(forward[0], 90) + assert not any(a["forward"] for a in r["planeFirst"]["arcs"]) + south = [a for a in r["planeTieSouth"]["arcs"] if a["forward"]] + north = [a for a in r["planeTieNorth"]["arcs"] if a["forward"]] + assert _inside(south[0], 270) and _inside(north[0], 90) + + +@pytest.mark.parametrize("n", [1, 2, 3, 4]) +def test_꽃잎_땅_노드_2_4_6_8(r, n): + f = r[f"petal{n}"] + assert len(f["nodes"]) == 2 * n and len(f["arcs"]) == n + for k in range(n): # 허용 호 = 꽃잎 꼭대기(cos nθ = 1)마다 + assert sum(_inside(a, 360 * k / n) for a in f["arcs"]) == 1 + assert all(node["sign"] == -1 for node in f["nodes"]) + + +def test_shift_사이_길이_그대로_기울기_실제_지형(r): + b = r["between"] + assert abs(b["deg"] - 93) < 1e-6 and abs(b["length"] - 20) < 1e-9 + assert abs(b["slope"] - 65 * math.cos(math.radians(93))) < 1e-6 and abs(b["slope"]) < 9 + # 호 밖 = 가까운 호 끝(= ±9 % 노드) + o = r["betweenOut"] + assert abs(o["length"] - 20) < 1e-9 and abs(abs(o["slope"]) - 9) < 0.01 + assert abs(o["deg"] - (90 - math.degrees(math.asin(9 / 65)))) < 0.05 + + +def test_보통_고르기_가까운_노드_역방향도(r): + assert abs(abs(r["pick"]["slope"]) - 9) < 0.01 + rev = r["pickReverse"] + assert abs(abs(rev["slope"]) - 9) < 0.01 and 260 < rev["deg"] < 280 # 역방향 호 노드도 고름 + + +def test_완만한_땅_어느_방향이나(r): + g = r["gentle"] + assert g["flat"] and not g["nodes"] and not g["arcs"] + for key in ("gentlePick", "gentleBetween"): + p = r[key] + assert abs(p["deg"] - 200) < 1e-6 and abs(p["length"] - 20) < 1e-9 + assert abs(p["slope"] - 1 * math.cos(math.radians(200))) < 1e-6 + + +def test_가파른_땅_지형_밖_고를_자리_없음(r): + for key in ("steep", "offTerrain"): + f = r[key] + assert not f["flat"] and not f["nodes"] and not f["arcs"] diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index 3294e052e..a2ee2adbb 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -601,6 +601,8 @@ html.is-bm-route .m01lab-src__body * { --draw-map-sketch: #ffe94d; /* 지도 위 그림(테마 안 따름 · 검은 테와 함께) — 긋기 미리보기 · 그리는 선 · 고름 강조 · 마커 */ --draw-map-measure: #ffffff; /* 거리재기 */ --draw-map-warn: #ff5a4d; /* 긋기 막힘(경고) */ + --draw-map-slope-up: #ff9e2c; /* 긋기 기울기 부호 — + 오르막(따뜻한 색 · 노드 · 꺾임점) */ + --draw-map-slope-down: #29e0ff; /* − 내리막(찬 색) */ --draw-map-route-0: #3df5c4; /* 예정노선 선마다 차례 색(개발제한 · 산사태 주황과 가름) */ --draw-map-route-1: #6db8ff; --draw-map-route-2: #cf9dff; From de8f2fee4a41d625c77b0d79eb0fe0e20f60613d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:33:47 +0900 Subject: [PATCH 13/18] =?UTF-8?q?feat(D03):=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EB=9D=BC=EC=9D=B4=EB=8B=A4=20=E2=80=94=20LAS=20?= =?UTF-8?q?=EC=98=AC=EB=A6=AC=EA=B8=B0=20=C2=B7=20B04=20=EC=97=94=EC=A7=84?= =?UTF-8?q?=20=EC=A7=80=ED=98=95=20=C2=B7=20terrain=3Dlidar=20(PLAN=20624-?= =?UTF-8?q?4=20=C2=B7=20=EC=9A=94=EA=B5=AC=20530=20=C2=B7=20531)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /plan/lidar(LAS · LAZ + .prj · 예정노선만 · 다시 올리면 갈음) → 뒤에서 지형(진행 = /plan/progress lidar) · GET /plan/lidar - 자리 = D 의 B04_PreProcess/lidar/(안은 B 와 같은 꼴) · LAS 좌표계 다르면 x · y 옮겨 적음 · DTM = surface_models 행(높이 격자) - terrain=lidar 급경사 · 음영 · 0차곡 = 올린 DTM · 범위 밖 = 빈칸(수치지형도로 안 메움 · 결정 A⑥) - B04 run_surface_analysis 에 maps 인자 덧(기본 True = B 그대로 · False = 지도 · GIS 받기 · 도엽 서피스 건너뜀) - 시험 test_624_4_plan_lidar 4 Co-Authored-By: Claude Opus 5.5 --- B04_PreProcess/B04_PreProcess_Engine.py | 40 ++-- D03_PlanAnalysis/D03_PlanAnalysis_Lidar.py | 194 ++++++++++++++++++ D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 2 +- .../D03_PlanAnalysis_TerrainSource.py | 11 +- main.py | 2 + resources/tester/test_624_4_plan_lidar.py | 128 ++++++++++++ 6 files changed, 356 insertions(+), 21 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_Lidar.py create mode 100644 resources/tester/test_624_4_plan_lidar.py diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index 6e18bfc88..71f1c0583 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -197,9 +197,12 @@ def run_surface_analysis( methods: list[str], force: bool = False, on_progress: ProgressCallback | None = None, + maps: bool = True, ) -> dict[str, Any]: """구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다. + `maps=False` = 지도 · GIS 받기와 도엽 서피스를 건너뜀(D 라이다 · PLAN 624-4). + `las_path`는 지형 파일 한 장 또는 여러 장이다 — 여러 장이면 합친 범위로 한 벌을 만든다(2026-09-06 사용자 확정). @@ -349,31 +352,38 @@ def run_surface_analysis( ) # 3-2·3-3. VWorld 지도·국가 GIS 벡터·수치지형도 도엽 (공용 블록 — 도엽 서피스도 사용) - _report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중") las_bounds_dict = { "x": [float(bounds[0, 0]), float(bounds[0, 1])], "y": [float(bounds[1, 0]), float(bounds[1, 1])], "z": [float(bounds[2, 0]), float(bounds[2, 1])], } - download_geodata( - project_root, processed_dir, las_bounds_dict, las_paths[0].parent, rebuild, report=_report - ) + if maps: + _report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중") + download_geodata( + project_root, + processed_dir, + las_bounds_dict, + las_paths[0].parent, + rebuild, + report=_report, + ) # 3-4. 도엽등고선 3D 서피스 — LAS가 있어도 참고용으로 같이 만들어 영구저장한다 # (2026-08-30 사용자 확정). 실패해도 분석은 계속한다. - _report(94, "surface_model", "도엽등고선 3D 서피스 생성 중") sheet_models: list[dict[str, Any]] = [] - try: - from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import ( - build_sheet_surface_from_route, - ) + if maps: + _report(94, "surface_model", "도엽등고선 3D 서피스 생성 중") + try: + from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import ( + build_sheet_surface_from_route, + ) - # 자동 전처리는 기본 방식 하나만 만든다 — 나머지는 관리자가 화면에서 고를 때. - sheet_models = build_sheet_surface_from_route( - project_root, processed_dir, models_dir, list(SHEET_SURFACE_AUTO_METHODS) - ) - except Exception as exc: - logger.warning("도엽등고선 서피스 생성 실패: %s", exc) + # 자동 전처리는 기본 방식 하나만 만든다 — 나머지는 관리자가 화면에서 고를 때. + sheet_models = build_sheet_surface_from_route( + project_root, processed_dir, models_dir, list(SHEET_SURFACE_AUTO_METHODS) + ) + except Exception as exc: + logger.warning("도엽등고선 서피스 생성 실패: %s", exc) _report(95, "saving", "결과 저장 중") diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Lidar.py b/D03_PlanAnalysis/D03_PlanAnalysis_Lidar.py new file mode 100644 index 000000000..c50f3d8c3 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Lidar.py @@ -0,0 +1,194 @@ +"""D 예정노선 라이다 — LAS 올리기 → B04 엔진 그대로 지형(요구 530 · 531 · PLAN 624-4). + +- `POST /api/projects/{id}/plan/lidar` — LAS(+ .prj) 올리기 · 뒤에서 지형 짓기 + (진행 = `/plan/progress` 의 `lidar`) · 다시 올리면 옛 LAS 를 갈음 +- `GET …/plan/lidar` — 지은 라이다 지형(없으면 `ready: false`) + +자리 = `B04_PreProcess/lidar/`(D 몫 · 안은 B 와 같은 꼴 — `B03_FileInput/input/las/` · +`B04_PreProcess/models/`). +LAS 좌표계(.prj · LAS 머리 · 없으면 프로젝트 좌표계)가 프로젝트와 다르면 옮겨 적음(높이 그대로). +지도 · GIS 받기 · 도엽 서피스는 안 함(`maps=False`) · 지은 DTM = `surface_models` 행 +(높이 표본 격자). +라이다 범위 밖 = 빈칸(격자 `valid_mask`) · 수치지형도로 메우지 않음(결정 A⑥). + +계산 자리 = ③ 서버 단독. +""" + +from __future__ import annotations + +import asyncio +import json +import shutil +from pathlib import Path +from typing import Any, Callable +from uuid import UUID + +from fastapi import APIRouter, File, HTTPException, UploadFile + +from common_util.common_util_json import atomic_write_json + +router = APIRouter(prefix="/api/projects", tags=["D03 Plan Lidar"]) + +LIDAR = Path("B04_PreProcess") / "lidar" # 표면 모델 행 경로 = B04_PreProcess 아래 +UPLOAD = "upload" +LAS_DIR = Path("B03_FileInput") / "input" / "las" +MODELS = Path("B04_PreProcess") / "models" +META = "lidar_meta.json" +LAS_SUFFIXES = (".las", ".laz") +CHUNK = 2_000_000 + + +def lidar_root(root: Path) -> Path: + return Path(root) / LIDAR + + +def read_meta(root: Path) -> dict[str, Any]: + try: + return json.loads((lidar_root(root) / META).read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def lidar_dem(root: Path) -> Path | None: + """지은 라이다 DTM(평활 안 함) — 없으면 None.""" + name = read_meta(root).get("model_file") + path = Path(root) / name if name else None + return path if path is not None and path.is_file() else None + + +def check_names(names: list[str]) -> None: + """LAS · LAZ 하나 이상 · 그 밖은 .prj 만 · 같은 이름 없음.""" + suffixes = [Path(name).suffix.lower() for name in names] + if not any(s in LAS_SUFFIXES for s in suffixes): + raise HTTPException(status_code=400, detail="LAS 또는 LAZ 파일을 하나 이상 올리세요.") + if any(s not in (*LAS_SUFFIXES, ".prj") for s in suffixes): + raise HTTPException(status_code=400, detail="LAS · LAZ · PRJ 만 올릴 수 있습니다.") + if len({Path(name).name.casefold() for name in names}) != len(names): + raise HTTPException(status_code=400, detail="같은 이름 파일이 있습니다.") + + +def las_epsg(path: Path, prj_text: str | None) -> int | None: + """LAS 좌표계 — .prj 먼저 · 없으면 LAS 머리 · 모르면 None.""" + import laspy + from pyproj import CRS + + from common_util.common_util_crs import identify_epsg + + if prj_text: + return identify_epsg(CRS.from_wkt(prj_text), prj_text) + with laspy.open(path) as reader: + crs = reader.header.parse_crs() + return identify_epsg(crs, crs.to_wkt()) if crs is not None else None + + +def place_las(src: Path, dst_dir: Path, src_epsg: int | None, epsg: int) -> Path: + """올린 LAS → 지형 입력 자리 — 좌표계가 같거나 모르면 옮김 · 다르면 x · y 만 옮겨 적음(.las).""" + import laspy + from pyproj import CRS, Transformer + + dst_dir.mkdir(parents=True, exist_ok=True) + if src_epsg in (None, int(epsg)): + return Path(shutil.move(src, dst_dir / src.name)) + dst = dst_dir / f"{src.stem}.las" + move = Transformer.from_crs(src_epsg, int(epsg), always_xy=True) + with laspy.open(src) as reader: + old = reader.header + header = laspy.LasHeader(point_format=old.point_format, version=old.version) + header.scales = old.scales + x0, y0 = move.transform(old.mins[0], old.mins[1]) + header.offsets = [round(x0), round(y0), old.offsets[2]] + header.add_crs(CRS.from_epsg(int(epsg))) + with laspy.open(dst, mode="w", header=header) as writer: + for points in reader.chunk_iterator(CHUNK): + x, y = move.transform(points.x, points.y) + record = laspy.ScaleAwarePointRecord.zeros(len(points), header=header) + record.array[:] = points.array + record.x, record.y, record.z = x, y, points.z + writer.write_points(record) + src.unlink() + return dst + + +def build_lidar(root: Path, epsg: int, report: Callable[[int, str], None]) -> dict[str, Any]: + """올린 자리(`upload/`)의 LAS → 입력 자리 갈음 → B04 엔진(지면 필터 · DTM) → 메타.""" + from B04_PreProcess.B04_PreProcess_Engine import run_surface_analysis + from config.config_system import SURFACE_AUTO_METHODS + + base = lidar_root(root) + upload, las_dir = base / UPLOAD, base / LAS_DIR + prj = next(iter(sorted(upload.glob("*.prj"))), None) + prj_text = prj.read_text(encoding="utf-8", errors="ignore") if prj else None + sources = sorted(p for p in upload.iterdir() if p.suffix.lower() in LAS_SUFFIXES) + if sources: # 새로 올림 = 옛 LAS 갈음 + shutil.rmtree(las_dir, ignore_errors=True) + report(5, "LAS 좌표계 맞추는 중") + for path in sources: + place_las(path, las_dir, las_epsg(path, prj_text), epsg) + shutil.rmtree(upload, ignore_errors=True) + las_paths = sorted(p for p in las_dir.glob("*") if p.suffix.lower() in LAS_SUFFIXES) + if not las_paths: + raise FileNotFoundError("올린 LAS 가 없습니다.") + result = run_surface_analysis( + base, + las_paths, + source_filters=None, + methods=list(SURFACE_AUTO_METHODS), + on_progress=lambda percent, _stage, message: report(10 + int(percent * 0.85), message), + maps=False, + ) + prefix = LIDAR.as_posix() + models = [ + {**model, "model_file_path": f"{prefix}/{model['model_file_path']}"} + for model in result["models"] + if model.get("model_file_path") + ] + picked = result["source_filters"][0] + meta = { + "epsg": int(epsg), + "files": [p.name for p in las_paths], + "source_filter": picked, + "model_file": f"{prefix}/{(MODELS / f'dtm_{picked}.npz').as_posix()}", + "point_count": result["processed"]["point_count"], + "bounds": result["processed"]["bounds"], + } + atomic_write_json(base / META, meta) + return {"meta": meta, "models": models} + + +@router.post("/{project_id}/plan/lidar", response_model=None) +async def upload_plan_lidar(project_id: UUID, files: list[UploadFile] = File(...)) -> dict: + """D 에 LAS 올리기 → 뒤에서 라이다 지형(진행 = `/plan/progress` 의 `lidar`).""" + from B03_FileInput.B03_FileInput_Engine import save_upload_stream + from D03_PlanAnalysis import D03_PlanAnalysis_Router as base + + check_names([upload.filename or "" for upload in files]) + root, epsg, _, _ = await base._plan_ends(project_id) # 예정노선 아님 400 + if (base._JOBS.get((str(project_id), "lidar")) or {}).get("running"): + raise HTTPException(status_code=409, detail="이미 돌고 있습니다.") + upload = lidar_root(root) / UPLOAD + shutil.rmtree(upload, ignore_errors=True) + upload.mkdir(parents=True) + try: + for item in files: + await save_upload_stream(item, upload / Path(item.filename or "").name) + except ValueError as error: + shutil.rmtree(upload, ignore_errors=True) + raise HTTPException(status_code=413, detail=str(error)) from error + + async def work(report: Any) -> None: + got = await asyncio.to_thread(build_lidar, root, epsg, report) + await base._save_models(project_id, got["models"], epsg) + report(100, f"라이다 지형 끝 — 점 {got['meta']['point_count']:,}개") + + return base._start_job(project_id, "lidar", work, root) + + +@router.get("/{project_id}/plan/lidar", response_model=None) +async def plan_lidar(project_id: UUID) -> dict[str, Any]: + """지은 라이다 지형 — `ready` · 메타(좌표계 · 파일 · 필터 · 점 수 · 범위).""" + from D03_PlanAnalysis import D03_PlanAnalysis_Store as store + + root, _ = await store.load_plan(project_id) + meta = await asyncio.to_thread(read_meta, root) + ready = await asyncio.to_thread(lidar_dem, root) is not None + return {"status": "success", "ready": ready, **meta} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 5dde3db34..676edafa5 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -57,7 +57,7 @@ router = APIRouter(prefix="/api/projects", tags=["D03 Plan Analysis"]) # 진행 — (프로젝트, 일) → {running, percent, message, error} · 프로세스 메모리 _JOBS: dict[tuple[str, str], dict[str, Any]] = {} _TASKS: set[asyncio.Task] = set() -KINDS = ("terrain", "analyze") +KINDS = ("terrain", "analyze", "lidar") class Center(BaseModel): diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_TerrainSource.py b/D03_PlanAnalysis/D03_PlanAnalysis_TerrainSource.py index 7481ba5f7..079100b32 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_TerrainSource.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_TerrainSource.py @@ -3,7 +3,8 @@ 겹 길(`/plan/layers/*`)은 프로젝트 종류를 안 가림: - 예정노선(PLAN) = 받은 범위 · 지형 = 수치지형도(`models_plan/dtm_sheet_<방법>.npz`) · - 라이다 = D 에 LAS 올리기(624-4) 전이라 없음(404) + `lidar` = D 에 올린 LAS 로 지은 DTM(624-4 · `D03_PlanAnalysis_Lidar` · 안 올렸으면 404 · + 범위 밖 = 빈칸) - 그 밖(임도 등) = 범위 = B04 배경 지도 상자(`vworld_white_meta.json` · 없으면 원천 격자 상자) · 지형 = 설계 지형(stage 1 params · 기본) · `lidar` = 라이다 확정 지표면 · `sheet` = 수치지형도 서피스 · @@ -29,6 +30,7 @@ from common_util.common_util_storage import resolve_stored_project_path from config.config_system_terrain import SHEET_SURFACE_DEFAULT_METHOD from D03_PlanAnalysis import D03_PlanAnalysis_Landslide as landslide from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers +from D03_PlanAnalysis import D03_PlanAnalysis_Lidar as lidar 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 @@ -80,13 +82,12 @@ def layer_store(root: Path, plan: bool) -> Path: def plan_frame(root: Path, epsg: int, box: Box, choice: str | None) -> Frame: - """예정노선 — 수치지형도 하나(design = sheet) · lidar = 없음.""" + """예정노선 — 수치지형도 하나(design = sheet) · lidar = 올린 LAS 의 DTM(없으면 None).""" source = check_source(choice) source = "sheet" if source == "design" else source npz = terrain.models_dir_of(Path(root)) / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz" - return Frame( - Path(root), epsg, box, layer_store(root, True), source, npz if source == "sheet" else None - ) + dem = npz if source == "sheet" else lidar.lidar_dem(root) + return Frame(Path(root), epsg, box, layer_store(root, True), source, dem) def road_dem(models: Path, params: dict[str, Any], source: str) -> tuple[str, Path | None]: diff --git a/main.py b/main.py index 3b05c2b2a..6fee9a373 100644 --- a/main.py +++ b/main.py @@ -75,6 +75,7 @@ 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 D03_PlanAnalysis.D03_PlanAnalysis_Lidar import router as d03_plan_lidar_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 D03_PlanAnalysis.D03_PlanAnalysis_Router_Tiles import router as d03_plan_tiles_router @@ -497,6 +498,7 @@ app.include_router(b05_route_lifecycle_router, dependencies=protected_with_compa app.include_router(d03_plan_analysis_router, dependencies=protected_with_company) app.include_router(d03_plan_tiles_router, dependencies=protected_with_company) app.include_router(d03_plan_network_router, dependencies=protected_with_company) +app.include_router(d03_plan_lidar_router, dependencies=protected_with_company) app.include_router(d04_plan_confirm_router, dependencies=protected_with_company) app.include_router(d04_plan_link_router, dependencies=protected_with_company) app.include_router(e02_stab_ground_router, dependencies=protected_with_company) diff --git a/resources/tester/test_624_4_plan_lidar.py b/resources/tester/test_624_4_plan_lidar.py new file mode 100644 index 000000000..fafb3e3dd --- /dev/null +++ b/resources/tester/test_624_4_plan_lidar.py @@ -0,0 +1,128 @@ +"""PLAN 624-4 — D 예정노선 라이다: LAS 올리기 → B04 엔진 지형 → `terrain=lidar` 겹(요구 530 · 531). + +D 몫 길 = `D03_PlanAnalysis_Lidar` · B04 엔진은 `maps=False` 인자만 덧(기본 = B 그대로). +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +from fastapi import HTTPException + +from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers +from D03_PlanAnalysis import D03_PlanAnalysis_Lidar as lidar +from D03_PlanAnalysis import D03_PlanAnalysis_TerrainSource as source + +D_EPSG, LAS_EPSG = 5187, 5176 # D = GRS80 동부 · 업체 LAS = 베셀 동부(검증 용화와 같음) + + +def _write_las(path: Path, epsg: int | None, x0: float, y0: float) -> None: + """두 덩이(가운데 빈 땅) 지면점 LAS — 비탈 0.3.""" + import laspy + from pyproj import CRS + + rng = np.random.default_rng(7) + xs, ys = [], [] + for dx in (0.0, 160.0): + xs.append(rng.uniform(x0 + dx, x0 + dx + 60, 6000)) + ys.append(rng.uniform(y0, y0 + 60, 6000)) + x, y = np.concatenate(xs), np.concatenate(ys) + header = laspy.LasHeader(point_format=3, version="1.2") + header.offsets = [round(x0), round(y0), 0] + header.scales = [0.01, 0.01, 0.01] + if epsg is not None: + header.add_crs(CRS.from_epsg(epsg)) + data = laspy.LasData(header) + data.x, data.y, data.z = x, y, 500 + 0.3 * (x - x0) + data.classification = np.full(len(x), 2, np.uint8) + path.parent.mkdir(parents=True, exist_ok=True) + data.write(path) + + +def _built(tmp_path: Path, monkeypatch, epsg: int | None = LAS_EPSG) -> dict: # type: ignore[no-untyped-def] + import B04_PreProcess.B04_PreProcess_Engine as engine + + called: list[int] = [] + monkeypatch.setattr(engine, "download_geodata", lambda *a, **k: called.append(1)) + _write_las(lidar.lidar_root(tmp_path) / lidar.UPLOAD / "t.las", epsg, 208300.0, 367500.0) + got = lidar.build_lidar(tmp_path, D_EPSG, lambda *_: None) + assert called == [] # 지도 · GIS 받기 안 함 + return got + + +def test_이름_고르기() -> None: + lidar.check_names(["a.las", "a.prj", "b.LAZ"]) + for names in (["a.prj"], ["a.las", "a.txt"], ["a.las", "A.las"]): + with pytest.raises(HTTPException) as caught: + lidar.check_names(names) + assert caught.value.status_code == 400 + + +def test_올리기_뒤_라이다_지형_B_꼴(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def] + got = _built(tmp_path, monkeypatch) + meta = got["meta"] + dem = lidar.lidar_dem(tmp_path) + assert dem is not None and dem.name == f"dtm_{meta['source_filter']}.npz" + assert dem.parent == lidar.lidar_root(tmp_path) / "B04_PreProcess" / "models" + assert meta["files"] == ["t.las"] and meta["point_count"] == 12000 + assert not (lidar.lidar_root(tmp_path) / lidar.UPLOAD).exists() + # surface_models 행 경로 = 프로젝트 뿌리 기준 + from B04_PreProcess.B04_PreProcess_Repository import _validate_stage_path + + assert any(m["model_file_path"] == meta["model_file"] for m in got["models"]) + assert all(_validate_stage_path(m["model_file_path"]) for m in got["models"]) + # 좌표계 옮김 — 베셀 → GRS80 북 +10 만 m 언저리 + from pyproj import Transformer + + want = Transformer.from_crs(LAS_EPSG, D_EPSG, always_xy=True).transform(208300.0, 367500.0) + assert abs(meta["bounds"]["x_min"] - want[0]) < 1.0 + assert abs(meta["bounds"]["y_min"] - want[1]) < 1.0 + # 다시 올리면 갈음 + _write_las(lidar.lidar_root(tmp_path) / lidar.UPLOAD / "u.las", None, 208300.0, 467500.0) + again = lidar.build_lidar(tmp_path, D_EPSG, lambda *_: None)["meta"] + assert again["files"] == ["u.las"] and abs(again["bounds"]["y_min"] - 467500.0) < 1.0 + + +def test_terrain_lidar_급경사_0차곡_범위_밖_빈칸(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def] + with pytest.raises(HTTPException) as caught: # 안 올림 = 404 + source.need_dem(source.plan_frame(tmp_path, D_EPSG, (0, 0, 1, 1), "lidar")) + assert caught.value.status_code == 404 + _built(tmp_path, monkeypatch) + frame = source.plan_frame(tmp_path, D_EPSG, (0, 0, 1, 1), "lidar") + npz = source.need_dem(frame) + assert frame.source == "lidar" and npz == lidar.lidar_dem(tmp_path) + value = layers.slope_layer(tmp_path, npz.parent, D_EPSG, "slope", npz) + counts = value["counts"] + assert sum(counts) > 0 and max(range(len(counts)), key=counts.__getitem__) in (29, 30) + # 두 덩이 사이 = 빈칸(수치지형도로 안 메움) + with np.load(npz) as dem: + valid = np.asarray(dem["valid_mask"], bool) + x = np.asarray(dem["x"], float) + x0 = float(x[0]) + gap = (x > x0 + 90) & (x < x0 + 130) + assert gap.any() and not valid[:, gap].any() + work, light, full = source.zero_order_calls(frame, 50.0, 5000.0) + assert work(*light) is None # 저장본 없음 + value = work(*full) + assert value["cell_m"] > 0 and work(*light) == value # 다음 = 저장본 + + +def test_B04_기본은_지도_받기_그대로(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def] + """B 프로젝트 전처리(기본 인자) = 지도 · GIS 받기 · 도엽 서피스 그대로 부름.""" + import B04_PreProcess.B04_PreProcess_Engine as engine + import B04_PreProcess.B04_PreProcess_Engine_SheetSurface as sheet + + calls: list[str] = [] + monkeypatch.setattr(engine, "download_geodata", lambda *a, **k: calls.append("maps")) + monkeypatch.setattr( + sheet, "build_sheet_surface_from_route", lambda *a, **k: calls.append("sheet") or [] + ) + las = tmp_path / "B03_FileInput" / "input" / "las" / "t.las" + _write_las(las, None, 200000.0, 500000.0) + engine.run_surface_analysis(tmp_path, las, source_filters=None, methods=["dtm"]) + assert calls == ["maps", "sheet"] + calls.clear() + engine.run_surface_analysis(tmp_path, las, source_filters=None, methods=["dtm"], maps=False) + assert calls == [] From db45b0542017bae62b4d0b36a3bcbf373c65e3a8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:36:38 +0900 Subject: [PATCH 14/18] =?UTF-8?q?test(627-1):=20=EB=82=A1=EC=9D=80=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=206=20=EA=B3=A0=EC=B9=A8=20=E2=80=94=20test?= =?UTF-8?q?=5F283=20=EC=86=90=20=EA=B3=A0=EC=B9=A8=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=C2=B7=20test=5F347=5F2=20=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD?= =?UTF-8?q?=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EA=B0=80=EC=A7=9C=EA=B0=92=20?= =?UTF-8?q?=ED=98=84=ED=96=89=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_347_2 다섯(세대 둘 · 저장 노선 답 · 초안 답 · 잠금) — 59ae735ed(609-1 공용 하강장)가 _prepare 에 좌표계 칸 crs 를 더함 · 시험 가짜 _prepare 에 crs 없어 KeyError · 가짜에 crs None 더함(코드 의도대로) - test_283 한 본문 손 고침 — b92fa06f3(498-2)가 _save_alignment 에 계획고 본문 꼴 검사(ProfileAlignmentSaveRequest 아니면 약속으로 기다림)를 더함 · 시험 가짜 model_copy 가 SimpleNamespace 를 내어 await 실패 · 가짜가 ProfileAlignmentSaveRequest.model_construct 를 내게 고침 - 코드 흠 없음 · B05 · B06 · B04 코드 그대로 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_283_route_change_reset.py | 6 +++++- resources/tester/test_347_2_drainage_live.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/resources/tester/test_283_route_change_reset.py b/resources/tester/test_283_route_change_reset.py index bcd6884f2..651d75fbe 100644 --- a/resources/tester/test_283_route_change_reset.py +++ b/resources/tester/test_283_route_change_reset.py @@ -310,7 +310,11 @@ def test_한_본문_손_고침은_보낸_칸을_씀(monkeypatch): pipes={"points": [{"chainage_m": 50.0}]}, structures=SimpleNamespace(), plus_stations=SimpleNamespace(), - alignment=SimpleNamespace(model_copy=lambda update: SimpleNamespace(**update)), + alignment=SimpleNamespace( + model_copy=lambda update: design_save.ProfileAlignmentSaveRequest.model_construct( + **update + ) + ), culvert_options=None, uphill=None, cross=None, diff --git a/resources/tester/test_347_2_drainage_live.py b/resources/tester/test_347_2_drainage_live.py index f4eaa8856..0add4a346 100644 --- a/resources/tester/test_347_2_drainage_live.py +++ b/resources/tester/test_347_2_drainage_live.py @@ -77,7 +77,7 @@ def test_build_detail_uses_memory_routing(tmp_path): def _patch_live(monkeypatch, preview_fn): async def prepare(project_id): - return {"contours": [], "streams": [], "contour_key": "k"} + return {"contours": [], "streams": [], "contour_key": "k", "crs": None} async def load(project_id): return DrainageContext(stored_path="x", project_root=Path("."), vertices=VERTICES), "" From 7cd09426dfbccfc8096fdfb83ec801f13f2b5120 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:37:43 +0900 Subject: [PATCH 15/18] =?UTF-8?q?chore(=EA=B3=84=ED=9A=8D):=20PLAN=20627?= =?UTF-8?q?=20=EC=9D=B4=EA=B4=80=20=E2=80=94=20=EA=B9=A8=EC=A7=84=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=206=20=EB=AA=A8=EB=91=90=20=EB=82=A1?= =?UTF-8?q?=EC=9D=80=20=EC=8B=9C=ED=97=98=20=C2=B7=20=EA=B0=80=EC=A7=9C?= =?UTF-8?q?=EA=B0=92=EB=A7=8C=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9 --- docs/raw/plans/2026-10-31_plan_627.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/raw/plans/2026-10-31_plan_627.md diff --git a/docs/raw/plans/2026-10-31_plan_627.md b/docs/raw/plans/2026-10-31_plan_627.md new file mode 100644 index 000000000..71a427499 --- /dev/null +++ b/docs/raw/plans/2026-10-31_plan_627.md @@ -0,0 +1,7 @@ +# PLAN 627 이관 — 깨진 시험 6 = 낡은 시험(609-1 crs 칸 · 498-2 계획고 검사) 가짜값 고침 + +## 627 메인에서 깨진 시험 6 — test_283 · test_347_2 (sub2 알림 · 진행) + +- `test_283_route_change_reset.py::test_한_본문_손_고침은_보낸_칸을_씀` · `test_347_2_drainage_live.py` 다섯(세대 · 저장 노선 답 · 잠금) — 623 · 626-2 전부터 깨짐(sub2) · 메인에서도 같음(10-31 브레인). +- [x] 627-1 시험마다 까닭 가름(낡은 시험 = 고침 · 지움 · 까닭은 커밋 글 / 진짜 흠 = 코드 안 고치고 question) — B 그룹 조심 + - 검증완료 — 6 모두 낡은 시험(진짜 흠 0) · test_347_2 다섯 = 59ae735ed(609-1 _prepare 에 crs 칸) · test_283 하나 = b92fa06f3(498-2 _save_alignment 꼴 검사) · 가짜값만 고침 · 두 파일 23 통과 · db45b0542 From c6a1efad0c4fe5d777f6ea3cae7f4854d9f66635 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:47:50 +0900 Subject: [PATCH 16/18] =?UTF-8?q?refactor(=EA=B3=B5=EC=9A=A9=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84):=20=EA=B2=B9=20=EB=B0=9B=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=B2=94=EB=A1=80=20=EC=A4=84=20=C2=B7=20=EA=B8=89=EA=B2=BD?= =?UTF-8?q?=EC=82=AC=20=EB=B2=94=EC=9C=84=20=C2=B7=20=EC=9C=84=EC=84=B1=20?= =?UTF-8?q?=EB=B0=9D=EA=B8=B0=20=C2=B7=20=EA=B2=B9=20=EC=9E=90=EB=A3=8C=20?= =?UTF-8?q?=EB=8F=84=EC=9B=80=EC=9D=84=20A00=5FCommon/map=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20D03=20=EC=9D=80=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=82=B4=EB=86=93=EA=B8=B0=20=C2=B7=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C=20(PLAN=20624-3=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20530)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D03_Api 겹 받기 → map_layer_api.ts(D03_Api 가 다시 내놓음) - UI_SlopeBands · logic SlopeBands · UI_SatLight → map_slope_bands · logic/map_slope_bands · map_sat_light - 범례 줄 legendRows → map_legend.ts(받은 자료 꼴 LegendData · D03 LayerLegend 가 다시 내놓음) - glImage · glTagged · shadeImage · loadImage · WATER_KEYS · FOREST_ROAD_KINDS · hectare → map_layer_data.ts - 시험 경로 고침 — test_603(TS 자리) · test_606_1(밝기 파일) · test_584_3(범례 글 = 공용 파일도 읽음) Co-Authored-By: Claude Opus 5.5 --- .../map/logic/map_slope_bands.ts | 6 +- A00_Common/map/map_layer_api.ts | 221 ++++++++++++++++++ A00_Common/map/map_layer_data.ts | 61 +++++ A00_Common/map/map_legend.ts | 174 +++++++++++++- .../map/map_sat_light.ts | 8 +- .../map/map_slope_bands.ts | 12 +- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 192 +-------------- .../D03_PlanAnalysis_UI_LayerDefs.ts | 68 +----- .../D03_PlanAnalysis_UI_LayerLegend.ts | 152 +----------- .../D03_PlanAnalysis_UI_Layers.ts | 4 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts | 2 +- .../test_584_3_plan_forest_road_zones.py | 1 + resources/tester/test_603_plan_slope_bands.py | 6 +- resources/tester/test_606_1_sat_light.py | 2 +- 14 files changed, 496 insertions(+), 413 deletions(-) rename D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts => A00_Common/map/logic/map_slope_bands.ts (93%) create mode 100644 A00_Common/map/map_layer_api.ts create mode 100644 A00_Common/map/map_layer_data.ts rename D03_PlanAnalysis/D03_PlanAnalysis_UI_SatLight.ts => A00_Common/map/map_sat_light.ts (78%) rename D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts => A00_Common/map/map_slope_bands.ts (94%) diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts b/A00_Common/map/logic/map_slope_bands.ts similarity index 93% rename from D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts rename to A00_Common/map/logic/map_slope_bands.ts index 2fa0ab653..5f9b27a0d 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts +++ b/A00_Common/map/logic/map_slope_bands.ts @@ -1,10 +1,10 @@ /* ============================================================================= - * D03_PlanAnalysis_SlopeBands.ts - * D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 기울기 % 범위 · 색 · 비율 셈(화면 · 서버 없이). + * map_slope_bands.ts(logic) + * 공용 지도 급경사 범위(624-3 공용으로 옮김)(PLAN 603 · 요구 466 · 467 · 468) — 기울기 % 범위 · 색 · 비율 셈(화면 · 서버 없이). * 서버 값 그림 칸 = 기울기 % 내림(0~250 · 250 = 250 % 이상 · `D03_PlanAnalysis_Layers.slope_values_png`) · * 칸 v 는 가운데(v + 0.5)가 [아래, 위) 안이면 그 범위(위 = 250 이면 250 % 이상도) · 앞 범위 먼저. * 기본 = 지식DB 15 · 35 · 40° 의 % — `임도망_편성.md:20` · `타당성평가.md:59` · `노선_배치.md:29`. - * 부르는 곳 = `D03_PlanAnalysis_UI_SlopeBands.ts` · `D03_PlanAnalysis_UI_LayerLegend.ts`. + * 부르는 곳 = `map_slope_bands.ts` · `map_legend.ts` · D03 `UI_LayerLegend`. * ========================================================================== */ export interface SlopeBand { diff --git a/A00_Common/map/map_layer_api.ts b/A00_Common/map/map_layer_api.ts new file mode 100644 index 000000000..94ab56ac8 --- /dev/null +++ b/A00_Common/map/map_layer_api.ts @@ -0,0 +1,221 @@ +/* ============================================================================= + * map_layer_api.ts + * 공용 지도 겹 받기(PLAN 569 · 581 · 584 · 603 · 605 · 606 · 624-3 · 요구 530) — D03 예정노선 · B05 평면이 같이 씀(D03_Api 에서 옮김). + * 서버 길 = `/api/projects/{id}/plan/…`(624-1 · 임도 프로젝트도 · 띠 · 올린 임도망도 = 예정노선만). + * 지적도 · 도엽 겹 GeoJSON 경위도 · 도엽 조각 · 그림 겹(급경사 · 음영기복 · 산사태위험) · 보호구역 · 산림입지도 · + * 선 겹(단층 · 지질구조선) · 0차곡 · 띠 · 올린 임도망도(올리기 · 지움) · 산사태취약지역. + * D03 = `D03_PlanAnalysis_Api` 가 다시 내놓음(부르는 곳 그대로). + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +/** 경위도 GeoJSON 묶음 — B04 `GeoJsonCollection` 과 같은 꼴(공용 폴더는 페이지 파일을 안 부름). */ +export type GeoJsonCollection = { + features?: Array<{ + geometry?: { type: string; coordinates: unknown } | null; + properties?: Record | null; + }>; +}; + +async function request(path: string): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + }); + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!response.ok) { + const detail = typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`; + throw new Error(detail); + } + return body; +} + +const base = (projectId: string): string => `/projects/${projectId}/plan`; + +/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북). */ +export interface PlanRaster { + box_m: [number, number, number, number]; + /** 경위도 네 귀퉁이(왼쪽 아래 · 오른쪽 아래 · 오른쪽 위 · 왼쪽 위) — GL 바탕(586-1). */ + box?: Array<[number, number]>; + image: string; + /** 급경사만(603) — `counts[v]` = 기울기 % 내림 v(0~250) 칸 수 · 그림 = 회색 칸 값(255 = 빈 칸). */ + counts?: number[]; +} + +export const fetchPlanCadastral = (projectId: string): Promise => + request(`${base(projectId)}/layers/cadastral`); + +/** 도엽 겹(`도로중심선` 등) — 없으면 서버가 받은 도엽에서 풀어 냄. */ +export const fetchPlanSheetLayer = (projectId: string, key: string): Promise => + request(`${base(projectId)}/layers/sheet?key=${encodeURIComponent(key)}`); + +/** 보이는 도엽(606-2) — 지도 보기 경위도 상자 → 수 · 번호(가운데부터 · 30 장 넘음 = 빈 목록). */ +export const fetchPlanSheetTiles = ( + projectId: string, + bbox: [number, number, number, number], +): Promise<{ count: number; sheets: string[] }> => + request(`${base(projectId)}/sheet-tiles?bbox=${bbox.map((v) => v.toFixed(5)).join(",")}`); + +/** 도엽 조각 하나(606-1) — 공용 창고 · 없으면 서버가 만듦. */ +export const fetchPlanSheetTile = ( + projectId: string, + sheet: string, + name: string, +): Promise => + request(`${base(projectId)}/sheet-tiles/${sheet}/${encodeURIComponent(name)}`); + +/** 그림 겹 — 급경사 = PNG 바이트 + 머리 `X-Plan-Raster`(616-4 · `image` = blob 주소 · 다 읽으면 + * `slopeValues` 가 놓음) · 그 밖 = JSON(base64 그림). */ +export const fetchPlanRaster = async ( + projectId: string, + kind: "slope" | "hillshade" | "landslide", +): Promise => { + const path = `${base(projectId)}/layers/raster?kind=${kind}`; + if (kind !== "slope") return request(path); + const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { detail?: unknown }; + throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); + } + const meta = JSON.parse(response.headers.get("X-Plan-Raster") ?? "{}") as PlanRaster; + return { ...meta, image: URL.createObjectURL(await response.blob()) }; +}; + +/** 보호 · 규제 구역(581-1) — 지물 `kind` · `name` · `area_m2` + 종류별 개수 · 넓이(실패 = `failed`). */ +export interface PlanZones extends GeoJsonCollection { + summary: Record; +} + +export const fetchPlanZones = (projectId: string): Promise => + request(`${base(projectId)}/layers/zones`); + +/** 산림입지도(581-2) — 지물 `code` · `name` · `area_m2` + 토양형별 개수 · 넓이(코드 차례). */ +export interface PlanSoils extends GeoJsonCollection { + summary: Array<{ code: string; name: string; count: number; area_m2: number }>; +} + +export const fetchPlanSoils = (projectId: string): Promise => + request(`${base(projectId)}/layers/soils`); + +/** 계류 100 m · 도로 300 m 띠(581-1) — 받은 범위 안 넓이 · 비율. */ +export interface PlanBand { + dist_m: number; + lines: number; + area_m2: number; + ratio: number; + collection: GeoJsonCollection; +} + +/** 선 겹(584-1) — 받은 범위 안 선 + 가닥 · 길이 m. */ +export interface PlanLines extends GeoJsonCollection { + summary: { count: number; length_m: number }; +} + +/** 단층 · 지질구조선(브이월드 WFS · 서버 `LINE_KINDS` 열쇠). */ +export const fetchPlanLines = (projectId: string, kind: string): Promise => + request(`${base(projectId)}/layers/lines?kind=${encodeURIComponent(kind)}`); + +/** 0차곡(584-1 · 지형 추정) — 위 집수 넓이 [min_m2, max_m2) 선 + 가닥 · 길이 m. */ +export interface PlanZeroOrder { + min_m2: number; + max_m2: number; + cell_m: number; + count: number; + length_m: number; + collection: GeoJsonCollection; +} + +export const fetchPlanZeroOrder = (projectId: string): Promise => + request(`${base(projectId)}/layers/zero-order`); + +export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> => + request(`${base(projectId)}/layers/bands`); + +/** 올린 묶음 한 줄(605-1) — 올릴 때마다 하나 · 자른 가닥 · 길이 m · 원본 선 수 · 잘린 상자 · 기준점 거리. */ +export interface PlanRoadBundle { + id: string; + name: string; + files: string[]; + bytes: number | null; + uploaded_at: string | null; + count: number; + length_m: number; + total: number; + bounds: [number, number, number, number] | null; + distance_m: number | null; +} + +/** 올린 임도망도(584-3 · 595-4 · 605-1) — 묶음 모두를 합친 값: 기준점 ±5 km ∪ 받은 지형으로 자른 선 + * (선마다 `__bundle` 묶음 번호 · `__line` 묶음 안 차례) + 가닥 · 길이 m · 원본 선 수 · 좌표계 · + * 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 마지막 올린 파일 · 묶음 줄. */ +export interface PlanForestRoads { + bundles: PlanRoadBundle[]; + count: number; + length_m: number; + total: number; + crs: string[]; + bounds: [number, number, number, number] | null; + distance_m: number | null; + source: { files: string[]; bytes: number; uploaded_at: string } | null; + collection: GeoJsonCollection; +} + +/** 올린 적 없으면 오류(404 글 그대로). */ +export const fetchPlanForestRoads = (projectId: string): Promise => + request(`${base(projectId)}/layers/forest-roads`); + +/** 묶음 하나 지움(605-1) — 남은 묶음 합친 값 · 하나도 안 남으면 null. */ +export async function deletePlanForestRoad( + projectId: string, + bundle: string, +): Promise { + const response = await fetch( + `${API_BASE_URL}${base(projectId)}/layers/forest-roads/${encodeURIComponent(bundle)}`, + { method: "DELETE", credentials: "include" }, + ); + const body = (await response.json().catch(() => ({}))) as PlanForestRoads & { + detail?: unknown; + }; + if (!response.ok) + throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); + return body.bundles.length ? body : null; +} + +/** 임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음) — 못 읽으면 서버 글 그대로 오류. */ +export async function uploadPlanForestRoads( + projectId: string, + files: File[], +): Promise { + const form = new FormData(); + for (const file of files) form.append("files", file); + const response = await fetch(`${API_BASE_URL}${base(projectId)}/layers/forest-roads`, { + method: "POST", + credentials: "include", + body: form, + }); + const body = (await response.json().catch(() => ({}))) as PlanForestRoads & { + detail?: unknown; + }; + if (!response.ok) + throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); + return body; +} + +/** 산사태취약지역(584-3) — 받은 범위 안 점 · 지번 맞는 필지 테두리 · 시도별 표 · 표 없는 시도. */ +export interface PlanLandslideZones { + points: Array<{ + lon: number; + lat: number; + kind: string | null; + area_m2: number | null; + place: string; + }>; + parcels: GeoJsonCollection; + sidos: Array<{ code: string; name: string; rows: number; located: number; basis?: string }>; + missing: string[]; + /** 지적도를 받았나(못 받으면 점만). */ + cadastral: boolean; +} + +export const fetchPlanLandslideZones = (projectId: string): Promise => + request(`${base(projectId)}/layers/landslide-zones`); diff --git a/A00_Common/map/map_layer_data.ts b/A00_Common/map/map_layer_data.ts new file mode 100644 index 000000000..5d82fb0c7 --- /dev/null +++ b/A00_Common/map/map_layer_data.ts @@ -0,0 +1,61 @@ +/* ============================================================================= + * map_layer_data.ts + * 공용 지도 겹 자료 도움(PLAN 569-2 · 581 · 586-2 · 624-3) — D03 예정노선 · B05 평면이 같이 씀(D03 `UI_LayerDefs` 에서 옮김). + * 임도 짐작 구분 · 물 면 도엽 이름 · 넓이 글 · 그림 읽기 · GL 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림). + * ========================================================================== */ + +import type { GlImage } from "@ui/ui_template_gl_map"; +import type { GeoJsonCollection, PlanRaster } from "./map_layer_api"; + +/** 도로 중 기존 임도로 짐작하는 구분. */ +export const FOREST_ROAD_KINDS = new Set(["소로", "미분류"]); +/** 물 면 = 도엽 수계(E) 면 셋. */ +export const WATER_KEYS = ["실폭하천", "수계면E003", "수계면E005"]; + +export const hectare = (m2: number): string => `${(m2 / 10000).toFixed(1)} ha`; + +export const loadImage = (src: string): Promise => + new Promise((done, fail) => { + const image = new Image(); + image.onload = () => done(image); + image.onerror = () => fail(new Error("그림을 읽지 못했습니다")); + image.src = src; + }); + +/** GL 자료 — 지물마다 그릴 색(`_color`) · 덧 속성을 붙인 사본. */ +export function glTagged( + collection: GeoJsonCollection, + color: (props: Record) => string, + extra: Record = {}, +): GeoJsonCollection { + return { + ...collection, + features: (collection.features ?? []).map((feature) => ({ + ...feature, + properties: { ...feature.properties, ...extra, _color: color(feature.properties ?? {}) }, + })), + }; +} + +/** GL 그림 자리 — 서버 경위도 귀퉁이(왼쪽 아래부터 돎) → 왼쪽 위부터 · 없으면 null. */ +export function glImage(raster: PlanRaster, url = raster.image): GlImage | null { + const box = raster.box; + return box?.length === 4 ? { url, corners: [box[3], box[2], box[1], box[0]] } : null; +} + +/** 음영 그늘 그림 — 위성 위 「곱하기 0.75」와 같은 검은 반투명(밝기 g → 투명도 0.75 · (1 − g)). */ +export function shadeImage(image: HTMLImageElement): string { + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d")!; + context.drawImage(image, 0, 0); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height); + const px = pixels.data; + for (let i = 0; i < px.length; i += 4) { + px[i + 3] = Math.round(0.75 * (255 - px[i]) * (px[i + 3] / 255)); + px[i] = px[i + 1] = px[i + 2] = 0; + } + context.putImageData(pixels, 0, 0); + return canvas.toDataURL("image/png"); +} diff --git a/A00_Common/map/map_legend.ts b/A00_Common/map/map_legend.ts index e2dd3cc67..96a3aafb7 100644 --- a/A00_Common/map/map_legend.ts +++ b/A00_Common/map/map_legend.ts @@ -2,13 +2,52 @@ * map_legend.ts * 공용 지도 범례(PLAN 569-2 · 622-2 · 624-2 · 요구 530) — 켠 겹 범례 칸 하나(줄마다 `createLegend`) + 페이지가 붙이는 덧 줄. * 조각 겹 범례(산사태위험 · 국유림 임도 · 경제림 · 생태자연도) = 받을 자료 없음 → 켬끔만 보고 여기서 만듦(`tileLegendRows`). - * 받은 자료 범례(수 · 넓이 · 급경사 범위)는 받는 페이지 몫 — 줄을 만들어 `show` 에 넘김. + * 받은 자료 범례 줄(수 · 넓이 · 급경사 범위 · 624-3 D03 에서 옮김) = `legendRows(on, data)` — 받은 자료 꼴은 `LegendData` + * (D03 옛 캔버스 자료 · B05 GL 자료 둘 다 맞음 · 받는 일은 페이지 몫). * 쓰는 법 = `const legend = createMapLegend("d03-plan__legend")` → `legend.show(rows, [덧 줄])` · 줄 · 덧 없으면 숨김. * ========================================================================== */ import { createLegend, type LegendItem } from "@ui/ui_template_flow_legend"; import { forestLegendRows } from "./map_forest_layers"; -import { LANDSLIDE_LEGEND, type MapLayerKey } from "./map_layer_list"; +import type { + PlanBand, + PlanForestRoads, + PlanLandslideZones, + PlanRaster, + PlanSoils, + PlanZones, +} from "./map_layer_api"; +import { hectare } from "./map_layer_data"; +import { + LANDSLIDE_LEGEND, + LINE_KEYS, + LINE_LAYERS, + ZONE_COLORS, + type MapLayerKey, +} from "./map_layer_list"; +import { SLOPE_MAX_PCT, bandRatios, pctToDeg, type SlopeBand } from "./logic/map_slope_bands"; + +/** 지물 수만 보는 겹(옛 캔버스 자료 · GeoJSON 둘 다). */ +interface Counted { + features?: ArrayLike; +} + +/** 범례가 읽는 받은 자료 — 받은 겹만. */ +export interface LegendData { + slope: { raster: PlanRaster; bands: SlopeBand[] }; + landslideZones: { info: PlanLandslideZones }; + zones: { summary: PlanZones["summary"] }; + /** 토양형(코드 차례) 색 값 · 요약. */ + soils: { kinds: Array<{ color: string }>; summary: PlanSoils["summary"] }; + bands: { info: { stream: PlanBand; road: PlanBand } }; + water: Counted; + building: Counted; + road: { small: Counted }; + forestRoadFile: { info: PlanForestRoads }; + faults: { count: number; length_m: number }; + lineaments: { count: number; length_m: number }; + zeroOrder: { count: number; length_m: number }; +} export interface MapLegend { root: HTMLElement; @@ -34,3 +73,134 @@ export function createMapLegend(className: string): MapLegend { }, }; } + +const km = (m: number): string => `${(m / 1000).toFixed(2)} km`; + +/** 급경사 범례 — 범위마다 「아래~위 %(°) · 비율」(위 = 끝 = 「↑」). */ +function slopeRow({ raster, bands }: LegendData["slope"]): LegendItem[] { + const ratios = bandRatios(raster.counts ?? [], bands); + const deg = (pct: number): string => pctToDeg(pct).toFixed(0); + return bands.map((band, i) => { + const open = band.high >= SLOPE_MAX_PCT; + const range = open + ? `${band.low} %↑(${deg(band.low)}°↑)` + : `${band.low}~${band.high} %(${deg(band.low)}~${deg(band.high)}°)`; + return { label: `${range} · ${(ratios[i] * 100).toFixed(1)} %`, color: `var(${band.color})` }; + }); +} + +/** 산사태취약지역 범례 — 점 · 필지 · 위치 없는 시도 · 표 없는 시도 · 지적 못 받음. */ +function landslideZoneRow({ info }: LegendData["landslideZones"]): LegendItem[] { + const color = "var(--draw-map-landslide-zone)"; + const items: LegendItem[] = [ + { + label: `산사태취약지역 점 ${info.points.length}곳`, + color, + kind: "dot", + off: !info.points.length, + }, + { + label: `지번 맞는 필지 ${info.parcels.features?.length ?? 0}곳(테두리)`, + color, + kind: "dash", + off: !info.parcels.features?.length, + }, + ]; + for (const sido of info.sidos) + if (!sido.located) items.push({ label: `${sido.name} 위치 없음 — 필지만`, color, off: true }); + for (const name of info.missing) + items.push({ label: `${name} — 이 시도 자료 없음`, color: "transparent", off: true }); + if (!info.cadastral) + items.push({ label: "지적도 못 받음 — 필지 테두리 없음", color: "transparent", off: true }); + return items; +} + +/** 켠 겹의 받은 자료 범례 줄(급경사 · 조각 겹 · 산사태취약 · 보호구역 · 토양 · 띠 · 선 겹 · 물 면 · 건물 · 임도). */ +export function legendRows( + on: Record, + data: Partial, +): LegendItem[][] { + const rows: LegendItem[][] = []; + if (on.slope && data.slope) rows.push(slopeRow(data.slope)); + rows.push(...tileLegendRows(on)); // 조각 겹 = 받기 없음(622-2 · 공용 범례) + if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones)); + if (on.zones && data.zones) { + const items = Object.entries(data.zones.summary) + .filter(([, kind]) => kind.count || kind.failed) + .map(([key, kind]) => ({ + label: kind.failed + ? `${kind.label} 받지 못함` + : `${kind.label} ${kind.count}곳 ${hectare(kind.area_m2)}`, + color: `var(${ZONE_COLORS[key]})`, + off: !!kind.failed, + })); + rows.push( + items.length + ? items + : [{ label: "보호 · 규제 구역 없음(받은 범위 안)", color: "transparent", off: true }], + ); + } + if (on.soils && data.soils) { + const { kinds, summary } = data.soils; + rows.push( + summary.length + ? summary.map((soil, i) => ({ + label: `${soil.name} ${hectare(soil.area_m2)}`, + color: `var(${kinds[i].color})`, + })) + : [{ label: "산림입지도 없음(받은 범위 안)", color: "transparent", off: true }], + ); + } + if (on.bands && data.bands) { + const { stream, road } = data.bands.info; + rows.push([ + { + label: `계류 ${stream.dist_m} m 띠 ${hectare(stream.area_m2)} (${(stream.ratio * 100).toFixed(1)} %)`, + color: "var(--draw-map-band-stream)", + }, + { + label: `도로 ${road.dist_m} m 띠 ${hectare(road.area_m2)} (${(road.ratio * 100).toFixed(1)} %)`, + color: "var(--draw-map-band-road)", + }, + ]); + } + const lines = LINE_KEYS.flatMap((key) => { + const got = on[key] && data[key]; + if (!got) return []; + const { label, color, dash } = LINE_LAYERS[key]; + return [ + { + label: `${label} ${got.count}가닥 ${km(got.length_m)}`, + color: `var(${color})`, + kind: dash.length ? ("dash" as const) : ("line" as const), + off: !got.count, + }, + ]; + }); + if (lines.length) rows.push(lines); + const tail: LegendItem[] = []; + if (on.water && data.water) + tail.push({ + label: `물 면 ${data.water.features?.length ?? 0}곳`, + color: "var(--draw-map-water)", + }); + if (on.building && data.building) + tail.push({ + label: `건물 ${(data.building.features?.length ?? 0).toLocaleString()}동`, + color: "var(--draw-map-building)", + }); + if (on.forestRoad && data.road) + tail.push({ + label: `임도 짐작 ${data.road.small.features?.length ?? 0}가닥`, + color: "var(--draw-map-forest-road)", + kind: "dash", + }); + if (on.forestRoadFile && data.forestRoadFile) + tail.push({ + label: `올린 임도망도 ${data.forestRoadFile.info.count}가닥 ${km(data.forestRoadFile.info.length_m)}`, + color: "var(--draw-map-forest-road-file)", + kind: "line", + }); + if (tail.length) rows.push(tail); + return rows; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SatLight.ts b/A00_Common/map/map_sat_light.ts similarity index 78% rename from D03_PlanAnalysis/D03_PlanAnalysis_UI_SatLight.ts rename to A00_Common/map/map_sat_light.ts index c5dab6be4..85956f8bf 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SatLight.ts +++ b/A00_Common/map/map_sat_light.ts @@ -1,11 +1,11 @@ /* ============================================================================= - * D03_PlanAnalysis_UI_SatLight.ts - * D03 위성 바탕 밝기(요구 475) — 노선 선 대비를 위해 위성을 조금 어둡게 · 사용자가 범례에서 조절. - * 값 = 밝기 상한(0.4~1) · 계정 화면 취향으로 기억. 칠하기 = `D03_PlanAnalysis_UI_MapGl` `setSatelliteLight`. + * map_sat_light.ts + * 공용 지도 위성 바탕 밝기(D03 · B05 · 624-3 공용으로 옮김)(요구 475) — 노선 선 대비를 위해 위성을 조금 어둡게 · 사용자가 범례에서 조절. + * 값 = 밝기 상한(0.4~1) · 계정 화면 취향으로 기억. 칠하기 = D03 `UI_MapGl` `setSatelliteLight` · B05 `UI_RouteEdit_Layers`. * ========================================================================== */ import { createSlider } from "@ui/ui_template_input"; -import { readByKey, writeByKey } from "../A00_Common/b_page_state"; +import { readByKey, writeByKey } from "../b_page_state"; const PREF_KEY = "aislo:pref:d03.satellite-light"; export const SAT_LIGHT_MIN = 0.4; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts b/A00_Common/map/map_slope_bands.ts similarity index 94% rename from D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts rename to A00_Common/map/map_slope_bands.ts index 9c7b0314c..5ab73e403 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts +++ b/A00_Common/map/map_slope_bands.ts @@ -1,10 +1,10 @@ /* ============================================================================= - * D03_PlanAnalysis_UI_SlopeBands.ts - * D03 급경사 범위 옵션(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달 + * map_slope_bands.ts + * 공용 지도 급경사 범위 옵션(D03 · B05 · 624-3 공용으로 옮김)(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달 * (범위 줄 = 아래 % · 위 % · 색 · 지움 · 칸 옆 ° 환산 · [범위 추가] · [기본값] · [적용] · 빈 값 · 겹침 막음). * 기억 = 화면 취향(`aislo:pref:d03.slope-bands` · 계정에 붙음 · 프로젝트 무관). * 칠하기 = 서버 값 그림(회색 · 칸 = 기울기 % 내림) → 범위 색 그림(서버 왕복 없음 · CLAUDE.md 5장). - * 범위 · 비율 셈 = `logic/D03_PlanAnalysis_SlopeBands.ts` · 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`. + * 범위 · 비율 셈 = `logic/map_slope_bands.ts` · 부르는 곳 = D03 `UI_Layers` · B05 `UI_RouteEdit_Layers`. * ========================================================================== */ import { createButton, createIconButton } from "@ui/ui_template_button"; @@ -13,8 +13,8 @@ import { createLegend } from "@ui/ui_template_flow_legend"; import { openModal } from "@ui/ui_template_modal"; import { themeColor } from "@ui/ui_template_palette"; import { createText } from "@ui/ui_template_text"; -import { readByKey, writeByKey } from "../A00_Common/b_page_state"; -import { loadImage } from "./D03_PlanAnalysis_UI_LayerDefs"; +import { readByKey, writeByKey } from "../b_page_state"; +import { loadImage } from "./map_layer_data"; import { DEFAULT_SLOPE_BANDS, SLOPE_COLORS, @@ -25,7 +25,7 @@ import { parseBands, pctToDeg, type SlopeBand, -} from "./logic/D03_PlanAnalysis_SlopeBands"; +} from "./logic/map_slope_bands"; const PREF_KEY = "aislo:pref:d03.slope-bands"; /** 칠한 칸 투명도(겹 투명도 0.55 는 따로). */ diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 0026c5d0c..83978361e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -3,13 +3,12 @@ * D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 · * 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 지점 · 기준점 함께 591-2 · * 지점 · 선 이름 · 노드 기록 · [저장] 기준점 594) · 주소 · 장소 찾기(594-4 공용 `/map/search`) · - * 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림 · 581 — 음영기복 · 보호구역 · 띠 · 산림입지도). + * 지도 겹 받기 = 공용 `A00_Common/map/map_layer_api`(624-3 · 다시 내놓음). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { fetchCachedBytes } from "../A00_Common/b_asset_cache"; import { listSurfaceModels } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler"; import type { PlanLine, PlanPiece, Vertex } from "./logic/D03_PlanAnalysis_Plan"; @@ -180,190 +179,5 @@ export async function fetchPlanSurface(projectId: string): Promise; - image: string; - /** 급경사만(603) — `counts[v]` = 기울기 % 내림 v(0~250) 칸 수 · 그림 = 회색 칸 값(255 = 빈 칸). */ - counts?: number[]; -} - -export const fetchPlanCadastral = (projectId: string): Promise => - request(`${base(projectId)}/layers/cadastral`); - -/** 도엽 겹(`도로중심선` 등) — 없으면 서버가 받은 도엽에서 풀어 냄. */ -export const fetchPlanSheetLayer = (projectId: string, key: string): Promise => - request(`${base(projectId)}/layers/sheet?key=${encodeURIComponent(key)}`); - -/** 보이는 도엽(606-2) — 지도 보기 경위도 상자 → 수 · 번호(가운데부터 · 30 장 넘음 = 빈 목록). */ -export const fetchPlanSheetTiles = ( - projectId: string, - bbox: [number, number, number, number], -): Promise<{ count: number; sheets: string[] }> => - request(`${base(projectId)}/sheet-tiles?bbox=${bbox.map((v) => v.toFixed(5)).join(",")}`); - -/** 도엽 조각 하나(606-1) — 공용 창고 · 없으면 서버가 만듦. */ -export const fetchPlanSheetTile = ( - projectId: string, - sheet: string, - name: string, -): Promise => - request(`${base(projectId)}/sheet-tiles/${sheet}/${encodeURIComponent(name)}`); - -/** 그림 겹 — 급경사 = PNG 바이트 + 머리 `X-Plan-Raster`(616-4 · `image` = blob 주소 · 다 읽으면 - * `slopeValues` 가 놓음) · 그 밖 = JSON(base64 그림). */ -export const fetchPlanRaster = async ( - projectId: string, - kind: "slope" | "hillshade" | "landslide", -): Promise => { - const path = `${base(projectId)}/layers/raster?kind=${kind}`; - if (kind !== "slope") return request(path); - const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); - if (!response.ok) { - const body = (await response.json().catch(() => ({}))) as { detail?: unknown }; - throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); - } - const meta = JSON.parse(response.headers.get("X-Plan-Raster") ?? "{}") as PlanRaster; - return { ...meta, image: URL.createObjectURL(await response.blob()) }; -}; - -/** 보호 · 규제 구역(581-1) — 지물 `kind` · `name` · `area_m2` + 종류별 개수 · 넓이(실패 = `failed`). */ -export interface PlanZones extends GeoJsonCollection { - summary: Record; -} - -export const fetchPlanZones = (projectId: string): Promise => - request(`${base(projectId)}/layers/zones`); - -/** 산림입지도(581-2) — 지물 `code` · `name` · `area_m2` + 토양형별 개수 · 넓이(코드 차례). */ -export interface PlanSoils extends GeoJsonCollection { - summary: Array<{ code: string; name: string; count: number; area_m2: number }>; -} - -export const fetchPlanSoils = (projectId: string): Promise => - request(`${base(projectId)}/layers/soils`); - -/** 계류 100 m · 도로 300 m 띠(581-1) — 받은 범위 안 넓이 · 비율. */ -export interface PlanBand { - dist_m: number; - lines: number; - area_m2: number; - ratio: number; - collection: GeoJsonCollection; -} - -/** 선 겹(584-1) — 받은 범위 안 선 + 가닥 · 길이 m. */ -export interface PlanLines extends GeoJsonCollection { - summary: { count: number; length_m: number }; -} - -/** 단층 · 지질구조선(브이월드 WFS · 서버 `LINE_KINDS` 열쇠). */ -export const fetchPlanLines = (projectId: string, kind: string): Promise => - request(`${base(projectId)}/layers/lines?kind=${encodeURIComponent(kind)}`); - -/** 0차곡(584-1 · 지형 추정) — 위 집수 넓이 [min_m2, max_m2) 선 + 가닥 · 길이 m. */ -export interface PlanZeroOrder { - min_m2: number; - max_m2: number; - cell_m: number; - count: number; - length_m: number; - collection: GeoJsonCollection; -} - -export const fetchPlanZeroOrder = (projectId: string): Promise => - request(`${base(projectId)}/layers/zero-order`); - -export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> => - request(`${base(projectId)}/layers/bands`); - -/** 올린 묶음 한 줄(605-1) — 올릴 때마다 하나 · 자른 가닥 · 길이 m · 원본 선 수 · 잘린 상자 · 기준점 거리. */ -export interface PlanRoadBundle { - id: string; - name: string; - files: string[]; - bytes: number | null; - uploaded_at: string | null; - count: number; - length_m: number; - total: number; - bounds: [number, number, number, number] | null; - distance_m: number | null; -} - -/** 올린 임도망도(584-3 · 595-4 · 605-1) — 묶음 모두를 합친 값: 기준점 ±5 km ∪ 받은 지형으로 자른 선 - * (선마다 `__bundle` 묶음 번호 · `__line` 묶음 안 차례) + 가닥 · 길이 m · 원본 선 수 · 좌표계 · - * 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 마지막 올린 파일 · 묶음 줄. */ -export interface PlanForestRoads { - bundles: PlanRoadBundle[]; - count: number; - length_m: number; - total: number; - crs: string[]; - bounds: [number, number, number, number] | null; - distance_m: number | null; - source: { files: string[]; bytes: number; uploaded_at: string } | null; - collection: GeoJsonCollection; -} - -/** 올린 적 없으면 오류(404 글 그대로). */ -export const fetchPlanForestRoads = (projectId: string): Promise => - request(`${base(projectId)}/layers/forest-roads`); - -/** 묶음 하나 지움(605-1) — 남은 묶음 합친 값 · 하나도 안 남으면 null. */ -export async function deletePlanForestRoad( - projectId: string, - bundle: string, -): Promise { - const response = await fetch( - `${API_BASE_URL}${base(projectId)}/layers/forest-roads/${encodeURIComponent(bundle)}`, - { method: "DELETE", credentials: "include" }, - ); - const body = (await response.json().catch(() => ({}))) as PlanForestRoads & { - detail?: unknown; - }; - if (!response.ok) - throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); - return body.bundles.length ? body : null; -} - -/** 임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음) — 못 읽으면 서버 글 그대로 오류. */ -export async function uploadPlanForestRoads( - projectId: string, - files: File[], -): Promise { - const form = new FormData(); - for (const file of files) form.append("files", file); - const response = await fetch(`${API_BASE_URL}${base(projectId)}/layers/forest-roads`, { - method: "POST", - credentials: "include", - body: form, - }); - const body = (await response.json().catch(() => ({}))) as PlanForestRoads & { - detail?: unknown; - }; - if (!response.ok) - throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); - return body; -} - -/** 산사태취약지역(584-3) — 받은 범위 안 점 · 지번 맞는 필지 테두리 · 시도별 표 · 표 없는 시도. */ -export interface PlanLandslideZones { - points: Array<{ - lon: number; - lat: number; - kind: string | null; - area_m2: number | null; - place: string; - }>; - parcels: GeoJsonCollection; - sidos: Array<{ code: string; name: string; rows: number; located: number; basis?: string }>; - missing: string[]; - /** 지적도를 받았나(못 받으면 점만). */ - cadastral: boolean; -} - -export const fetchPlanLandslideZones = (projectId: string): Promise => - request(`${base(projectId)}/layers/landslide-zones`); +/** 지도 겹 받기 = 공용(624-3 · B05 평면도 씀). */ +export * from "../A00_Common/map/map_layer_api"; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts index c8bc37042..dfdfe2b9e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -1,7 +1,7 @@ /* ============================================================================= * D03_PlanAnalysis_UI_LayerDefs.ts - * D03 바탕 겹 받기 · 옛 캔버스 도움(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 586-2 · 624-2) — 임도 짐작 구분 · 물 면 · - * 면 겹 채우기 · 선 긋기 · 그림 겹 놓기 · GL 바탕 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림). + * D03 바탕 겹 옛 캔버스 도움(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 586-2 · 624-2 · 624-3) — 면 겹 채우기 · 선 긋기 · 그림 겹 놓기. + * 임도 짐작 구분 · 물 면 · 그림 읽기 · GL 자료 = 공용 `A00_Common/map/map_layer_data`(624-3 · 다시 내놓음). * 겹 목록 · 처음 값 · 켬끔 취향 · 선 겹 꼴 · 보호구역 색 = 공용 `A00_Common/map/map_layer_list`(624-2). * 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기 차례 · 범례). * ========================================================================== */ @@ -10,16 +10,23 @@ import type { MapLayerKey } from "../A00_Common/map/map_layer_list"; import { drawPreparedLayer, normalizedToScreen, - type GeoJsonCollection, type PreparedLayer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { PlanRaster } from "./D03_PlanAnalysis_Api"; import type { ScreenMap } from "./D03_PlanAnalysis_UI_Map"; -import type { GlImage } from "./D03_PlanAnalysis_UI_MapGl"; /** 겹 목록 · 페이지 표 = 공용 `map_layer_list`(624-2) — 옛 이름으로 다시 내놓음(캔버스 · 조각 · 임도망 목록이 읽음). */ export type PlanLayerKey = MapLayerKey; +export { + FOREST_ROAD_KINDS, + WATER_KEYS, + glImage, + glTagged, + hectare, + loadImage, + shadeImage, +} from "../A00_Common/map/map_layer_data"; export { LINE_KEYS, LINE_LAYERS, @@ -27,13 +34,6 @@ export { type RoadPick, } from "../A00_Common/map/map_layer_list"; -/** 도로 중 기존 임도로 짐작하는 구분. */ -export const FOREST_ROAD_KINDS = new Set(["소로", "미분류"]); -/** 물 면 = 도엽 수계(E) 면 셋. */ -export const WATER_KEYS = ["실폭하천", "수계면E003", "수계면E005"]; - -export const hectare = (m2: number): string => `${(m2 / 10000).toFixed(1)} ha`; - /** 면 겹 채움 — 지물마다 한 길(evenodd = 구멍) · 화면 밖 지물 거름(돌린 지도 귀퉁이까지 여유). */ export function fillLayer( context: CanvasRenderingContext2D, @@ -79,14 +79,6 @@ export function fillLayer( context.setLineDash([]); } -export const loadImage = (src: string): Promise => - new Promise((done, fail) => { - const image = new Image(); - image.onload = () => done(image); - image.onerror = () => fail(new Error("그림을 읽지 못했습니다")); - image.src = src; - }); - /** 선 겹 긋기(점 솎음 `dot` · `everyM` = 등고 간격 거름). */ export function stroke( context: CanvasRenderingContext2D, @@ -123,41 +115,3 @@ export function drawRaster( ); context.drawImage(raster.image, 0, 0); } - -/** GL 자료 — 지물마다 그릴 색(`_color`) · 덧 속성을 붙인 사본. */ -export function glTagged( - collection: GeoJsonCollection, - color: (props: Record) => string, - extra: Record = {}, -): GeoJsonCollection { - return { - ...collection, - features: (collection.features ?? []).map((feature) => ({ - ...feature, - properties: { ...feature.properties, ...extra, _color: color(feature.properties ?? {}) }, - })), - }; -} - -/** GL 그림 자리 — 서버 경위도 귀퉁이(왼쪽 아래부터 돎) → 왼쪽 위부터 · 없으면 null. */ -export function glImage(raster: PlanRaster, url = raster.image): GlImage | null { - const box = raster.box; - return box?.length === 4 ? { url, corners: [box[3], box[2], box[1], box[0]] } : null; -} - -/** 음영 그늘 그림 — 위성 위 「곱하기 0.75」와 같은 검은 반투명(밝기 g → 투명도 0.75 · (1 − g)). */ -export function shadeImage(image: HTMLImageElement): string { - const canvas = document.createElement("canvas"); - canvas.width = image.naturalWidth; - canvas.height = image.naturalHeight; - const context = canvas.getContext("2d")!; - context.drawImage(image, 0, 0); - const pixels = context.getImageData(0, 0, canvas.width, canvas.height); - const px = pixels.data; - for (let i = 0; i < px.length; i += 4) { - px[i + 3] = Math.round(0.75 * (255 - px[i]) * (px[i + 3] / 255)); - px[i] = px[i + 1] = px[i + 2] = 0; - } - context.putImageData(pixels, 0, 0); - return canvas.toDataURL("image/png"); -} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts index 27658bfda..ca9057ee1 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts @@ -3,34 +3,23 @@ * D03 바탕 겹 받은 자료 꼴 · 범례 줄 · 겹별 수(PLAN 569-2 · 581 · 584-1 · 584-3) — 켠 겹의 받은 범위 안 수 · 넓이. * 급경사 = 고른 범위(603)마다 % · ° · 비율(1 % 칸 수로 바로 셈). * 범례 칸 · 조각 겹 범례 = 공용 `A00_Common/map/map_legend`(624-2) · 여기는 받은 자료 줄만. - * 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기). + * 범례 줄 셈 = 공용 `map_legend` `legendRows`(624-3) · 여기는 자료 꼴 · 겹별 수 · 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기). * ========================================================================== */ -import type { LegendItem } from "@ui/ui_template_flow_legend"; import type { PreparedLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { - PlanBand, PlanForestRoads, PlanLandslideZones, PlanRaster, PlanSoils, PlanZones, + PlanBand, } from "./D03_PlanAnalysis_Api"; -import { - LINE_KEYS, - LINE_LAYERS, - ZONE_COLORS, - type LineLayerKey, - type MapLayerKey, -} from "../A00_Common/map/map_layer_list"; -import { tileLegendRows } from "../A00_Common/map/map_legend"; -import { hectare } from "./D03_PlanAnalysis_UI_LayerDefs"; -import { - SLOPE_MAX_PCT, - bandRatios, - pctToDeg, - type SlopeBand, -} from "./logic/D03_PlanAnalysis_SlopeBands"; +import { LINE_KEYS, type LineLayerKey } from "../A00_Common/map/map_layer_list"; +import { bandRatios, type SlopeBand } from "../A00_Common/map/logic/map_slope_bands"; + +/** 범례 줄 = 공용(624-3 · B05 평면도 씀) — 옛 이름으로 다시 내놓음. */ +export { legendRows } from "../A00_Common/map/map_legend"; /** 선 겹 — 그릴 선 · 받은 범위 안 가닥 · 길이 m. */ export interface PreparedLines { @@ -69,133 +58,6 @@ export type Prepared = Record & { place: Array<{ lon: number; lat: number; name: string }>; }; -const km = (m: number): string => `${(m / 1000).toFixed(2)} km`; - -/** 급경사 범례 — 범위마다 「아래~위 %(°) · 비율」(위 = 끝 = 「↑」). */ -function slopeRow({ raster, bands }: Prepared["slope"]): LegendItem[] { - const ratios = bandRatios(raster.counts ?? [], bands); - const deg = (pct: number): string => pctToDeg(pct).toFixed(0); - return bands.map((band, i) => { - const open = band.high >= SLOPE_MAX_PCT; - const range = open - ? `${band.low} %↑(${deg(band.low)}°↑)` - : `${band.low}~${band.high} %(${deg(band.low)}~${deg(band.high)}°)`; - return { label: `${range} · ${(ratios[i] * 100).toFixed(1)} %`, color: `var(${band.color})` }; - }); -} - -/** 산사태취약지역 범례 — 점 · 필지 · 위치 없는 시도 · 표 없는 시도 · 지적 못 받음. */ -function landslideZoneRow({ info }: Prepared["landslideZones"]): LegendItem[] { - const color = "var(--draw-map-landslide-zone)"; - const items: LegendItem[] = [ - { - label: `산사태취약지역 점 ${info.points.length}곳`, - color, - kind: "dot", - off: !info.points.length, - }, - { - label: `지번 맞는 필지 ${info.parcels.features?.length ?? 0}곳(테두리)`, - color, - kind: "dash", - off: !info.parcels.features?.length, - }, - ]; - for (const sido of info.sidos) - if (!sido.located) items.push({ label: `${sido.name} 위치 없음 — 필지만`, color, off: true }); - for (const name of info.missing) - items.push({ label: `${name} — 이 시도 자료 없음`, color: "transparent", off: true }); - if (!info.cadastral) - items.push({ label: "지적도 못 받음 — 필지 테두리 없음", color: "transparent", off: true }); - return items; -} - -export function legendRows( - on: Record, - data: Partial, -): LegendItem[][] { - const rows: LegendItem[][] = []; - if (on.slope && data.slope) rows.push(slopeRow(data.slope)); - rows.push(...tileLegendRows(on)); // 조각 겹 = 받기 없음(622-2 · 공용 범례) - if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones)); - if (on.zones && data.zones) { - const items = Object.entries(data.zones.summary) - .filter(([, kind]) => kind.count || kind.failed) - .map(([key, kind]) => ({ - label: kind.failed - ? `${kind.label} 받지 못함` - : `${kind.label} ${kind.count}곳 ${hectare(kind.area_m2)}`, - color: `var(${ZONE_COLORS[key]})`, - off: !!kind.failed, - })); - rows.push( - items.length - ? items - : [{ label: "보호 · 규제 구역 없음(받은 범위 안)", color: "transparent", off: true }], - ); - } - if (on.soils && data.soils) { - const { kinds, summary } = data.soils; - rows.push( - summary.length - ? summary.map((soil, i) => ({ - label: `${soil.name} ${hectare(soil.area_m2)}`, - color: `var(${kinds[i].color})`, - })) - : [{ label: "산림입지도 없음(받은 범위 안)", color: "transparent", off: true }], - ); - } - if (on.bands && data.bands) { - const { stream, road } = data.bands.info; - rows.push([ - { - label: `계류 ${stream.dist_m} m 띠 ${hectare(stream.area_m2)} (${(stream.ratio * 100).toFixed(1)} %)`, - color: "var(--draw-map-band-stream)", - }, - { - label: `도로 ${road.dist_m} m 띠 ${hectare(road.area_m2)} (${(road.ratio * 100).toFixed(1)} %)`, - color: "var(--draw-map-band-road)", - }, - ]); - } - const lines = LINE_KEYS.flatMap((key) => { - const got = on[key] && data[key]; - if (!got) return []; - const { label, color, dash } = LINE_LAYERS[key]; - return [ - { - label: `${label} ${got.count}가닥 ${km(got.length_m)}`, - color: `var(${color})`, - kind: dash.length ? ("dash" as const) : ("line" as const), - off: !got.count, - }, - ]; - }); - if (lines.length) rows.push(lines); - const tail: LegendItem[] = []; - if (on.water && data.water) - tail.push({ label: `물 면 ${data.water.features.length}곳`, color: "var(--draw-map-water)" }); - if (on.building && data.building) - tail.push({ - label: `건물 ${data.building.features.length.toLocaleString()}동`, - color: "var(--draw-map-building)", - }); - if (on.forestRoad && data.road) - tail.push({ - label: `임도 짐작 ${data.road.small.features.length}가닥`, - color: "var(--draw-map-forest-road)", - kind: "dash", - }); - if (on.forestRoadFile && data.forestRoadFile) - tail.push({ - label: `올린 임도망도 ${data.forestRoadFile.info.count}가닥 ${km(data.forestRoadFile.info.length_m)}`, - color: "var(--draw-map-forest-road-file)", - kind: "line", - }); - if (tail.length) rows.push(tail); - return rows; -} - /** 검증 창구 — 겹별 수(지물 · 넓이 m² · 길이 m). */ export const layerCounts = (data: Partial): Record => ({ ...(data.building && { building: data.building.features.length }), diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index 476535e1b..c5767d7a7 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -68,7 +68,7 @@ import { import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend"; import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint"; import { createGlBase, glFont, type GlImage } from "./D03_PlanAnalysis_UI_MapGl"; -import { createSatLightRow, readSatLight } from "./D03_PlanAnalysis_UI_SatLight"; +import { createSatLightRow, readSatLight } from "../A00_Common/map/map_sat_light"; import { createSheetTiles, SHEET_TILE_NAMES, @@ -81,7 +81,7 @@ import { slopeGear, slopeValues, writeSlopeBands, -} from "./D03_PlanAnalysis_UI_SlopeBands"; +} from "../A00_Common/map/map_slope_bands"; import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; /** 옛 캔버스로 그림("1") — 처음 = GL. */ diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts index 1c501c919..5cc46a38d 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts @@ -28,7 +28,7 @@ import { type GlSource, } from "../A00_Common/map/map_layer_specs"; import type { PlanLayerKey, RoadPick } from "./D03_PlanAnalysis_UI_LayerDefs"; -import { SAT_LIGHT_DEFAULT } from "./D03_PlanAnalysis_UI_SatLight"; +import { SAT_LIGHT_DEFAULT } from "../A00_Common/map/map_sat_light"; export type { GlImage }; diff --git a/resources/tester/test_584_3_plan_forest_road_zones.py b/resources/tester/test_584_3_plan_forest_road_zones.py index 7586f991c..2aabae7fa 100644 --- a/resources/tester/test_584_3_plan_forest_road_zones.py +++ b/resources/tester/test_584_3_plan_forest_road_zones.py @@ -332,6 +332,7 @@ def test_screen_defs() -> None: ) # 586-2 캔버스 그리기 따로 page = (folder / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8") + paint legend = (folder / "D03_PlanAnalysis_UI_LayerLegend.ts").read_text("utf-8") + legend += (ROOT / "A00_Common" / "map" / "map_legend.ts").read_text("utf-8") # 624-3 범례 줄 공용 for key, color in ( ("forestRoadFile", "forest-road-file"), ("landslideZones", "landslide-zone"), diff --git a/resources/tester/test_603_plan_slope_bands.py b/resources/tester/test_603_plan_slope_bands.py index f6d1e0935..1ba956239 100644 --- a/resources/tester/test_603_plan_slope_bands.py +++ b/resources/tester/test_603_plan_slope_bands.py @@ -1,5 +1,5 @@ """D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 서버 1 % 칸 수 + -`logic/D03_PlanAnalysis_SlopeBands.ts`. +`A00_Common/map/logic/map_slope_bands.ts`(624-3 공용으로 옮김). 1 % 칸 수 합 = 유효 칸 · 기본 범위(26.8 · 70.0 · 83.9 %) 비율 = 옛 15 · 35 · 40° 비율 · 범위 막음(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음) · 깨진 취향 = 기본 · 250 % 이상 칸 = 끝 범위. @@ -22,7 +22,7 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers from E07_StabArea.E07_StabArea_Engine import slope_rad ROOT = Path(__file__).resolve().parents[2] -TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_SlopeBands.ts" +TS_FILE = ROOT / "A00_Common" / "map" / "logic" / "map_slope_bands.ts" TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" CELL = 2.0 OLD_DEG = ((15.0, 35.0), (35.0, 40.0), (40.0, 90.0)) # 옛 SLOPE_BANDS @@ -36,7 +36,7 @@ def _hills(n: int = 300) -> np.ndarray: _RUNNER = """ import { readFileSync, writeFileSync } from "node:fs"; -import * as bands from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.js"; +import * as bands from "./A00_Common/map/logic/map_slope_bands.js"; const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); const out = { DEFAULT: bands.DEFAULT_SLOPE_BANDS }; // 인자 null = 기본 범위 diff --git a/resources/tester/test_606_1_sat_light.py b/resources/tester/test_606_1_sat_light.py index c4f0de6e5..7d6dd8906 100644 --- a/resources/tester/test_606_1_sat_light.py +++ b/resources/tester/test_606_1_sat_light.py @@ -10,7 +10,7 @@ ROOT = Path(__file__).resolve().parents[2] read = lambda rel: (ROOT / rel).read_text(encoding="utf-8") # noqa: E731 MAPGL = read("A00_Common/map/map_layer_specs.ts") # 624-2 GL 겹 꼴 = 공용 -LIGHT = read("D03_PlanAnalysis/D03_PlanAnalysis_UI_SatLight.ts") +LIGHT = read("A00_Common/map/map_sat_light.ts") # 624-3 공용 LAYERS = read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") From a5179e4dfe9ac0b0927c73f664f8069f332b69b4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:58:41 +0900 Subject: [PATCH 17/18] =?UTF-8?q?fix(=EA=B3=B5=EC=9A=A9=20=EC=A7=80?= =?UTF-8?q?=ED=98=95):=20=EC=A0=84=EA=B5=AD=200=EC=B0=A8=EA=B3=A1=20?= =?UTF-8?q?=EC=9D=B4=EC=9B=83=20=EB=8F=84=EC=97=BD=20=EB=B0=9B=EA=B8=B0=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=2036=20=EA=B0=80=EB=A6=84=20=C2=B7=20?= =?UTF-8?q?=EB=A9=B4=20=EB=8F=84=EA=B3=BD=20=C2=B7=20zip=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EB=8F=84=EC=97=BD=20=C2=B7=20=EC=96=B4=EA=B8=8B?= =?UTF-8?q?=EB=82=9C=20=EB=8F=84=EA=B3=BD=20=3D=20=EB=B9=88=20=EC=A7=80?= =?UTF-8?q?=ED=98=95=20(PLAN=20628-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도곽 겹 N3A_H0010000(면) 도 읽음 — 섬 · 해안 작은 zip 은 선 도곽 · 도로 면 없이 면 도곽뿐 - Ver2.0 이 NGI · NDA 뿐인 도엽 = LookupError(zip 없음) — 옛 길은 NGI 글을 zip 으로 받다 로그인 실패로 오판 - 공용 지형 받기: ValueError(도곽이 번호 자리와 어긋남 = 제주 · 울릉 번호 체계) 도 빈 지형 · 로그인 · 망 실패는 그대로 실패 - 시험 test_628_3_sheet_fetch_fallback 새로 Co-Authored-By: Claude Opus 5.5 --- .../B04_PreProcess_Engine_SheetStore.py | 14 +++- common_util/common_util_terrain_store.py | 8 +- .../tester/test_628_3_sheet_fetch_fallback.py | 81 +++++++++++++++++++ 3 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 resources/tester/test_628_3_sheet_fetch_fallback.py diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetStore.py b/B04_PreProcess/B04_PreProcess_Engine_SheetStore.py index 5aab57a21..9c8bc3354 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetStore.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetStore.py @@ -39,8 +39,10 @@ from .B04_PreProcess_Engine_MapSheet import latlon_to_sheet5k, sheet5k_to_bounds logger = logging.getLogger(__name__) -# 도곽선 레이어 코드 (수치지형도 v2.0 도엽본) — 도곽선 N3L_H0010000 이 없는 zip 은 옛 A0010000(도로 면)로 대신 +# 도곽선 레이어 코드 (수치지형도 v2.0 도엽본) — 선 N3L_H0010000 · 면 N3A_H0010000(섬 · 해안 작은 도엽) +# · 둘 다 없는 zip 은 옛 A0010000(도로 면)로 대신 _SHEET_FRAME_CODE = "N3L_H0010000" +_SHEET_FRAME_AREA_CODE = "N3A_H0010000" _SHEET_FRAME_FALLBACK_CODE = "A0010000" _SHEET_NO_RE = re.compile(r"(\d{8})") _SHARED_DIRNAME = "_shared" # storage/_shared 아래 = 여러 프로젝트가 같이 쓰는 창고 @@ -153,7 +155,11 @@ def download_sheet(store_dir: str | Path, sheet_no: str, version: int = 2) -> di rows = search_sheet_files(sheet_no, version=version) if not rows: raise LookupError(f"도엽 {sheet_no}: 브이월드 검색 결과 없음") - row = rows[0] + # Ver2.0 이 zip 아닌 NGI · NDA 뿐인 도엽(작은 섬) — 받을 zip 없음 + row = next((r for r in rows if str(r.get("file_nm", "")).lower().endswith(".zip")), None) + if row is None: + kinds = ", ".join(sorted({str(r.get("file_extsn", "")) for r in rows})) + raise LookupError(f"도엽 {sheet_no}: Ver2.0 zip 없음({kinds})") cookie = _session_cookie_cache or VWORLD_SESSION_COOKIE payload = None @@ -318,7 +324,7 @@ def ingest_zip(store_dir: str | Path, src_path: str | Path, sheet_no: str | None def _read_frame_bounds_wgs84(zip_path: Path) -> tuple[tuple[float, float, float, float], str]: - """zip 내 도곽선(N3L_H0010000 · 없으면 A0010000) SHP bounds를 WGS84로 반환. (bounds, 사용된 CRS). + """zip 내 도곽선(N3L_H0010000 · N3A_H0010000 · 없으면 A0010000) SHP bounds를 WGS84로 반환. (bounds, 사용된 CRS). 선언된 PRJ가 오라벨(중부/동부원점 뒤바뀜)인 실측 사례가 있어, 파일명 도엽번호 기준 예측 도곽과 대조해 정합한 CRS를 자동 판별한다. @@ -328,7 +334,7 @@ def _read_frame_bounds_wgs84(zip_path: Path) -> tuple[tuple[float, float, float, with zipfile.ZipFile(zip_path) as zf: names = zf.namelist() shp: list[str] = [] - for code in (_SHEET_FRAME_CODE, _SHEET_FRAME_FALLBACK_CODE): + for code in (_SHEET_FRAME_CODE, _SHEET_FRAME_AREA_CODE, _SHEET_FRAME_FALLBACK_CODE): shp = [n for n in names if n.upper().endswith(f"{code}.SHP")] if shp: break diff --git a/common_util/common_util_terrain_store.py b/common_util/common_util_terrain_store.py index b4302f407..acb7ce59b 100644 --- a/common_util/common_util_terrain_store.py +++ b/common_util/common_util_terrain_store.py @@ -273,8 +273,12 @@ def _fetch_zips(sheet_no: str, report: Callable[[int, str], None]) -> tuple[dict zips[no] = _sha256(TERRAIN_ROOT / no / f"{no}.zip") elif no == sheet_no: reason = result["failed"].get(no, "") - # 검색 결과 없음 = 바다 등 진짜 빈 도엽 · 그 밖(로그인 · 망)은 다음에 다시 - return zips, None if reason.startswith("LookupError") else reason + # 검색 결과 · zip 없음 = 바다 · 작은 섬 · zip 꼴 흠(ValueError — 도곽이 번호 자리와 어긋남 = + # 제주 · 울릉 등 다른 번호 체계) = 그 자리 등고선 없음 · 그 밖(로그인 · 망)은 다음에 다시 + if reason.startswith(("LookupError", "ValueError")): + logger.warning("도엽 %s zip 못 씀 — 빈 지형: %s", no, reason[:200]) + return zips, None + return zips, reason return zips, None diff --git a/resources/tester/test_628_3_sheet_fetch_fallback.py b/resources/tester/test_628_3_sheet_fetch_fallback.py new file mode 100644 index 000000000..8c2323fba --- /dev/null +++ b/resources/tester/test_628_3_sheet_fetch_fallback.py @@ -0,0 +1,81 @@ +"""628-3 — 전국 0차곡 이웃 도엽 받기 실패 셋: 면 도곽(N3A_H0010000) · zip 없는 도엽(NGI 뿐) · +도곽이 번호 자리와 어긋난 zip(제주 · 울릉) = 빈 지형 · 로그인 · 망 실패는 그대로 실패.""" + +import sys +import tempfile +import zipfile +from pathlib import Path + +import geopandas as gpd +import pytest +from shapely.geometry import box + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from B04_PreProcess import B04_PreProcess_Engine_SheetStore as sheet_store # noqa: E402 +from B04_PreProcess.B04_PreProcess_Engine_MapSheet import sheet5k_to_bounds # noqa: E402 +from common_util import common_util_terrain_store as store # noqa: E402 + +SHEET = "34616006" +CRS = "EPSG:5186" + + +def _make_zip(path: Path, code: str, bb: tuple[float, float, float, float]) -> None: + with tempfile.TemporaryDirectory() as td, zipfile.ZipFile(path, "w") as zf: + gdf = gpd.GeoDataFrame(geometry=[box(*bb)], crs="EPSG:4326").to_crs(CRS) + gdf.to_file(Path(td) / f"{code}.shp") + for f in Path(td).glob(f"{code}.*"): + zf.write(f, f.name) + + +def test_면_도곽만_있는_섬_도엽_받기_통과(tmp_path): + z = tmp_path / f"{SHEET}.zip" + _make_zip(z, "N3A_H0010000", sheet5k_to_bounds(SHEET)) + entry = sheet_store.ingest_zip(tmp_path / "store", z, sheet_no=SHEET) + for got, want in zip(entry["bounds_wgs84"], sheet5k_to_bounds(SHEET)): + assert got == pytest.approx(want, abs=1e-6) + + +def test_도곽이_번호_자리와_어긋나면_ValueError(tmp_path): + w, s, e, n = sheet5k_to_bounds(SHEET) + z = tmp_path / f"{SHEET}.zip" + _make_zip(z, "N3A_H0010000", (w, s - 0.1, e, n - 0.1)) # 제주식 0.1° 어긋남 + with pytest.raises(ValueError, match="정합하지 않음"): + sheet_store.ingest_zip(tmp_path / "store", z, sheet_no=SHEET) + + +def test_zip_없는_도엽은_LookupError_받기_안_함(tmp_path, monkeypatch): + rows = [ + {"ds_sq": 1, "file_no": 1, "file_nm": f"{SHEET}.nda", "file_extsn": "nda"}, + {"ds_sq": 1, "file_no": 2, "file_nm": f"{SHEET}.ngi", "file_extsn": "ngi"}, + ] + monkeypatch.setattr(sheet_store, "search_sheet_files", lambda no, version=2: rows) + + def no_fetch(*_a, **_k): + raise AssertionError("받기 안 함") + + monkeypatch.setattr(sheet_store, "_fetch_sheet_payload", no_fetch) + monkeypatch.setattr(sheet_store, "vworld_login", no_fetch) + with pytest.raises(LookupError, match="zip 없음"): + sheet_store.download_sheet(tmp_path, SHEET) + result = sheet_store.ensure_sheets(tmp_path, [SHEET]) + assert result["failed"][SHEET].startswith("LookupError") + + +@pytest.mark.parametrize( + ("reason", "empty"), + [ + ("LookupError: 검색 결과 없음", True), + ("LookupError: Ver2.0 zip 없음(nda, ngi)", True), + ("ValueError: _dl_x.zip: 어떤 좌표계 후보로도 도엽번호 도곽과 정합하지 않음", True), + ("PermissionError: 재로그인 후에도 실패", False), + ("URLError: timed out", False), + ], +) +def test_가운데_도엽_받기_실패_가름(monkeypatch, reason, empty): + monkeypatch.setattr( + sheet_store, "ensure_sheets", lambda d, nos: {"available": {}, "failed": {nos[0]: reason}} + ) + zips, failure = store._fetch_zips(SHEET, lambda p, s: None) + assert zips == {} + assert failure == (None if empty else reason) From 472bf98ba2dbc35b05262b0e2a4ab6a51c1efad3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 21:27:40 +0900 Subject: [PATCH 18/18] =?UTF-8?q?feat(B09=20=EC=9B=90=EA=B0=80=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=84=9C=20=C2=B7=20708-2):=20=ED=98=84=ED=96=89=20?= =?UTF-8?q?=EB=B9=84=EB=AA=A9=20=EB=8B=A4=EC=84=AF=20=EC=85=88=20=E2=80=94?= =?UTF-8?q?=20=ED=87=B4=EC=A7=81=EA=B3=B5=EC=A0=9C=20=C2=B7=20=EC=9E=84?= =?UTF-8?q?=EA=B8=88=EC=B1=84=EA=B6=8C=20=C2=B7=20=EC=84=9D=EB=A9=B4=20?= =?UTF-8?q?=C2=B7=20=ED=95=98=EB=8F=84=EA=B8=89=20=EC=9A=94=EC=9C=A8=20?= =?UTF-8?q?=EC=85=88=20=C2=B7=20=EA=B3=B5=EC=82=AC=EC=9D=B4=ED=96=89?= =?UTF-8?q?=EB=B3=B4=EC=A6=9D=20=EA=B8=88=EC=95=A1=20=EC=86=90=EC=9E=85?= =?UTF-8?q?=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 셈 짝(Calc_Total py ↔ ts) — STmate 일반형식 식 · 자리(지식DB 12 · 35 분석) · 경비 묶음 안 · 나.소계에 듦 퇴직공제부금비 = 직접노무비 × 율 · 임금채권 · 석면 = (직노 + 간노) × 율 · 하도급 = 순공사비 × 율 · 공사이행보증 = 금액 직접 입력(performance_fee) · 자동 요율 없음 · 요율 빈칸 = 해당 없음(0) - 요율 출처 「불러옴」 = rates_imported(707-2 불러오기가 실음 · 요율 고치면 사용자) - 707-2 불러오기 — 「셈 없음」 알림 뺌 · 공사이행보증 줄 금액 = 손입력 칸 - 총괄 · 원가계산서 탭 · 인쇄 미리보기 = 같은 줄 짜기 - 낡은 시험 고침 — 현행 비목 기대값 빈칸 → 0 · 퇴직공제 요율 칸 고침 가능 · 거창 총공사비 = 엑셀 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Calc_Total.py | 41 +++++++++---- B09_Estimation/B09_Estimation_Calc_Total.ts | 44 +++++++------ B09_Estimation/B09_Estimation_Import_Rates.py | 18 +++--- .../B09_Estimation_Router_Import.py | 1 + B09_Estimation/B09_Estimation_Store.py | 4 ++ .../B09_Estimation_UI_Tabs_Total.ts | 61 +++++++++++-------- resources/tester/test_145_4_b09_total_calc.py | 55 ++++++++++++++++- resources/tester/test_166_1_b09_rate_auto.py | 4 +- .../test_707_b09_import_rates_prices.py | 12 ++-- 9 files changed, 161 insertions(+), 79 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Calc_Total.py b/B09_Estimation/B09_Estimation_Calc_Total.py index b54e624a1..de6deb648 100644 --- a/B09_Estimation/B09_Estimation_Calc_Total.py +++ b/B09_Estimation/B09_Estimation_Calc_Total.py @@ -19,7 +19,14 @@ 산업안전보건관리비 = A(관급 순자재대 ÷ 1.1 포함) · B(관급 빼고 × 1.2) 중 작은 쪽 · 이윤 = ROUNDDOWN((다 − 재료비) × 율, 0) − 보정 · 부가세 = ROUNDDOWN(공급가액 × 율, 0) + 조정. 빈칸 규칙 = 요율(사용자 · 자동 둘 다 없음) · 비목 합이 빈칸이면 그 줄 빈칸(0 을 채우지 않음) · 그 줄을 쓰는 소계도 빈칸 · -손입력(기초액 · 보정 · 조정 · 간접재료비 · 작업설) 빈칸 = 0 · 엑셀에 없는 현행 비목 = 줄만(늘 빈칸). +손입력(기초액 · 보정 · 조정 · 간접재료비 · 작업설) 빈칸 = 0. +현행 비목 다섯(PLAN 708-2 · 엑셀 총괄에 없음 · 경쟁사 둘 다 셈) = STmate 일반형식 식 · 자리 + (지식DB `STmate 분석/20_분석/12_원가계산_4형식과_RATE표_복원.md` 일반 · 수공 경비 차례 · + `35_형식과_단계의_공통과_차이.md` 밑수 표) — 경비 묶음 안 · 나.소계(순공사원가)에 듦 · + 퇴직공제부금비 = 직접노무비 × 율 · 임금채권 · 석면 = (직접노무비 + 간접노무비) × 율 · + 하도급대금지급보증수수료 = 순공사비 × 율 · 공사이행보증수수료 = 금액 손입력(`performance_fee` · + STmate 식 = 공종 · 기간 표라 뒤로) · 자동 요율 없음 · 요율 빈칸 = 해당 없음(0 · 빈칸 아님). +요율 출처 = 사용자 값 중 `rates_imported` 키 = 「import」(707-2 불러옴). """ from __future__ import annotations @@ -29,7 +36,8 @@ from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown, roundup, total from common_util.common_util_time import kst_today -#: 제비율 줄 (키, 이름, M01 요율 후보 키, 산식 글) — 엑셀 총괄 49~58 차례 · 나.소계에 듦 +#: 제비율 줄 (키, 이름, M01 요율 후보 키, 산식 글) — 엑셀 총괄 49~58 차례 + +#: 현행 비목(STmate 경비 차례) · 나.소계에 듦 RATE_LINES: tuple[tuple[str, str, str, str], ...] = ( ("indirect_labor", "간접노무비", "RT000008", "직접노무비 × 율"), ("accident", "산재보험료", "RT000001", "(직접노무비 + 간접노무비) × 율"), @@ -37,19 +45,18 @@ RATE_LINES: tuple[tuple[str, str, str, str], ...] = ( ("health", "건강보험료", "RT000003", "직접노무비 × 율"), ("care", "노인장기요양보험료", "RT000005", "건강보험료 × 율"), ("pension", "연금보험료", "RT000004", "직접노무비 × 율"), + ("retirement", "퇴직공제부금비", "RT000011", "직접노무비 × 율"), ("safety", "산업안전보건관리비", "RT000006", "A · B 중 작은 쪽 + 기초액"), ("other", "기타경비", "RT000009", "(직접노무비 + 간접노무비 + 재료비) × 율"), + ("performance", "공사이행보증수수료", "RT000016", "금액 직접 입력"), + ("subcontract", "하도급대금지급보증수수료", "RT000014", "순공사비 × 율"), ("environment", "환경보전비", "RT000010", "순공사비 × 율"), ("machine", "건설기계대여금지급보증", "RT000015", "순공사비 × 율"), + ("asbestos", "석면피해구제분담금", "RT000013", "(직접노무비 + 간접노무비) × 율"), + ("wage_claim", "임금채권보장분담금", "RT000012", "(직접노무비 + 간접노무비) × 율"), ) -#: 엑셀에 없는 현행 비목 — 줄만 두고 빈칸(밑수 · 고르는 값 미정 · 나.소계에 안 듦) -CURRENT_ONLY: tuple[tuple[str, str, str], ...] = ( - ("retirement", "퇴직공제부금비", "RT000011"), - ("wage_claim", "임금채권보장분담금", "RT000012"), - ("asbestos", "석면피해구제분담금", "RT000013"), - ("subcontract", "하도급대금지급보증수수료", "RT000014"), - ("performance", "공사이행보증수수료", "RT000016"), -) +#: 현행 비목 — 요율 빈칸 = 해당 없음(0) · 공사이행보증 = 금액 손입력(율 칸 없음) +CURRENT_ONLY = ("retirement", "subcontract", "asbestos", "wage_claim", "performance") #: 소계 아래 줄 (키, 이름, 후보 키, 산식 글) TAIL_LINES: tuple[tuple[str, str, str, str], ...] = ( ("overhead", "일반관리비", "RT000017", "나.소계 × 율"), @@ -135,6 +142,7 @@ def compute_total( sums = sums if isinstance(sums, dict) else {} est = estimate if isinstance(estimate, dict) else {} rates_in = est.get("rates") if isinstance(est.get("rates"), dict) else {} + imported = est.get("rates_imported") if isinstance(est.get("rates_imported"), list) else [] def hand(key: str) -> float: return num(est.get(key)) or 0.0 @@ -164,7 +172,7 @@ def compute_total( mine, got = num(rates_in.get(key)), auto.get(key) or {} rates[key] = { "rate": got.get("rate") if mine is None else mine, - "from": "user" + "from": ("import" if key in imported else "user") if mine is not None else ("auto" if got.get("rate") is not None else None), "auto": got.get("rate"), @@ -193,6 +201,14 @@ def compute_total( line["other"] = _pct(total([labor, line["indirect_labor"], material]), rate["other"]) line["environment"] = _pct(direct, rate["environment"]) line["machine"] = _pct(direct, rate["machine"]) + line["retirement"] = _pct(labor, rate["retirement"]) + line["subcontract"] = _pct(direct, rate["subcontract"]) + line["asbestos"] = _pct(total_labor, rate["asbestos"]) + line["wage_claim"] = _pct(total_labor, rate["wage_claim"]) + for key in CURRENT_ONLY: # 요율 빈칸 = 해당 없음 + if rate[key] is None: + line[key] = 0.0 + line["performance"] = hand("performance_fee") net_cost = total([direct, *(line[key] for key, *_ in RATE_LINES)]) # 나. 순공사원가 line["overhead"] = _pct(net_cost, rate["overhead"]) da = total([net_cost, line["overhead"]]) # 다 @@ -220,8 +236,7 @@ def compute_total( "indirect_labor": line["indirect_labor"], "labor": total_labor, "expense": expense, - **{key: line[key] for key in ("accident", "employment", "health", "care", "pension")}, - **{key: line[key] for key in ("safety", "other", "environment", "machine")}, + **{key: line[key] for key, *_ in RATE_LINES[1:]}, "expense_sum": total( [expense, *(line[key] for key, *_ in RATE_LINES if key != "indirect_labor")] ), diff --git a/B09_Estimation/B09_Estimation_Calc_Total.ts b/B09_Estimation/B09_Estimation_Calc_Total.ts index 564c05429..dfe5bad3b 100644 --- a/B09_Estimation/B09_Estimation_Calc_Total.ts +++ b/B09_Estimation/B09_Estimation_Calc_Total.ts @@ -9,13 +9,14 @@ * 입력 sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`) · estimate = 사용자 값(`user.estimate`) · * table = M01 요율 압축 표(payload `rate_table`) · year = 연금 해 — 자동 고르기(166-1) = 파이썬 짝 머리 글 * 식 · 끝수 = 엑셀 총괄 49~67 그대로 · 빈칸 규칙 = 파이썬 짝 머리 글 그대로. + * 현행 비목 다섯(708-2) = STmate 일반형식 식 · 자리(경비 묶음 안 · 나.소계에 듦) — 근거 = 파이썬 짝 머리 글. * ========================================================================== */ import { num, rounddown, roundup, total, type Row } from "./B09_Estimation_Calc_Base"; type Num = number | null; -/** 제비율 줄 [키, 이름, M01 요율 후보 키, 산식 글] — 엑셀 총괄 49~58 차례 · 나.소계에 듦 */ +/** 제비율 줄 [키, 이름, M01 요율 후보 키, 산식 글] — 엑셀 총괄 49~58 차례 + 현행 비목 · 나.소계에 듦 */ export const RATE_LINES: Array<[string, string, string, string]> = [ ["indirect_labor", "간접노무비", "RT000008", "직접노무비 × 율"], ["accident", "산재보험료", "RT000001", "(직접노무비 + 간접노무비) × 율"], @@ -23,19 +24,18 @@ export const RATE_LINES: Array<[string, string, string, string]> = [ ["health", "건강보험료", "RT000003", "직접노무비 × 율"], ["care", "노인장기요양보험료", "RT000005", "건강보험료 × 율"], ["pension", "연금보험료", "RT000004", "직접노무비 × 율"], + ["retirement", "퇴직공제부금비", "RT000011", "직접노무비 × 율"], ["safety", "산업안전보건관리비", "RT000006", "A · B 중 작은 쪽 + 기초액"], ["other", "기타경비", "RT000009", "(직접노무비 + 간접노무비 + 재료비) × 율"], + ["performance", "공사이행보증수수료", "RT000016", "금액 직접 입력"], + ["subcontract", "하도급대금지급보증수수료", "RT000014", "순공사비 × 율"], ["environment", "환경보전비", "RT000010", "순공사비 × 율"], ["machine", "건설기계대여금지급보증", "RT000015", "순공사비 × 율"], + ["asbestos", "석면피해구제분담금", "RT000013", "(직접노무비 + 간접노무비) × 율"], + ["wage_claim", "임금채권보장분담금", "RT000012", "(직접노무비 + 간접노무비) × 율"], ]; -/** 엑셀에 없는 현행 비목 — 줄만 두고 빈칸 */ -export const CURRENT_ONLY: Array<[string, string, string]> = [ - ["retirement", "퇴직공제부금비", "RT000011"], - ["wage_claim", "임금채권보장분담금", "RT000012"], - ["asbestos", "석면피해구제분담금", "RT000013"], - ["subcontract", "하도급대금지급보증수수료", "RT000014"], - ["performance", "공사이행보증수수료", "RT000016"], -]; +/** 현행 비목 — 요율 빈칸 = 해당 없음(0) · 공사이행보증 = 금액 손입력(율 칸 없음) */ +export const CURRENT_ONLY = ["retirement", "subcontract", "asbestos", "wage_claim", "performance"]; /** 소계 아래 줄 */ export const TAIL_LINES: Array<[string, string, string, string]> = [ ["overhead", "일반관리비", "RT000017", "나.소계 × 율"], @@ -113,6 +113,7 @@ export function computeTotal( const s: Row = sums && typeof sums === "object" ? sums : {}; const est: Row = estimate && typeof estimate === "object" ? estimate : {}; const ratesIn: Row = est.rates && typeof est.rates === "object" ? est.rates : {}; + const imported: unknown[] = Array.isArray(est.rates_imported) ? est.rates_imported : []; const hand = (key: string): number => num(est[key]) || 0; const material = num(s.material); @@ -138,7 +139,14 @@ export function computeTotal( const autoRate: Num = got.rate ?? null; rates[key] = { rate: mine === null ? autoRate : mine, - from: mine !== null ? "user" : autoRate !== null ? "auto" : null, + from: + mine !== null + ? imported.includes(key) + ? "import" + : "user" + : autoRate !== null + ? "auto" + : null, auto: autoRate, why: got.why || "", }; @@ -168,6 +176,12 @@ export function computeTotal( line.other = pct(total([labor, line.indirect_labor, material]), rate.other); line.environment = pct(direct, rate.environment); line.machine = pct(direct, rate.machine); + line.retirement = pct(labor, rate.retirement); + line.subcontract = pct(direct, rate.subcontract); + line.asbestos = pct(totalLabor, rate.asbestos); + line.wage_claim = pct(totalLabor, rate.wage_claim); + for (const key of CURRENT_ONLY) if (rate[key] === null) line[key] = 0; // 요율 빈칸 = 해당 없음 + line.performance = hand("performance_fee"); const netCost = total([direct, ...RATE_LINES.map(([key]) => line[key])]); line.overhead = pct(netCost, rate.overhead); const da = total([netCost, line.overhead]); @@ -193,15 +207,7 @@ export function computeTotal( indirect_labor: line.indirect_labor, labor: totalLabor, expense, - accident: line.accident, - employment: line.employment, - health: line.health, - care: line.care, - pension: line.pension, - safety: line.safety, - other: line.other, - environment: line.environment, - machine: line.machine, + ...Object.fromEntries(RATE_LINES.slice(1).map(([key]) => [key, line[key]])), expense_sum: total([ expense, ...RATE_LINES.filter(([key]) => key !== "indirect_labor").map(([key]) => line[key]), diff --git a/B09_Estimation/B09_Estimation_Import_Rates.py b/B09_Estimation/B09_Estimation_Import_Rates.py index 014687a04..829b2bf92 100644 --- a/B09_Estimation/B09_Estimation_Import_Rates.py +++ b/B09_Estimation/B09_Estimation_Import_Rates.py @@ -12,11 +12,11 @@ indirect_material · scrap = 그 줄 금액 · profit_adjust = ROUNDDOWN((순공사원가 − 재료비 + 일반관리비) × 율) − 엑셀 이윤 · vat_adjust = 엑셀 부가세 − ROUNDDOWN(총원가 × 율) · - procurement_fee_pct = 관급 조달수수료 ÷ 순자재대(소수 2~4 자리 중 수수료를 그대로 내는 첫 값). + procurement_fee_pct = 관급 조달수수료 ÷ 순자재대(소수 2~4 자리 중 수수료를 그대로 내는 첫 값) · + performance_fee = 공사이행보증수수료 줄 금액(708-2 · 율 셈 없음). 대조(`check`) = 엑셀 비목 합 + 실은 값으로 우리 식(`compute_total`)을 돌려 줄마다 엑셀 금액과 견줌 — 1 원 넘게 다른 줄 = 「식 다름」(노인장기요양 · 산업안전보건관리비 A/B 등 식 꼴이 다른 비목) · - 우리 표에 없는 행(폐기물처리 · 국가지점번호판 …) · 셈 없는 현행 비목(퇴직공제부금비 …) - = 「짝 없음」 · 「셈 없음」. + 우리 표에 없는 행(폐기물처리 · 국가지점번호판 …) = 「짝 없음」. """ from __future__ import annotations @@ -27,7 +27,6 @@ from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown from B09_Estimation.B09_Estimation_Calc_Total import ( - CURRENT_ONLY, RATE_LINES, TAIL_LINES, compute_total, @@ -68,7 +67,6 @@ ROWS: tuple[tuple[str, str], ...] = ( ("소계", "subtotal"), ) RATE_KEYS = {key: (name, rt) for key, name, rt, _ in (*RATE_LINES, *TAIL_LINES)} -NO_CALC = {key: (name, rt) for key, name, rt in CURRENT_ONLY} _PCT = re.compile(r"[x×*]\s*([\d.]+)\s*%") _BASE = re.compile(r"%\s*\+\s*([\d,]+)") _HANGUL = re.compile(r"[가-힣]") @@ -204,16 +202,14 @@ def read_rates(name: str, data: bytes) -> dict[str, Any]: excel[key] = min(excel[key], amount or excel[key]) continue excel[key] = amount if amount is not None else excel.get(key, 0.0) - if key in RATE_KEYS or key in NO_CALC: + if key == "performance": # 공사이행보증 = 금액 손입력(율 셈 없음) + if amount: + estimate["performance_fee"] = amount + elif key in RATE_KEYS: if row["rate"] is not None: rates[key] = row["rate"] elif amount: notes.append(f"율 없음 — 「{row['name']}」 산출근거 빈칸 · 지금 율 그대로") - if key in NO_CALC and amount: - notes.append( - f"셈 없음 — 「{row['name']}」 {amount:,.0f} 원 · 율 {row['rate']}% 만 실음" - "(우리 원가계산서 줄만 · 총액에 안 듦)" - ) if key == "safety" and row["text"]: got = _BASE.search(row["text"]) estimate["safety_base"] = float(got.group(1).replace(",", "")) if got else 0.0 diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py index 1bce2e96e..943bf58f9 100644 --- a/B09_Estimation/B09_Estimation_Router_Import.py +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -350,6 +350,7 @@ def _rate_patch(name: str, data: bytes, mine: dict[str, Any]) -> dict[str, Any]: raise HTTPException(400, str(error)) from error check = import_rates.check(read, mine, rate_table()) estimate = {**mine, **read["estimate"], "rates": {**(mine.get("rates") or {}), **read["rates"]}} + estimate["rates_imported"] = sorted({*(mine.get("rates_imported") or []), *read["rates"]}) keep = ("kind", "sheet", "rows", "rates", "notes") return {**{k: read[k] for k in keep}, "check": check, "patch": {"estimate": estimate}} diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 12026ad0b..88f1a2b34 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -62,6 +62,7 @@ ESTIMATE_FIELDS = ( "indirect_material", # 간접재료비(원) "scrap", # 작업설 부산물(원 · 빼는 값) "procurement_fee_pct", # 조달수수료율(%) + "performance_fee", # 공사이행보증수수료(원 · 708-2) ) TEXT_FIELDS = ("work_type", "environment_kind") @@ -345,6 +346,9 @@ def clean_user(raw: Any) -> dict[str, Any]: rates = given.get("rates") if isinstance(rates, dict): estimate["rates"] = {str(k): _num(v) for k, v in rates.items()} + imported = given.get("rates_imported") # 707-2 불러온 요율 줄 키(출처 「불러옴」) + if isinstance(imported, list): + estimate["rates_imported"] = [str(k) for k in imported] bill = clean_bill(raw.get("bill")) out = {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})} if cover := clean_cover(raw.get("daega_cover")): diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index 0c761f055..e84107e8b 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -7,7 +7,8 @@ * 맨 위 = 요율 고르는 값(추정가격 · 공사기간 · 환경보전비 공종 — 누르면 도로 ↔ 그 밖)(166-1) * 요율 = 자동(M01 조달청 제비율 · 짝 셈이 고름) · 사용자가 적으면 앞섬 · 출처 칸 누름 = 자동으로 * 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) · - * 엑셀에 없는 현행 비목 = 줄만(빈칸) + * 현행 비목(708-2) = 경비 묶음 안 · 요율 빈칸 = 해당 없음(0) · 공사이행보증 = 금액 손입력 · + * 요율 출처 = 사용자 · 불러옴(707-2 `rates_imported`) — 요율을 고치면 사용자 * 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로 * 레벨(185-1 · 201-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) → * 공용 표 level 옵션(명칭 칸 들여쓰기 · 보기 전용) · 1단 = 굵게(is-title) @@ -42,8 +43,22 @@ const HAND: Record = { supply: ["procurement_fee_pct", "조달수수료율(%)"], indirect_material: ["indirect_material", "간접재료비(원)"], scrap: ["scrap", "작업설 부산물(원)"], + performance: ["performance_fee", "공사이행보증수수료(원)"], }; -const RATE_KEYS = new Set([...RATE_LINES, ...TAIL_LINES].map(([key]) => key)); +/** 요율 칸을 고치는 줄 — 공사이행보증 = 금액 손입력(율 칸 없음) */ +const RATE_KEYS = new Set( + [...RATE_LINES, ...TAIL_LINES].map(([key]) => key).filter((key) => key !== "performance"), +); + +/** 요율 줄 사용자 값 고침 · 지움 — 불러옴 표시도 뗌 */ +function setRate(estimate: Row, key: string, v: number | null): void { + const rates: Row = { ...(estimate.rates || {}) }; + if (v === null) delete rates[key]; + else rates[key] = v; + estimate.rates = rates; + if (Array.isArray(estimate.rates_imported)) + estimate.rates_imported = estimate.rates_imported.filter((k: string) => k !== key); +} const ENV_NAME: Record = { road: "도로", other: "그 밖의 토목" }; @@ -52,12 +67,8 @@ function editEstimate(ctx: TabContext, rowKey: string, column: string, value: st const v = parseNumber(value); if (v === undefined) return; const estimate: Row = { ...(ctx.user.estimate || {}) }; - if (column === "rate" && RATE_KEYS.has(rowKey)) { - const rates: Row = { ...(estimate.rates || {}) }; - if (v === null) delete rates[rowKey]; - else rates[rowKey] = v; - estimate.rates = rates; - } else if (HAND[rowKey]) { + if (column === "rate" && RATE_KEYS.has(rowKey)) setRate(estimate, rowKey, v); + else if (HAND[rowKey]) { const field = HAND[rowKey][0]; if (v === null) delete estimate[field]; else estimate[field] = v; @@ -71,11 +82,9 @@ function clickEstimate(ctx: TabContext, rowKey: string, column: string, from: un if (rowKey === "in_env") { if (estimate.environment_kind === "road") delete estimate.environment_kind; else estimate.environment_kind = "road"; - } else if (column === "candidate" && from === "user") { - const rates: Row = { ...(estimate.rates || {}) }; - delete rates[rowKey]; - estimate.rates = rates; - } else return; + } else if (column === "candidate" && (from === "user" || from === "import")) + setRate(estimate, rowKey, null); + else return; ctx.setUser({ ...ctx.user, estimate }); } @@ -122,8 +131,13 @@ function billRows(bill: Row | undefined): DataRow[] { ]; } -function rateText(formula: string, rate: Num): string { - return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`); +function rateText(formula: string, rate: Num, key = ""): string { + if (key === "performance") return formula; + if (rate === null) + return CURRENT_ONLY.includes(key) + ? `해당 없음(요율 빈칸) — ${formula}` + : `${formula}(요율 빈칸)`; + return formula.replace("율", `${rate}%`); } export const buildTotal: TabBuild = (ctx): TabTable => { @@ -137,8 +151,10 @@ export const buildTotal: TabBuild = (ctx): TabTable => { /** 출처 칸 글 — 사용자 · 자동 까닭 · 자동 없음 */ const source = (key: string): string => { const r: Row = picked[key] || {}; - if (r.from === "user") - return `사용자 값 · 누르면 자동${r.auto === null || r.auto === undefined ? "(없음)" : ` ${r.auto}%`}으로`; + if (key === "performance") return "금액 직접 입력(율 셈 없음)"; + if (r.from === "user" || r.from === "import") + return `${r.from === "user" ? "사용자" : "불러옴"} 값 · 누르면 자동${r.auto === null || r.auto === undefined ? "(없음)" : ` ${r.auto}%`}으로`; + if (CURRENT_ONLY.includes(key)) return "사용자 · 불러옴만(자동 없음)"; return r.from === "auto" ? `자동 — ${r.why}` : `자동 없음${r.why ? ` — ${r.why}` : ""}`; }; const hand = (key: string): Num => { @@ -165,7 +181,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { ...extra, }); const rateRow = ([key, name, rt, formula]: [string, string, string, string], no: number) => - row(key, "", `${no}. ${name}`, line[key] ?? null, rateText(formula, rate(key)), { + row(key, "", `${no}. ${name}`, line[key] ?? null, rateText(formula, rate(key), key), { rate: rate(key), candidate: source(key), from: picked[key]?.from ?? null, @@ -204,11 +220,6 @@ export const buildTotal: TabBuild = (ctx): TabTable => { expense: sums.expense ?? null, }), ...RATE_LINES.map((spec, i) => rateRow(spec, i + 1)), - ...CURRENT_ONLY.map(([key, name, rt]) => - row(key, "", name, null, "현행 비목 · 엑셀에 없음 — 밑수 · 고르는 값 미정(줄만)", { - candidate: cand[rt] ?? rt, - }), - ), row("net_cost", "나.", "소계(순공사원가)", sub.net_cost ?? null), rateRow(TAIL_LINES[0], RATE_LINES.length + 1), row("da", "다.", "소계", sub.da ?? null), @@ -302,7 +313,7 @@ export const buildCost: TabBuild = (ctx): TabTable => { const formula: Record = Object.fromEntries( [...RATE_LINES, ...TAIL_LINES].map(([key, , , f]) => [ key, - rateText(f, picked[key]?.rate ?? null), + rateText(f, picked[key]?.rate ?? null, key), ]), ); const rows: DataRow[] = COST_ROWS.map(([key, group, part, name, link]) => ({ @@ -339,7 +350,7 @@ export const buildCost: TabBuild = (ctx): TabTable => { level: nameLevel(rows), mergeSame: ["group", "part"], banner: unfinished(ctx.sheets) ?? undefined, - hint: "고칠 칸은 간접재료비 · 줄을 누르면 총괄 그 줄로 이동", + hint: "고칠 칸은 간접재료비 · 작업설 · 공사이행보증수수료 · 줄을 누르면 총괄 그 줄로 이동", editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND, onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), link: (i) => { diff --git a/resources/tester/test_145_4_b09_total_calc.py b/resources/tester/test_145_4_b09_total_calc.py index 42b6e9ac2..0a04a8d18 100644 --- a/resources/tester/test_145_4_b09_total_calc.py +++ b/resources/tester/test_145_4_b09_total_calc.py @@ -17,7 +17,7 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Total import CURRENT_ONLY, compute_total # noqa: E402 from B09_Estimation.B09_Estimation_Rates import rate_candidates # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -60,6 +60,7 @@ EXCEL_LINES = { # 총괄 D49~D58 · D60 · D62 · D64 · D66 "profit": 108109955, "vat": 102911727, "supply": 69850000, + **{key: 0 for key in CURRENT_ONLY}, # 708-2 현행 비목 — 엑셀(기번3)에 없음 = 요율 빈칸 = 0 } EXCEL_SUBTOTALS = { # 총괄 D59 · D61 · D63 · D65 · D67 "net_cost": 868874829, @@ -103,7 +104,8 @@ def test_간접재료비_작업설은_직접재료비만_바꿈(): def test_빈칸_규칙(): blank = compute_total(SUMS, {}) - assert all(v is None for k, v in blank["lines"].items() if k != "supply") + assert all(v is None for k, v in blank["lines"].items() if k not in ("supply", *CURRENT_ONLY)) + assert all(blank["lines"][k] == 0 for k in CURRENT_ONLY) # 708-2 요율 빈칸 = 해당 없음 assert blank["subtotals"]["net_cost"] is None and blank["subtotals"]["grand"] is None assert blank["lines"]["supply"] is None # 조달수수료율 빈칸 # 요율 하나만 빈칸 — 그 줄 · 소계 빈칸 · 다른 줄은 셈 @@ -157,6 +159,8 @@ def test_거울_TS_가_같은_값(): [{**SUMS, "supply_net": 2_000_000_000}, ESTIMATE], [{**SUMS, "labor": None}, ESTIMATE], [SUMS, {**ESTIMATE, "rates": {**RATES, "care": None, "profit": 12.5}}], + [SUMS, CURRENT], + [{**SUMS, "labor": None}, CURRENT], ] assert _run_ts(cases) == [compute_total(s, e) for s, e in cases] @@ -208,8 +212,53 @@ console.log(JSON.stringify(out)); ) # fmt: skip assert result.returncode == 0, result.stderr out = json.loads(result.stdout) - assert out["editable"] == [True, False, True, False, False] + assert out["editable"] == [True, False, True, True, False] # 708-2 퇴직공제 요율 칸 assert out["care"] == 13.14 and out["careGone"] is False assert out["grandBlank"] is None # 요율 지우면 그 줄 · 소계 빈칸 assert out["base"] == 1000 and out["scrap"] == 300 assert out["link"] == {"tab": "total", "key": "safety"} + + +CURRENT = { # 708-2 현행 비목 — 율 넷 · 공사이행보증 금액 · 불러온 율 표시 + **ESTIMATE, + "rates": { + **RATES, + "retirement": 2.3, + "wage_claim": 0.09, + "asbestos": 0.006, + "subcontract": 0.1, + }, + "rates_imported": ["retirement", "safety"], + "performance_fee": 1234567, +} + + +def test_708_2_현행_비목_다섯(): + out = compute_total(SUMS, CURRENT) + ln, total_labor, direct = out["lines"], 346800554 + 50286080, 710359136 + assert ln["retirement"] == int(346800554 * 2.3 / 100) # 직접노무비 × 율 + assert ln["wage_claim"] == int(total_labor * 0.09 / 100) # (직노 + 간노) × 율 + assert ln["asbestos"] == int(total_labor * 0.006 / 100) + assert ln["subcontract"] == int(direct * 0.1 / 100) # (재료 + 직노 + 산출경비) × 율 + assert ln["performance"] == 1234567 # 금액 손입력 + added = sum(ln[k] for k in CURRENT_ONLY) + assert out["subtotals"]["net_cost"] == EXCEL_SUBTOTALS["net_cost"] + added # 나.소계에 듦 + assert out["cost"]["expense_sum"] == 277553778 + added # 경비 묶음 안 + assert out["cost"]["retirement"] == ln["retirement"] + assert ( + out["rates"]["retirement"]["from"] == "import" + and out["rates"]["safety"]["from"] == "import" + ) + assert out["rates"]["wage_claim"]["from"] == "user" + # 요율 빈칸 = 해당 없음(0 · 소계 빈칸 아님) — 옛 공사 금액 그대로 + old = compute_total(SUMS, ESTIMATE) + assert all(old["lines"][k] == 0 for k in CURRENT_ONLY) + assert old["subtotals"] == EXCEL_SUBTOTALS + + +def test_708_2_저장_칸(): + from B09_Estimation import B09_Estimation_Store as store + + raw = {"estimate": {"performance_fee": 1234567, "rates_imported": ["retirement", 3]}} + got = store.clean_user(raw)["estimate"] + assert got["performance_fee"] == 1234567 and got["rates_imported"] == ["retirement", "3"] diff --git a/resources/tester/test_166_1_b09_rate_auto.py b/resources/tester/test_166_1_b09_rate_auto.py index a00b15292..639781824 100644 --- a/resources/tester/test_166_1_b09_rate_auto.py +++ b/resources/tester/test_166_1_b09_rate_auto.py @@ -15,7 +15,7 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Total import CURRENT_ONLY, compute_total # noqa: E402 from B09_Estimation.B09_Estimation_Rates import rate_table # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -142,7 +142,7 @@ def test_엑셀_검산_손값이면_그대로(): out = total(SUMS, EXCEL, 2026) assert out["subtotals"]["grand"] == 1201879000 assert out["lines"]["safety"] == 16586996 and out["lines"]["profit"] == 108109955 - assert all(r["from"] == "user" for r in out["rates"].values()) + assert all(r["from"] == "user" for k, r in out["rates"].items() if k not in CURRENT_ONLY) def _node(script: str) -> str: diff --git a/resources/tester/test_707_b09_import_rates_prices.py b/resources/tester/test_707_b09_import_rates_prices.py index 5b328bc48..bbf1eefe0 100644 --- a/resources/tester/test_707_b09_import_rates_prices.py +++ b/resources/tester/test_707_b09_import_rates_prices.py @@ -3,7 +3,7 @@ 총액 대조 = 엑셀 비목 합 + 실은 요율 · 손입력으로 우리 식(`compute_total`) → 엑셀 총공사비: 대흥 = 같음 · 영월 = 「국가지점번호판검증수수료」 762,000 원만 다름(우리 표에 줄 없음) · - 거창 = 퇴직공제부금비(셈 없는 현행 비목)만큼 · 소광 = 산출근거 빈칸 율 넷 · 폐기물처리 줄. + 거창 = 같음(708-2 퇴직공제부금비 셈 뒤) · 소광 = 산출근거 빈칸 율 넷 · 폐기물처리 줄. """ import sys @@ -61,7 +61,7 @@ def test_707_2_요율_짝(rates): 2.3, ) assert gc["estimate"]["safety_base"] == 0 # 글에 + 기초액 없음 - assert any("퇴직공제부금비" in n and "셈 없음" in n for n in gc["notes"]) + assert gc["notes"] == [] # 708-2 퇴직공제부금비 = 셈(「셈 없음」 없음) sk = rates["소광"] assert sum("율 없음" in n for n in sk["notes"]) == 4 # 산출근거 빈칸 — 지금 율 그대로 assert any("페기물처리" in n for n in sk["notes"]) @@ -73,11 +73,11 @@ def test_707_2_총액_대조(rates): assert got["대흥"]["differ"] == [] assert got["영월"]["excel_grand"] - got["영월"]["grand"] == 762000 # 국가지점번호판 줄 몫 assert set(got["영월"]["differ"]) == {"도급공사비", "총공사비"} + # 708-2 — 퇴직공제부금비 5,603,907(직노 × 2.3 %)가 순공사원가에 듦 → 총공사비 = 엑셀 + assert got["거창"]["grand"] == got["거창"]["excel_grand"] == 744653000 + assert got["거창"]["differ"] == [] lines = {ln["key"]: ln for ln in got["거창"]["lines"]} - rate_keys = ("indirect_labor", "accident", "employment", "health", "care", "pension") - assert all(lines[k]["same"] for k in (*rate_keys, "safety", "other", "environment")) - assert not lines["overhead"]["same"] # 퇴직공제부금비 5,603,907 이 순공사원가에 없음 - assert lines["overhead"]["excel"] - lines["overhead"]["ours"] == 448312 # = 5,603,907 × 8 % + assert lines["retirement"]["ours"] == lines["retirement"]["excel"] == 5603907 def test_707_3_단가표_읽기():