diff --git a/A00_Common/key_registry.ts b/A00_Common/key_registry.ts index a42902472..c460cc0ff 100644 --- a/A00_Common/key_registry.ts +++ b/A00_Common/key_registry.ts @@ -138,17 +138,18 @@ 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.delete", "plan", ["Delete"], ["고른 것 지움", "Delete selection"]), + 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), A("plan.rotate", "plan", ["Ctrl+휠"], ["지도 돌림", "Rotate map"], F), A("plan.pan", "plan", [PAN], ["화면 옮김", "Pan"], F), - A("plan.vertex", "plan", ["고른 선 누름", "꼭짓점 끌기"], ["꼭짓점 넣고 끌기 · 옮김", "Insert · drag vertex"], F), - A("plan.unvertex", "plan", ["꼭짓점 · 마커 오른쪽 누름"], ["지움", "Delete"], F), + A("plan.select", "plan", ["누름 · 다시 누름"], ["노드 → 선 → 구역 → 전체 고름", "Pick node → segment → region → all"], F), + A("plan.vertex", "plan", ["고른 선 끌기", "노드 끌기"], ["꼭짓점 넣고 끌기 · 노드 옮김(지점 같이)", "Insert · drag vertex (point follows)"], F), + A("plan.unvertex", "plan", ["노드 · 마커 오른쪽 누름"], ["메뉴 [노드 지움] · [마커 지움]", "Menu: delete node · marker"], F), A("plan.measure", "plan", ["재기: 누름 · 누름"], ["시점 · 종점", "Start · end"], F), - A("plan.menu", "plan", ["오른쪽 누름"], ["지도 메뉴 — 긋기 시작 · 지점 놓기 · 갈래", "Map menu — draw · place point · branch"], F), - A("plan.point", "plan", ["지점 핀 끌기"], ["지점 옮김(그 자리 노드 같이)", "Move point (with its node)"], F), + A("plan.menu", "plan", ["오른쪽 누름"], ["지도 메뉴 — 긋기 · 지점 놓기 · 노드 지움 · 연도 구역 나눔", "Map menu — draw · place point · delete node · split year"], F), + A("plan.point", "plan", ["지점 핀 끌기"], ["지점 옮김(그 자리 노드 같이 · 노드 12 px 안 = 붙음)", "Move point (with its node · snaps within 12 px)"], F), A("b04.zoom", "b04", ["휠"], ["지도 · 3D 확대 · 축소", "Map · 3D zoom"], F), A("b04.pan", "b04", [PAN], ["지도 · 3D 옮김", "Map · 3D pan"], F), diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index 59e14f86a..be18299bc 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -47,6 +47,8 @@ export interface RouteEditNavigationParams { leftPan?: boolean; /** 왼쪽이 옮기기 버튼일 때 페이지가 그 누름을 잡았나(노드 · 마커) — 안 주면 포인터를 잡은 겹이 있으면 잡음 */ claimed?: () => boolean; + /** 배율 하한 — 안 주면 0.5(B05 그대로). 예정노선 지도만 한반도까지 빼려고 낮은 값을 줌(600-3). */ + minScale?: () => number; /** Ctrl+휠 = 돌리기(390-3) — 안 주면 Ctrl+휠도 확대(추천 지도). 손가락 · 트랙패드 벌림은 늘 확대. */ rotateWheel?: (sign: 1 | -1) => void; draw: () => void; @@ -81,7 +83,10 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void MAX_SCALE_FALLBACK, ); const factor = wheelZoomsIn(event) ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR; - const scale = Math.min(maxScale, Math.max(MIN_SCALE, view.scale * factor)); + const scale = Math.min( + maxScale, + Math.max(params.minScale?.() ?? MIN_SCALE, view.scale * factor), + ); const ratio = scale / view.scale; const rect = canvas.getBoundingClientRect(); // 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다. diff --git a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts index 83cc164b6..dad0a66eb 100644 --- a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts +++ b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts @@ -5,6 +5,7 @@ * · 사업연도 끝 · 임도 종류 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본) · 설계속도 없음(591-1). * 기준점(591-1 · 요구 452 · 453) = 카카오 지도(공용 editMap · 마우스 설정 따름) 누름 · 끌기로 한 점 → * GET /api/map/address(VWorld · 출처 표기) → 주소 글 · 사업 지역 「시군구 읍면」 채움(손으로 고친 뒤엔 안 덮음) + * 지도 머리 [위성] 오른쪽 = 주소 · 장소 찾기(596-5 · 예정노선 위치 칸과 같은 `createPlaceFind` · 서버 VWorld) → 고름 = 그 자리로 기준점. * [등록] = POST /api/d01/project(site = 경위도 · 주소는 서버가 다시 찾음) → 현재 프로젝트 · D03 으로. * ========================================================================== */ @@ -20,6 +21,7 @@ import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import { createGroupProject } from "../A00_Common/group_workflow"; import { navigateTo } from "../A00_Common/router"; import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name"; +import { createPlaceFind } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Site"; const KOREA_CENTER: [number, number] = [127.8, 36.3]; const DIGITS = 6; // 경위도 소수 자리(약 0.1 m) @@ -85,12 +87,17 @@ function sitePicker(onRegion: (region: string) => void): { } draw(); + // 찾기 = 칸은 머리 줄([위성] 오른쪽 · 좁으면 줄바꿈) · 알림 · 후보 줄은 머리 아래 + const find = createPlaceFind((hit) => void moveTo(hit.lon, hit.lat)); + const field = find.search.parentElement!; + field.style.flex = "1 1 12rem"; const head = document.createElement("div"); head.className = "ui-general-block__actions"; - head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root); + head.style.flexWrap = "wrap"; + head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root, field); const root = document.createElement("div"); root.dataset.d01Site = ""; - root.append(head, map.root, where); + root.append(head, find.root, map.root, where); return { root, value: () => site }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 174a1c55c..d353e00ba 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -30,23 +30,24 @@ export interface PlanBaseView { site: { lon: number; lat: number; address: string | null } | null; } +/** 진행(595-3) — 끊김 = running 거짓 · percent < 100 · message 없음 · interrupted(서버가 다시 켜짐) · + * 받은 적 없음 = percent 100 · message = 「n/5 …」 단계 글. */ export interface PlanJob { running: boolean; percent: number; message: string | null; error: string | null; - /** 서버 다시 켜짐 등으로 끊김(595-3). */ interrupted?: boolean; } -/** 받기 시작 답(595-2) — 받음 · 멂(받은 범위에서 5 km 넘음 → 묻고 `reset`) · 다른 TM 띠(노선 지움 확인 뒤 `reset`). */ +/** 받기 답(595-2) — 시작 · 멀다(받은 범위에서 5 km 넘음) · 다른 TM 띠 — 둘은 확인 뒤 `reset`. */ export type TerrainStart = - | (PlanJob & { status?: "accepted" }) + | ({ status: "accepted" } & PlanJob) | { status: "far"; distance_m: number; message: string } | { status: "band"; epsg: number; center_epsg: number; message: string }; -/** 위치 칸 기준점(캐시 · [저장] 전) — 서버가 둘레 ±1 km 를 더 덮음 · `reset` = 옛 받은 범위 버림. */ -export interface TerrainCenter { +/** 위치 칸 기준점(경위도 · 캐시) · 새로 받기(옛 받은 범위 버림). */ +export interface TerrainAim { center?: { lon: number; lat: number } | null; reset?: boolean; } @@ -62,6 +63,8 @@ export interface TerrainExtent { covered: boolean; sheets: Array<{ no: string; ring: Array<[number, number]>; stored: boolean }>; cells: number; + /** 받기 칸 상한(넘으면 받기 413). */ + max_cells: number; } /** 그은 선 서버 꼴(562-2) — 마크 = 조각 경계 · 조각 없는 구역 = 연도 없음. */ @@ -110,12 +113,12 @@ async function request(path: string, init: RequestInit = {}): Promise { const base = (projectId: string): string => `/projects/${projectId}/plan`; -/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점). */ +/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점) · `center` = 위치 칸 기준점(둘레 ±1 km). */ export const fetchTerrainExtent = ( projectId: string, marginM: number | null, extra: Vertex[], - center: TerrainCenter["center"] = null, + center: { lon: number; lat: number } | null = null, ): Promise => { const query = new URLSearchParams(); if (marginM !== null) query.set("margin_m", String(marginM)); @@ -127,15 +130,16 @@ export const fetchTerrainExtent = ( return request(`${base(projectId)}/terrain/extent?${query}`); }; +/** 받기 — 칸 상한 넘음 = 오류(413 글). */ export const startTerrain = ( projectId: string, marginM: number | null, extra: Vertex[], - where: TerrainCenter = {}, + aim: TerrainAim = {}, ): Promise => request(`${base(projectId)}/terrain`, { method: "POST", - body: JSON.stringify({ margin_m: marginM, extra, ...where }), + body: JSON.stringify({ margin_m: marginM, extra, ...aim }), }); export const fetchPlanProgress = (projectId: string): Promise<{ terrain: PlanJob }> => @@ -175,13 +179,14 @@ export async function fetchPlanSurface(projectId: string): Promise; image: string; - bands: Array<{ min_deg: number; max_deg: number; color: string; ratio: number }>; + /** 급경사만(603) — `counts[v]` = 기울기 % 내림 v(0~250) 칸 수 · 그림 = 회색 칸 값(255 = 빈 칸). */ + counts?: number[]; } export const fetchPlanCadastral = (projectId: string): Promise => @@ -246,18 +251,17 @@ export const fetchPlanZeroOrder = (projectId: string): Promise => export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> => request(`${base(projectId)}/layers/bands`); -/** 올린 임도망도(584-3) — 받은 범위로 자른 선 + 가닥 · 길이 m · 원본 선 수 · 좌표계 · 올린 파일. */ +/** 올린 임도망도(584-3 · 595-4) — 기준점 ±5 km ∪ 받은 지형으로 자른 선 + 가닥 · 길이 m · 원본 선 수 · + * 좌표계 · 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 올린 파일. */ export interface PlanForestRoads { 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; - /** 잘린 임도 상자 [서, 남, 동, 북] 경위도 · 없으면 null(595-4). */ - bounds?: [number, number, number, number] | null; - /** 기준점 ~ 가장 가까운 임도(m). */ - distance_m?: number | null; } /** 올린 적 없으면 오류(404 글 그대로). */ diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py b/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py index 052a24828..d18a2b9bd 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py @@ -1,12 +1,13 @@ -"""D 노선분석 지도 올린 임도망도 겹(PLAN 584-3 · 요구 443). +"""D 노선분석 지도 올린 임도망도 겹(PLAN 584-3 · 595-4 · 요구 443 · 463). - 출처 = 사용자가 받은 산림청 임도망도 SHP(FGIS 무료신청 · 산림빅데이터거래소 · 시군구 단위 · 선) -- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 받은 범위 안 - 선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError`) +- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 자르는 범위 + 안 선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError` + 기준점 ~ 가장 가까운 임도 거리) +- 자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형(`clip_box` · 지형 받기 전에도 기준점만으로) - 원본 = 프로젝트 `D03_PlanAnalysis/임도망도.zip`(낱 파일은 zip 으로 묶음) + `임도망도.json`(파일 이름 · 올린 때) -- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 받은 범위로 잘라 가닥 · 길이 → - 파생 저장본(열쇠 = 원본 지문 · 범위) +- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 자르는 범위로 잘라 가닥 · 길이 · + 잘린 상자(`bounds` 경위도) · 기준점 거리 → 파생 저장본(열쇠 = 원본 지문 · 범위 · 기준점) 계산 자리 = ③ 서버 단독. """ @@ -32,6 +33,7 @@ from common_util.common_util_json import atomic_write_json from common_util.common_util_time import utc_now_text Box = tuple[float, float, float, float] +XY = tuple[float, float] FOLDER = "D03_PlanAnalysis" SOURCE_ZIP = "임도망도.zip" @@ -41,7 +43,17 @@ DBF_ENCODING = "cp949" MAX_BYTES = 100 * 1024 * 1024 SHP_PARTS = (".shp", ".shx", ".dbf", ".prj", ".cpg") ROAD_KIND = "plan_forest_road" -ROAD_VERSION = 1 +ROAD_VERSION = 2 # 잘린 상자 · 기준점 거리 +RADIUS_M = 5000.0 # 기준점 둘레(요구서_463 3장 18) + + +def clip_box(origin: XY, have: Box | None) -> Box: + """자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형.""" + x, y = origin + box = (x - RADIUS_M, y - RADIUS_M, x + RADIUS_M, y + RADIUS_M) + if have is None: + return box + return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3]) def source_dir(project_root: Path) -> Path: @@ -100,18 +112,22 @@ def _plain(value: Any) -> Any: return value if isinstance(value, (str, int, float, bool)) or value is None else str(value) -def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]: - """SHP 선 → 받은 범위로 잘라 경위도 선(원 속성 + `length_m`) · 가닥 · 길이 · 원본 선 수 · - 좌표계.""" +def clip_roads(zip_path: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]: + """SHP 선 → 범위로 잘라 경위도 선(원 속성 + `length_m`) · 가닥 · 길이 · 원본 선 수 · 좌표계 · + 잘린 상자(`bounds` [서, 남, 동, 북] · 없으면 None) · 기준점 ~ 가장 가까운 임도(`distance_m` · + 자르기 전 모든 선).""" from pyproj import Transformer + from shapely.geometry import Point, mapping from shapely.geometry import box as rect - from shapely.geometry import mapping from shapely.ops import transform frame = rect(*box) + here = Point(origin) if origin else None to_ll = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True).transform features: list[dict[str, Any]] = [] total, length, crs = 0, 0.0, [] + near: float | None = None + edges: list[tuple[float, float, float, float]] = [] for shapes in read_shapes(zip_path): crs.append(shapes.crs.to_string()) shapes = shapes[shapes.geometry.notna()] @@ -121,19 +137,18 @@ def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]: for (_, row), geometry in zip( shapes.drop(columns=shapes.geometry.name).iterrows(), metric.geometry ): + if here is not None: + gap = geometry.distance(here) + near = gap if near is None else min(near, gap) part = geometry.intersection(frame) if part.is_empty or part.length <= 0: continue length += part.length props = {str(k): _plain(v) for k, v in row.items()} props["length_m"] = round(part.length, 1) - features.append( - { - "type": "Feature", - "geometry": mapping(transform(to_ll, part)), - "properties": props, - } - ) + lonlat = transform(to_ll, part) + edges.append(lonlat.bounds) + features.append({"type": "Feature", "geometry": mapping(lonlat), "properties": props}) if not total: raise ValueError("SHP 에 선 지물이 없습니다 — 임도망도(선) 파일인지 보세요.") return { @@ -141,23 +156,34 @@ def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]: "length_m": round(length, 1), "total": total, "crs": sorted(set(crs)), + "bounds": [ + min(e[0] for e in edges), + min(e[1] for e in edges), + max(e[2] for e in edges), + max(e[3] for e in edges), + ] + if edges + else None, + "distance_m": None if near is None else round(near, 1), "collection": {"type": "FeatureCollection", "features": features}, } -def road_layer(project_root: Path, box: Box, epsg: int) -> dict[str, Any]: - """올린 임도망도 — 원본 지문 · 범위 같으면 저장본 · 올린 적 없으면 `FileNotFoundError`.""" +def road_layer(project_root: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]: + """올린 임도망도 — 원본 지문 · 범위 · 기준점 같으면 저장본 · 올린 적 없으면 + `FileNotFoundError`.""" path = source_dir(project_root) / SOURCE_ZIP stamp = file_stamp(path) if stamp is None: raise FileNotFoundError( - "올린 임도망도가 없습니다 — 옆 올리기 단추로 시군구 SHP 를 올리세요." + "올린 임도망도가 없습니다 — [임도망 올리기]로 시군구 SHP 를 올리세요." ) - key = derived_key(ROAD_KIND, ROAD_VERSION, stamp, int(epsg), [round(v, 1) for v in box]) + at = [round(v, 1) for v in origin] if origin else None + key = derived_key(ROAD_KIND, ROAD_VERSION, stamp, int(epsg), [round(v, 1) for v in box], at) saved = read_derived(project_root, ROAD_KIND, key) if saved is not None: return saved["value"] - value = clip_roads(path, box, epsg) + value = clip_roads(path, box, epsg, origin) try: value["source"] = json.loads((source_dir(project_root) / SOURCE_META).read_text("utf-8")) except (OSError, ValueError): @@ -167,10 +193,14 @@ def road_layer(project_root: Path, box: Box, epsg: int) -> dict[str, Any]: def save_roads( - project_root: Path, files: list[tuple[str, bytes]], box: Box, epsg: int + project_root: Path, + files: list[tuple[str, bytes]], + box: Box, + epsg: int, + origin: XY | None = None, ) -> dict[str, Any]: - """올림 — 묶어 읽어 보고 받은 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError` - (옛 것 그대로).""" + """올림 — 묶어 읽어 보고 자르는 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError` + (옛 것 그대로 · 기준점 ~ 가장 가까운 임도 거리).""" data = pack(files) folder = source_dir(project_root) folder.mkdir(parents=True, exist_ok=True) @@ -178,11 +208,13 @@ def save_roads( temporary.write(data) trial = Path(temporary.name) try: - got = clip_roads(trial, box, epsg) + got = clip_roads(trial, box, epsg, origin) if not got["count"]: + gap = got["distance_m"] + where = f" — 가장 가까운 임도 {gap / 1000:.1f} km" if gap is not None else "" raise ValueError( - f"받은 범위 안에 임도가 없습니다(파일 선 {got['total']}가닥) — " - "사업지 시군구 파일인지 보세요." + f"임도가 기준점 ±{RADIUS_M / 1000:.0f} km · 받은 지형 밖뿐입니다{where}" + f"(파일 선 {got['total']}가닥) — 사업지 시군구 파일인지 보세요." ) os.replace(trial, folder / SOURCE_ZIP) finally: @@ -195,4 +227,4 @@ def save_roads( "uploaded_at": utc_now_text(), }, ) - return road_layer(project_root, box, epsg) + return road_layer(project_root, box, epsg, origin) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py index 534b74e5e..3cc402d0e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py @@ -5,7 +5,8 @@ 한 번 1,000 필지 한도 → 꽉 차면 넷으로 다시 쪼갬 · `pnu` 로 겹친 필지 거름 · 저장 = `processed/연속지적도_bounds.geojson`(B04 `/geojson?layer=지적도` 자리) + 곁 열쇠 - 급경사 = 평활 안 한 사업 DEM(`models_plan/dtm_sheet_<방법>.npz`) · E07 `slope_rad` · - 15° · 35° · 40° 단계 PNG · 파생 저장본(열쇠 = 격자 지문) + 기울기 % 값 그림 한 장(1 % 단계 · 회색) + 1 % 칸 수 · 범위 색칠 · 비율 = 화면(PLAN 603) · + 파생 저장본(열쇠 = 격자 지문) - 음영기복 = 같은 DEM · B07 `hillshade_png`(PLAN 581-1) - 보호 · 규제 구역 = 브이월드 WFS 여덟 종(겹 이름 = `_분석_440-C` 표 1) · 받은 범위로 잘라 종류 · 이름 · 넓이만 · 저장 = `processed/보호구역_bounds.geojson` + 곁 열쇠 · @@ -64,13 +65,9 @@ SLOPE_KIND = "plan_slope" HILLSHADE_KIND = "plan_hillshade" HILLSHADE_VERSION = 2 # 빈 칸 투명 RASTER_KINDS = ("slope", "hillshade") -SLOPE_VERSION = 1 -# (아래 °, 위 °, 색 RGBA) — 임도망_편성 완 · 중 · 급경사지 · 타당성평가 35° · 노선_배치 40° 연속 -SLOPE_BANDS = ( - (15.0, 35.0, (250, 204, 21, 110)), - (35.0, 40.0, (249, 115, 22, 140)), - (40.0, 90.0, (220, 38, 38, 160)), -) +SLOPE_VERSION = 2 # 값 그림 + 1 % 칸 수(PLAN 603) +SLOPE_MAX_PCT = 250 # 칸 값 0~250 = 기울기 % 내림(250 = 250 % 이상) +SLOPE_EMPTY = 255 # 빈 칸 MAX_SIDE_PX = 4096 # ponytail: 한 변 넘으면 솎음 · 넓은 사업은 조각 그림으로 @@ -179,69 +176,64 @@ def _covers(have: Any, box: Box) -> bool: def ensure_cadastral(processed: Path, box: Box, epsg: int) -> dict[str, Any]: - """지적도 — 열쇠 상자가 덮고 실패 조각 없으면 그대로 · 아니면 상자 통째로 다시 받음.""" + """지적도 — 열쇠 상자가 덮고 실패 조각 없으면 그대로 · 덮는데 실패 조각이 남았으면 그 조각만 + 다시(PLAN 595-1) · 못 덮으면 상자 통째로 다시 받음.""" path, key_path = processed / CADASTRAL_FILE, processed / CADASTRAL_KEY key = _read_json(key_path) or {} - if path.is_file() and not key.get("failed") and _covers(key.get("box"), box): + covered = path.is_file() and _covers(key.get("box"), box) + if covered and not key.get("failed"): return {"path": path, "fetched": False, "count": key.get("count"), "failed": 0} - parts = tiles(box) + retry = key.get("failed_tiles") if covered else None # 옛 열쇠(조각 기록 없음) = 통째로 + old = ((_read_json(path) or {}).get("features") or []) if retry else [] + frame = key["box"] if retry else [float(v) for v in box] + parts = [tuple(t) for t in retry] if retry else tiles(box) with ThreadPoolExecutor(max_workers=WFS_WORKERS) as pool: results = list(pool.map(lambda part: fetch_tile(part, epsg), parts)) - failed = sum(bad for _, bad in results) - if failed == len(parts): - raise RuntimeError(f"지적도를 받지 못했습니다 — 브이월드 WFS 조각 {failed}개 실패") - features = dedup_parcels([f for got, _ in results for f in got]) + bad = [list(part) for part, (_, n) in zip(parts, results) if n] + if len(bad) == len(parts) and not old: + raise RuntimeError(f"지적도를 받지 못했습니다 — 브이월드 WFS 조각 {len(bad)}개 실패") + features = dedup_parcels([*old, *(f for got, _ in results for f in got)]) processed.mkdir(parents=True, exist_ok=True) atomic_write_json(path, {"type": "FeatureCollection", "features": features}) atomic_write_json( key_path, { - "box": [float(v) for v in box], + "box": frame, "epsg": int(epsg), "count": len(features), - "failed": failed, + "failed": len(bad), + "failed_tiles": bad, }, ) - logger.info("D 지적도 받기: 조각 %d · 실패 %d · 필지 %d", len(parts), failed, len(features)) - return {"path": path, "fetched": True, "count": len(features), "failed": failed} + logger.info("D 지적도 받기: 조각 %d · 실패 %d · 필지 %d", len(parts), len(bad), len(features)) + return {"path": path, "fetched": True, "count": len(features), "failed": len(bad)} # ── 급경사 ────────────────────────────────────────────── -def slope_bands_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]: - """격자(행 = 남 → 북) → 단계 PNG(위 = 북 · 15° 미만 · 빈 칸 투명) + 단계 칸 비율.""" +def slope_values_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]: + """격자(행 = 남 → 북) → 기울기 % 값 그림(회색 · 위 = 북 · 칸 값 = % 내림 0~250 · 빈 칸 255) + + 1 % 칸 수(`counts[v]` = 값 v 칸 수 · 합 = 유효 칸).""" from PIL import Image from E07_StabArea.E07_StabArea_Engine import slope_rad stride = max(1, math.ceil(max(z.shape) / MAX_SIDE_PX)) grid = np.where(valid, np.asarray(z, float), np.nan)[::stride, ::stride] - deg = np.degrees(slope_rad(grid, cell_m * stride)) - known = np.isfinite(deg) - index = np.zeros(deg.shape, np.uint8) - for i, (low, high, _) in enumerate(SLOPE_BANDS, start=1): - index[known & (deg >= low) & (deg < high)] = i - total = max(1, int(known.sum())) - image = Image.fromarray(np.flipud(index), mode="P") - palette = [0, 0, 0] + [c for _, _, rgba in SLOPE_BANDS for c in rgba[:3]] - image.putpalette(palette + [0] * (768 - len(palette))) - alpha = bytes([0] + [rgba[3] for _, _, rgba in SLOPE_BANDS]) + pct = np.tan(slope_rad(grid, cell_m * stride)) * 100.0 + known = np.isfinite(pct) + value = np.full(pct.shape, SLOPE_EMPTY, np.uint8) + value[known] = np.minimum(np.floor(pct[known]), SLOPE_MAX_PCT).astype(np.uint8) + image = Image.fromarray(np.flipud(value), mode="L") buffer = io.BytesIO() - image.save(buffer, format="PNG", optimize=True, transparency=alpha) + image.save(buffer, format="PNG", optimize=True) + counts = np.bincount(value[known], minlength=SLOPE_MAX_PCT + 1) return { "width": image.width, "height": image.height, "image": "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii"), - "bands": [ - { - "min_deg": low, - "max_deg": high, - "color": "#{:02x}{:02x}{:02x}".format(*rgba[:3]), - "ratio": round(int((index == i).sum()) / total, 4), - } - for i, (low, high, rgba) in enumerate(SLOPE_BANDS, start=1) - ], + "counts": [int(n) for n in counts], } @@ -263,17 +255,17 @@ 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" ) -> dict[str, Any]: - """급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 단계 · 판) 같으면 저장본.""" + """급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 판) 같으면 저장본.""" npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz" stamp = file_stamp(npz) if stamp is None: raise FileNotFoundError("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.") store_kind, version, build = ( - (SLOPE_KIND, SLOPE_VERSION, slope_bands_png) + (SLOPE_KIND, SLOPE_VERSION, slope_values_png) if kind == "slope" else (HILLSHADE_KIND, HILLSHADE_VERSION, hillshade_layer_png) ) - key = derived_key(store_kind, version, npz.name, stamp, int(epsg), SLOPE_BANDS, MAX_SIDE_PX) + key = derived_key(store_kind, version, npz.name, stamp, int(epsg), MAX_SIDE_PX) saved = read_derived(project_root, store_kind, key) if saved is not None: return saved["value"] diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 6059b375e..e1e7f351a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -11,6 +11,10 @@ 범위 = 기준점 둘레(`SITE_RADIUS_M`) + 지점 + 그은 점(`extra` · 지도 보기 귀) 상자 + 여유 ∪ 받은 범위 (PLAN 591-2 · 기준점 = 처음 받는 기준일 뿐). +위치 바꿈(PLAN 595 · 꼴 = PLAN 595 절) = 몸 `center`(위치 칸 캐시 기준점) 둘레를 더함 · +받은 범위에서 `FAR_M` 넘음 = 「far」 물음 · 다른 TM 띠 = 「band」 물음 → `reset` = 옛 범위 버리고 +center 둘레만 · 칸 `MAX_CELLS` 넘음 = 413 · 진행은 `models_plan/plan_job_<일>.json` 에도 적어 +서버가 다시 켜지면 「끊김」(interrupted). 노선 정본(손 그은 선)은 `D03_PlanAnalysis_Network`(PLAN 562-2 · 옛 추천 엔진 · 꺾임점 R 걷음). """ @@ -28,8 +32,9 @@ from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFil from pydantic import BaseModel from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from common_util.common_util_crs import lonlat_to_tm +from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon from common_util.common_util_http_cache import cached_file_response +from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool from D03_PlanAnalysis import D03_PlanAnalysis_ForestRoad as forest_road @@ -50,26 +55,53 @@ _TASKS: set[asyncio.Task] = set() KINDS = ("terrain", "analyze") +class Center(BaseModel): + lon: float + lat: float + + class TerrainBody(BaseModel): - """지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m).""" + """지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m) · + 위치 칸 가운데(경위도 · 595) · 새로(옛 받은 범위 버림).""" margin_m: float | None = None extra: list[tuple[float, float]] = [] + center: Center | None = None + reset: bool = False -async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, float]]]: - """(실경로, 좌표계, [지점들]) — 설계 내용 파일 · 지점 · 선이 없으면 등록 기준점 둘레 두 귀.""" +Ends = tuple[Path, int, list[tuple[float, float]], int | None] + + +async def _plan_ends( + project_id: UUID, center: tuple[float, float] | None = None, reset: bool = False +) -> Ends: + """(실경로, 좌표계, [지점들], 띠 넘음 epsg) — 설계 내용 파일 · 지점 · 선이 없으면 등록 기준점 + 둘레 두 귀 · `center`(위치 칸) = 그 둘레를 더함(`reset` = 둘레만) · 다른 TM 띠 = 확인 전이면 + 넷째 값에 그 띠(셋은 `center` 없이) · 확인 뒤(받은 지형이 그 띠) = 그 띠 · 둘레만.""" root, plan = await store.load_plan(project_id) # 없음 404 · 예정노선 아님 400 site = (await network.project_info(project_id))["site"] epsg = network.work_epsg(plan, site) + meta = terrain.received_meta(root) + if center is None and meta.get("center") and meta.get("epsg") not in (None, epsg): + center = tuple(meta["center"]) # 띠 넘어 받은 뒤 [저장] 전 — 받은 지형 띠 · 가운데 + ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]] + if center is not None: + band = tm_epsg_for_lon(center[0]) + if epsg is not None and band != epsg: + if not (reset or meta.get("epsg") == band): + return (*(await _plan_ends(project_id))[:3], band) + epsg, ends, reset = band, [], True # 옛 띠 지점 · 선 = 「노선 지움」 확인으로 빠짐 + epsg = epsg or band + corners = terrain.site_corners(*lonlat_to_tm(center[0], center[1], epsg)) + return root, epsg, corners if reset else [*ends, *corners], None if epsg is None: raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.") - ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]] if site and not ends and not plan["lines"]: # 처음 = 기준점 둘레 · 그 뒤는 받은 범위가 덮음 ends = terrain.site_corners(*lonlat_to_tm(site["lon"], site["lat"], epsg)) if not ends: raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.") - return root, epsg, ends + return root, epsg, ends, None def _query_xy(extra: str | None) -> list[tuple[float, float]]: @@ -81,15 +113,49 @@ def _query_xy(extra: str | None) -> list[tuple[float, float]]: raise HTTPException(status_code=400, detail="extra 꼴이 다릅니다.") from error -def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]: +IDLE = {"running": False, "percent": 100, "message": None, "error": None, "interrupted": False} + + +def _job_path(root: Path, kind: str) -> Path: + return terrain.models_dir_of(root) / f"plan_job_{kind}.json" + + +def _note_job(path: Path | None, job: dict[str, Any]) -> None: + """진행 기록 — 서버가 다시 켜져도 「돌던 일」을 알게(실패해도 일은 계속).""" + if path is None: + return + try: + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(path, job) + except OSError as error: + logger.warning("D 진행 기록 실패: %s", error) + + +def _saved_job(root: Path, kind: str) -> dict[str, Any]: + """기억에 없는 일 — 기록이 「도는 중」이면 서버가 다시 켜져 끊김(percent < 100 · + message 없음).""" + try: + saved = json.loads(_job_path(root, kind).read_text(encoding="utf-8")) + except (OSError, ValueError): + return dict(IDLE) + if not (isinstance(saved, dict) and saved.get("running")): + return dict(IDLE) + percent = min(99, int(saved.get("percent") or 0)) + return {**IDLE, "percent": percent, "interrupted": True} + + +def _start_job(project_id: UUID, kind: str, work: Any, root: Path | None = None) -> dict[str, Any]: key = (str(project_id), kind) if (_JOBS.get(key) or {}).get("running"): raise HTTPException(status_code=409, detail="이미 돌고 있습니다.") - job = {"running": True, "percent": 0, "message": "시작", "error": None} + job = {"running": True, "percent": 0, "message": "시작", "error": None, "interrupted": False} _JOBS[key] = job + path = _job_path(root, kind) if root is not None else None + _note_job(path, job) def report(percent: int, message: str) -> None: job.update(percent=int(percent), message=message) + _note_job(path, job) async def run() -> None: try: @@ -100,6 +166,7 @@ def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]: job["error"] = str(getattr(error, "detail", None) or error) finally: job["running"] = False + _note_job(path, job) task = asyncio.create_task(run(), name=f"D {kind} {project_id}") _TASKS.add(task) @@ -133,10 +200,16 @@ async def _save_models(project_id: UUID, models: list[dict[str, Any]], epsg: int @router.get("/{project_id}/plan/terrain/extent", response_model=None) async def plan_terrain_extent( - project_id: UUID, margin_m: float | None = None, extra: str | None = None + project_id: UUID, + margin_m: float | None = None, + extra: str | None = None, + lon: float | None = None, + lat: float | None = None, ) -> dict[str, Any]: - """받기 전 범위 — 지점(+ `extra` 그은 점 `[[x, y]]`) 상자 + 여유 ∪ 받은 범위.""" - root, epsg, ends = await _plan_ends(project_id) + """받기 전 범위 — 지점(+ `extra` 그은 점 `[[x, y]]` · `lon` `lat` 위치 칸 둘레) 상자 + 여유 ∪ + 받은 범위.""" + center = (lon, lat) if lon is not None and lat is not None else None + root, epsg, ends, _ = await _plan_ends(project_id, center) view = await asyncio.to_thread( terrain.plan_extent, root, [*ends, *_query_xy(extra)], epsg, margin_m ) @@ -145,34 +218,73 @@ async def plan_terrain_extent( @router.post("/{project_id}/plan/terrain", response_model=None) async def plan_terrain(project_id: UUID, body: TerrainBody | None = None) -> dict[str, Any]: - """사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress`.""" + """사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress` · + 위치 칸 가운데가 멀거나 다른 띠 = 물음(`far` · `band`) · 칸 상한 넘음 = 413.""" body = body or TerrainBody() - root, epsg, ends = await _plan_ends(project_id) + center = (body.center.lon, body.center.lat) if body.center else None + root, epsg, ends, band = await _plan_ends(project_id, center, body.reset) + if band is not None: + return { + "status": "band", + "epsg": epsg, + "center_epsg": band, + "message": "다른 좌표 띠로 옮깁니다 — 그은 지점 · 선을 지우고 새로 받을까요?", + } + have = None if body.reset else terrain.received_box(root, epsg) + if center is not None and have is not None: + far = terrain.distance_to_box(lonlat_to_tm(center[0], center[1], epsg), have) + if far > terrain.FAR_M: + return { + "status": "far", + "distance_m": round(far), + "message": f"받은 지형에서 {far / 1000:.1f} km 떨어졌습니다 — " + "옛 받은 범위를 버리고 새로 받을까요?", + } points = [*ends, *body.extra] + cells = terrain.cells_of(terrain.wanted_box(points, terrain.clamp_margin(body.margin_m), have)) + if cells > terrain.MAX_CELLS: + raise HTTPException( + status_code=413, + detail=f"받을 범위가 너무 큽니다 — 칸 {cells / 1e4:.0f}만 · " + f"상한 {terrain.MAX_CELLS / 1e4:.0f}만(지점 · 선을 모으거나 위치를 옮겨 새로 받으세요)", + ) async def work(report: Any) -> None: got = await asyncio.to_thread( - terrain.ensure_plan_terrain, root, points, epsg, body.margin_m, report + terrain.ensure_plan_terrain, + root, + points, + epsg, + body.margin_m, + report, + reset=have is None, + center=center, ) await _save_models(project_id, got["models"], epsg) report(100, f"지형 받기 끝 — 새 도엽 {len(got['sheets']['downloaded'])}매") - return _start_job(project_id, "terrain", work) + return _start_job(project_id, "terrain", work, root) @router.get("/{project_id}/plan/progress", response_model=None) async def plan_progress(project_id: UUID) -> dict[str, Any]: - idle = {"running": False, "percent": 0, "message": None, "error": None} - return { - "status": "success", - **{kind: dict(_JOBS.get((str(project_id), kind)) or idle) for kind in KINDS}, - } + """일마다 진행 — 기억에 없으면 기록을 봄(돌던 기록 = 끊김 · 없으면 percent 100).""" + jobs: dict[str, Any] = {} + for kind in KINDS: + job = _JOBS.get((str(project_id), kind)) + if job is None: + try: + job = await asyncio.to_thread(_saved_job, await _tile_root(project_id), kind) + except HTTPException: + job = IDLE + jobs[kind] = dict(job) + 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) + 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 @@ -208,7 +320,7 @@ 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]: - """급경사 단계 그림(15° · 35° · 40°) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 · + """급경사 기울기 % 값 그림 + 1 % 칸 수(603) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 · 산사태위험지도(`landslide` · 행안부 WMS) — 받은 범위를 덮으면 저장본.""" if kind == "landslide": root, epsg, box = await _layer_root(project_id) @@ -301,12 +413,30 @@ async def plan_layer_zero_order( return {"status": "success", **value} +async def _road_frame(project_id: UUID) -> tuple[Path, int, terrain.Box, tuple[float, float]]: + """(실경로, 좌표계, 자르는 범위, 기준점 m) — 기준점 = 위치 칸으로 받은 가운데 · 등록 기준점 · + 지점 가운데 차례 · 지형 받기 전에도(595-4).""" + root, epsg, ends, _ = await _plan_ends(project_id) + center = terrain.received_meta(root).get("center") + site = (await network.project_info(project_id))["site"] + if center: + origin = lonlat_to_tm(float(center[0]), float(center[1]), epsg) + elif site: + origin = lonlat_to_tm(site["lon"], site["lat"], epsg) + else: + xs, ys = [p[0] for p in ends], [p[1] for p in ends] + origin = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2) + box = forest_road.clip_box(origin, terrain.received_box(root, epsg)) + return root, epsg, box, origin + + @router.get("/{project_id}/plan/layers/forest-roads", response_model=None) async def plan_layer_forest_roads(project_id: UUID) -> dict[str, Any]: - """올린 임도망도(받은 범위로 자른 선 · 가닥 · 길이) — 올린 적 없으면 404.""" - root, epsg, box = await _layer_root(project_id) + """올린 임도망도(기준점 ± 5 km ∪ 받은 지형으로 자른 선 · 가닥 · 길이 · 잘린 상자 · 기준점 + 거리) — 올린 적 없으면 404.""" + root, epsg, box, origin = await _road_frame(project_id) try: - value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg) + value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg, origin) except FileNotFoundError as error: raise HTTPException(status_code=404, detail=str(error)) from error return {"status": "success", **value} @@ -317,8 +447,8 @@ async def plan_layer_forest_roads_upload( project_id: UUID, files: list[UploadFile] = File(...) ) -> dict[str, Any]: """임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음 · 합계 100 MB) — 못 읽거나 - 받은 범위 안 선이 없으면 400(옛 것 그대로) · 넘치면 413.""" - root, epsg, box = await _layer_root(project_id) + 자르는 범위 안 선이 없으면 400(옛 것 그대로 · 가장 가까운 임도 거리) · 넘치면 413.""" + root, epsg, box, origin = await _road_frame(project_id) got: list[tuple[str, bytes]] = [] total = 0 for upload in files: @@ -335,7 +465,7 @@ async def plan_layer_forest_roads_upload( if not total: raise HTTPException(status_code=400, detail="빈 파일입니다.") try: - value = await asyncio.to_thread(forest_road.save_roads, root, got, box, epsg) + value = await asyncio.to_thread(forest_road.save_roads, root, got, box, epsg, origin) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error return {"status": "success", **value} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py index 866937b52..6827181ef 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py @@ -1,13 +1,17 @@ -"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · PLAN 530-1 · 561-2). +"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · 463 · PLAN 530-1 · +561-2 · 595). 지형은 D 프로젝트 storage 안 `B04_PreProcess/` 배치 그대로(지형 코드 주인 = B04 · 고칠 곳 0): -- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음) +- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음) · + 새로(`reset` · 위치를 멀리 옮김) = 옛 받은 범위 · 병합 도엽 · 위성 범위를 버리고 새 상자만 · + 칸 상한 `MAX_CELLS`(넘으면 라우터가 거절) - 도엽 = 범위 상자에 닿는 도엽 모두 → `ensure_sheets`(없는 것만 · prune 없음) - 병합 = 지난번 병합 도엽 ∪ 이번 도엽(옛 도엽 유지 · 열쇠 같으면 건너뜀) - 위성 = 범위 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀) -- 지적도 = 범위 상자 · 브이월드 WFS 1 km 조각(`D03_PlanAnalysis_Layers` · 실패해도 계속) +- 지적도 = 지형 일에서 뺌 — 겹을 켤 때 GET 이 받음(`D03_PlanAnalysis_Layers.ensure_cadastral`) - DEM = 한 벌 `models_plan/`(연도는 D03 나눔 뒤에 정해짐) · 받은 범위 = `plan_terrain.json` + (상자 · 여유 · 좌표계 · 위치 칸 가운데) - 작업 좌표계 = 지점 epsg · `B03_FileInput/input/prj/plan.prj` 한 장(B04 · B05 좌표계 창구가 읽음) - 받기 전 범위 보기 = `plan_extent`(내려받기 없음) · 받기는 사용자가 누를 때만 @@ -43,7 +47,6 @@ from config.config_system_terrain import ( SHEET_SURFACE_DEFAULT_METHOD, SHEET_SURFACE_MARGIN_M, ) -from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers logger = logging.getLogger(__name__) @@ -52,6 +55,9 @@ DEFAULT_MARGIN_M = 350.0 MAX_MARGIN_M = 5000.0 # 처음 받는 범위 = 등록 기준점 둘레 ± 1 km(요구 453 · PLAN 591-2) SITE_RADIUS_M = 1000.0 +# 위치 바꿈이 받은 범위에서 이만큼 넘게 멀면 묻고 새로 · 받을 DEM 칸 상한(요구서_463 3장 4) +FAR_M = 5000.0 +MAX_CELLS = 5_000_000 STAGE = Path("B04_PreProcess") PLAN_MODELS = "models_plan" TERRAIN_META = "plan_terrain.json" @@ -74,9 +80,16 @@ def clamp_margin(margin_m: float | None) -> float: return min(MAX_MARGIN_M, max(SHEET_SURFACE_MARGIN_M, float(margin_m))) -def received_box(project_root: Path) -> Box | None: - """받은 DEM 범위 — 받은 적 없거나 읽지 못하면 None.""" - meta = _read_json(models_dir_of(project_root) / TERRAIN_META) or {} +def received_meta(project_root: Path) -> dict[str, Any]: + """받은 범위 기록(`box` · `margin_m` · `epsg` · `center`) — 없으면 빈 dict.""" + return _read_json(models_dir_of(project_root) / TERRAIN_META) or {} + + +def received_box(project_root: Path, epsg: int | None = None) -> Box | None: + """받은 DEM 범위 — 받은 적 없거나 읽지 못하거나 다른 좌표계(`epsg` 줄 때)면 None.""" + meta = received_meta(project_root) + if epsg is not None and meta.get("epsg") not in (None, int(epsg)): + return None try: x0, y0, x1, y1 = (float(v) for v in meta["box"]) except (KeyError, TypeError, ValueError): @@ -99,6 +112,18 @@ def wanted_box(points: list[XY], margin_m: float, have: Box | None) -> Box: return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3]) +def cells_of(box: Box) -> int: + """DEM 칸 수 어림 — 받기 시간 · 메모리 가늠 · 상한 판정.""" + return int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2) + + +def distance_to_box(xy: XY, box: Box) -> float: + """점 ~ 상자 거리(m · 안이면 0).""" + dx = max(box[0] - xy[0], 0.0, xy[0] - box[2]) + dy = max(box[1] - xy[1], 0.0, xy[1] - box[3]) + return math.hypot(dx, dy) + + def covers(have: Box | None, box: Box) -> bool: return ( have is not None @@ -135,7 +160,7 @@ def plan_extent( ) -> dict[str, Any]: """받기 전 범위 보기 — 내려받기 없음. `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m).""" margin = clamp_margin(margin_m) - have = received_box(project_root) + have = received_box(project_root, epsg) box = wanted_box(points, margin, have) store = get_project_map_sheets_dir(project_root) sheets = [] @@ -157,8 +182,8 @@ def plan_extent( "have": box_ring(have, epsg) if have else None, "covered": covers(have, box), "sheets": sheets, - # DEM 칸 수 어림 — 받기 시간 · 메모리 가늠 - "cells": int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2), + "cells": cells_of(box), + "max_cells": MAX_CELLS, } @@ -181,10 +206,12 @@ def _read_json(path: Path) -> dict[str, Any] | None: return None -def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[str]: - """병합 = 지난번 병합 도엽(zip 남은 것) ∪ 이번 도엽 · 열쇠 같으면 건너뜀.""" +def _merge_sheets( + processed: Path, store: Path, available: list[str], keep_old: bool = True +) -> list[str]: + """병합 = 지난번 병합 도엽(zip 남은 것 · 새로면 뺌) ∪ 이번 도엽 · 열쇠 같으면 건너뜀.""" key_path = processed / "sheet_merge.key.json" - old = (_read_json(key_path) or {}).get("sheets") or [] + old = ((_read_json(key_path) or {}).get("sheets") or []) if keep_old else [] sheets = sorted({*available, *(s for s in old if get_sheet_path(store, s) is not None)}) key = {"sheets": sheets, "parser": PARSER_VERSION} if sheets and (_read_json(key_path) != key or not any(processed.glob("도엽_*.geojson"))): @@ -193,8 +220,15 @@ def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[st return sheets -def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], epsg: int) -> str: - """위성 = 기준 도엽 도곽 ∪ 옛 범위 · 이미 덮으면 건너뜀. 돌려줌 = skip · done · failed.""" +def _satellite( + processed: Path, + prj: Path, + points: list[tuple[float, float]], + epsg: int, + keep_old: bool = True, +) -> str: + """위성 = 기준 도엽 도곽 ∪ 옛 범위(새로면 뺌) · 이미 덮으면 건너뜀. 돌려줌 = skip · done · + failed.""" from pyproj import Transformer to_metric = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True) @@ -207,7 +241,7 @@ def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], ep xs.append(x) ys.append(y) need = {"x": [min(xs), max(xs)], "y": [min(ys), max(ys)]} - meta = _read_json(processed / "vworld_satellite_meta.json") + meta = _read_json(processed / "vworld_satellite_meta.json") if keep_old else None try: old = [float(meta[k]) for k in ("x_min", "x_max", "y_min", "y_max")] if meta else None except (KeyError, TypeError, ValueError): @@ -234,8 +268,11 @@ def ensure_plan_terrain( epsg: int, margin_m: float | None = None, on_progress: Progress | None = None, + reset: bool = False, + center: tuple[float, float] | None = None, ) -> dict[str, Any]: - """사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m). + """사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m) · + `reset` = 옛 받은 범위 버리고 새로 · `center` = 위치 칸 가운데(경위도 · 기록만). 돌려줌 = 도엽(받은 · 실패 · 병합) · 위성 상태 · 받은 범위 · 모델 등록용 dict 목록. """ @@ -250,28 +287,24 @@ def ensure_plan_terrain( processed.mkdir(parents=True, exist_ok=True) prj = write_plan_prj(project_root, epsg) margin = clamp_margin(margin_m) - box = wanted_box(points, margin, received_box(project_root)) + have = None if reset else received_box(project_root, epsg) + box = wanted_box(points, margin, have) - report(5, "수치지형도 도엽 고르는 중") + report(5, "1/5 수치지형도 도엽 고르는 중") wanted = sheets_for_box(box, epsg) store = get_project_map_sheets_dir(project_root) before = {s for s in wanted if get_sheet_path(store, s) is not None} - report(10, f"수치지형도 도엽 {len(wanted) - len(before)}매 받는 중") + report(10, f"2/5 수치지형도 도엽 {len(wanted) - len(before)}매 받는 중") got = ensure_sheets(store, wanted) if not got["available"]: raise ValueError(f"수치지형도 도엽을 하나도 받지 못했습니다: {got['failed']}") - report(45, "도엽 레이어 병합 중") - merged = _merge_sheets(processed, store, list(got["available"])) - report(60, "위성 지도 받는 중") - satellite = _satellite(processed, prj, [(p[1], p[0]) for p in box_ring(box, epsg)], epsg) - report(62, "지적도 받는 중") - try: - cadastral = layers.ensure_cadastral(processed, box, epsg)["count"] - except Exception as exc: # noqa: BLE001 — 지적도는 겹 · 지형 받기를 막지 않음(켤 때 다시) - logger.warning("D 지적도 받기 실패: %s", exc) - cadastral = None + report(45, "3/5 도엽 레이어 병합 중") + merged = _merge_sheets(processed, store, list(got["available"]), keep_old=have is not None) + report(60, "4/5 위성 지도 받는 중") + ring = [(p[1], p[0]) for p in box_ring(box, epsg)] + satellite = _satellite(processed, prj, ring, epsg, keep_old=have is not None) - report(65, "사업 지형면 만드는 중") + report(65, f"5/5 사업 지형면 만드는 중 — 칸 {cells_of(box) / 1e4:.0f}만 · 수 분 걸릴 수 있음") models_dir = models_dir_of(project_root) models_dir.mkdir(parents=True, exist_ok=True) # B 절취 = 넘긴 꼭짓점 상자 + `SHEET_SURFACE_MARGIN_M` → 그만큼 안쪽으로 넘김(여유 ≥ 그 값) @@ -293,7 +326,11 @@ def ensure_plan_terrain( "사업 지형면을 만들지 못했습니다 — 범위 안 도엽 등고선 높이 확인" f"(병합 도엽 {len(merged)}매)" ) - atomic_write_json(models_dir / TERRAIN_META, {"box": list(box), "margin_m": margin}) + meta: dict[str, Any] = {"box": list(box), "margin_m": margin, "epsg": int(epsg)} + kept = None if reset else received_meta(project_root).get("center") + if center or kept: + meta["center"] = list(center or kept) + atomic_write_json(models_dir / TERRAIN_META, meta) report(100, "지형 받기 끝") return { "sheets": { @@ -303,7 +340,6 @@ def ensure_plan_terrain( "merged": merged, }, "satellite": satellite, - "cadastral": cadastral, "box_m": list(box), "models": models, } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index 2e3a0a29a..87f532158 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -6,7 +6,7 @@ * 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`. * ========================================================================== */ -import { pointColor } from "@ui/ui_template_palette"; +import { pointColor, routeColor } from "@ui/ui_template_palette"; import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import { distanceAlong, @@ -18,6 +18,7 @@ import { type HeightAt, } from "./logic/D03_PlanAnalysis_Draw"; import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit"; import { nodeOff } from "./logic/D03_PlanAnalysis_Span"; import { planColors, @@ -128,7 +129,7 @@ export function paintNetwork( context.lineJoin = "round"; network.lines.forEach((line, index) => { if (line.vertices.length < 2) return; - context.strokeStyle = index === active ? colors.active : colors.line; + context.strokeStyle = index === active ? colors.active : routeColor(index); context.lineWidth = line.branch ? 2.5 : 3; const path = (vertices: ReadonlyArray): void => { context.beginPath(); @@ -181,7 +182,7 @@ export function paintNetwork( } /** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */ -export function pointLayer(points: () => Vertex[]): PlanMapLayer { +export function pointLayer(points: () => Vertex[], names: () => string[] = () => []): PlanMapLayer { return { draw(context, map) { points().forEach((point, i) => { @@ -206,7 +207,7 @@ export function pointLayer(points: () => Vertex[]): PlanMapLayer { context.fillStyle = "#ffffff"; context.fill(); }); - planLabel(context, map, sx, sy - 38, `지점 ${i + 1}`, "bold 12px"); + planLabel(context, map, sx, sy - 38, pointLabel(names(), i), "bold 12px"); }); }, }; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts index abe6298fe..259eb0f29 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -1,57 +1,67 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Edit.ts - * D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 요구서 440 3 · 6 · 7 · 445 A4 · A5) — 쉴 때(긋기 · 재기 아님)만. + * D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 598 · 요구 463 9 · 12 · 13·16 · 15) — 쉴 때(긋기 · 재기 아님)만. * - * 고름 = 구간(조각) 묶음 + 넓이(구간 · 선 · 같은 해) — 선 누름 = 그 구간 · 이미 고른 곳 다시 누름 = 넓이 한 단 위 - * (구간 → 선 → 같은 해 → 구간) · Shift+누름 = 구간 더함 · 뺌 · 빈 곳 누름 · Esc = 풂. - * 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 보이는 화면 가로 방향) — 오른쪽으로 = 창(실선 · 다 든 구간만) · - * 왼쪽으로 = 걸침(점선 · 걸친 구간까지) · Shift = 지금 고름에 더함. - * 고른 선 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) · - * 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 · - * Delete = 고른 마크 지움 · 고른 구간이 선 통째면 그 선 지움. - * 연도 구역 마커(585-3) = 도구줄 「연도 마커」를 선 위로 끌어 놓음 = 그 자리 구간을 둘로(해 그대로 · 구역만 나눔 · - * 해는 고른 뒤 속성 판) · 끄는 동안 마름모 미리보기(못 놓음 = 경고 색) · 놓인 마크를 선 밖으로 끌어냄 = 지움(합침). - * 잠금 = 지점에 놓인 점(지점 핀을 끌면 같이 감 · 오른쪽 누름 = 지도 메뉴 591-2) · 갈래 첫 점(붙은 선 점을 끌면 같이 감). - * 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 · 넓이 = 화면 기억(저장 안 함). - * 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 `select` · `setScope` · 동작 셋을 부름). + * 고름 층 = 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체(`UI_Select`) — 노드 누름 = 노드 · 선 위 누름 = 토막 · + * 고른 것 다시 누름 = 한 단 넓힘(전체 다음 = 처음) · Shift+누름 = 토막 더함 · 뺌 · 빈 곳 누름 · Esc = 풂. + * 「같은 해」 = 왼쪽 해 줄로만(`selectYear`). + * 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 토막) — 오른쪽으로 = 창(실선 · 다 든 토막만) · + * 왼쪽으로 = 걸침(점선 · 걸친 토막까지) · Shift = 지금 고름에 더함. + * 노드 끌기 = 옮김(붙은 갈래 첫 점 같이) · 지점 자리 노드 = 지점도 같이 · 지점 12 px 안에 놓으면 그 자리로 붙음 · + * 고른 토막 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 옮김 · + * 선 밖으로 끌어냄 = 지움(합침). + * 지움 = Delete · 속성 판 · 왼쪽 목록 · 우클릭 메뉴가 같은 함수 — 노드 · 토막(가운데 = 구역 둘) · 구역 · 전체 · 마크. + * 우클릭 = 메뉴(`UI_Menu` — 이 겹은 오른쪽 누름을 안 받음). + * 연도 구역 마커(585-3) = 「연도 마커」를 선 위로 끌어 놓음 · 메뉴 「여기서 연도 구역 나눔」(`splitAt`) = 그 자리 구간을 둘로. + * 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 = 화면 기억(저장 안 함). + * 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 동작을 부름). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { themeColor } from "@ui/ui_template_palette"; import { keyIs } from "../A00_Common/key_registry"; -import { - distanceAlong, - lineLength, - pointAtDistance, - sliceLine, -} from "./logic/D03_PlanAnalysis_Draw"; +import { distanceAlong, pointAtDistance } from "./logic/D03_PlanAnalysis_Draw"; import { insertVertex, moveMark, + moveNodesAt, moveVertex, - piecesInBox, removeLine, removeMark, removeVertex, - reverseLine, samePoint, splitPiece, - branchFromEnd, type PlanNetwork, type Vertex, type ViewBox, } from "./logic/D03_PlanAnalysis_Plan"; -import { lineAt, paintMark, planLabel, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; +import { + movePointsAt, + nodeSegment, + removeAll, + removeSegments, + segmentsInBox, + type PlanNode, + type PlanSegment, +} from "./logic/D03_PlanAnalysis_Select"; +import { + lineAt, + paintMark, + planLabel, + screenGap, + SNAP_PX, + strokeCased, +} from "./D03_PlanAnalysis_UI_DrawPaint"; import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; +import { createPlanSelect, type PlanScope, type PlanSelection } from "./D03_PlanAnalysis_UI_Select"; + +export type { PlanScope, PlanSelection } from "./D03_PlanAnalysis_UI_Select"; /** 끌기로 볼 마우스 움직임(px) — 이보다 적으면 누름. */ const DRAG_PX = 4; /** 마크를 선에서 이만큼(px) 넘게 끌어내면 지움. */ const MARK_OFF_PX = 3 * SNAP_PX; -export type PlanScope = "piece" | "line" | "year"; -const NEXT_SCOPE: Record = { piece: "line", line: "year", year: "piece" }; - export interface PlanEditParams { network: () => PlanNetwork; /** 지점(긋기에 붙는 점 · 591-2). */ @@ -59,6 +69,9 @@ export interface PlanEditParams { commit: (next: PlanNetwork) => void; /** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */ show: (next: PlanNetwork) => void; + /** 지점 자리 노드를 끎 — 지점 · 노선 한 걸음 · 화면만. */ + commitPoints: (points: Vertex[], next: PlanNetwork) => void; + showPoints: (points: Vertex[], next: PlanNetwork) => void; /** 고르기 받는 때(쉴 때 · 재기 아님). */ enabled: () => boolean; /** 고치기 됨(산림청). */ @@ -67,151 +80,123 @@ export interface PlanEditParams { onSelect: () => void; } -/** 고름 — 구간 id 묶음 · 넓이 · 넓힘 씨(넓이를 바꿀 때 기준 구간) · 고른 마크(그 마크가 여는 구간 id). */ -export interface PlanSelection { - pieces: string[]; - scope: PlanScope; - seed: string | null; +/** 누른 자리 — 마크(구간 id) · 노드 · 토막(선 위 · 노드면 넓힐 토막). */ +export interface PlanHit { mark: string | null; + node: PlanNode | null; + segment: PlanSegment | null; } export interface PlanEdit { layer: PlanMapLayer; selected: () => PlanSelection | null; - /** 구간 id 묶음 고름(빈 묶음 = 풂) · 넓이 = 구간. */ + /** 밝힐 꺾은선(고른 토막 · 구역 · 전체 · 구간). */ + paths: () => Vertex[][]; + /** 구간 id 묶음 고름(빈 묶음 = 풂) · 마크 고름. */ select: (pieces: string[], mark?: string | null) => void; - /** 씨 구간 기준 넓이 바꿈(구간 · 선 · 같은 해). */ + /** 구간 고름 넓이 바꿈(구간 · 선 · 같은 해 — 왼쪽 목록). */ setScope: (scope: PlanScope) => void; - /** 고른 구간이 든 선 하나 — 방향 바꾸기 · 지움(선 둘 이상 · 없음 = 까닭 글). */ - flipLine: () => void; - dropLine: () => void; + selectNode: (node: PlanNode) => void; + selectRegion: (line: string) => void; + selectYear: (year: number) => void; + selectAll: () => void; + /** 고른 것 지움(Delete · 판 [지움]). */ + dropSelected: () => void; + dropNode: (node: PlanNode) => void; dropMark: () => void; - /** 갈래 시점 지정 — 다른 선에 닿은 끝을 시점으로 그 선의 갈래(585-3). */ - branchLine: () => void; - /** 고른 구간이 모두 한 선이면 그 선 차례(아니면 −1). */ - lineIndex: () => number; + dropRegion: (line: string) => void; + /** 전체 지움 — 선 · 구역 모두(지점 남김) · 되돌리기 한 걸음. */ + dropAll: () => void; + /** 이 자리 선 위에 연도 구역 마커 — 놓음 = 참. */ + splitAt: (px: number, py: number, map: ScreenMap) => boolean; + /** 이 자리에 무엇이 있나(우클릭 메뉴). */ + hit: (px: number, py: number, map: ScreenMap) => PlanHit; /** 키 — 받았으면 참(Delete · Esc). */ key: (event: KeyboardEvent) => boolean; - /** 되돌리기 · 저장 뒤 — 사라진 구간은 뺌 · 다 사라지면 풂. */ + /** 되돌리기 · 저장 뒤 — 사라진 것은 뺌 · 다 사라지면 풂. */ sync: () => void; /** 검증 창구 — 지금 끄는 상자(보이는 px · 방향). */ box: () => (ViewBox & { crossing: boolean }) | null; } type Drag = - | { kind: "vertex"; line: number; vertex: number; before: PlanNetwork; last: PlanNetwork | null } - | { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null } - | { kind: "locked"; note: string; told: boolean } - /** 고른 구간 다시 누름 — 놓으면 넓힘 · 끌면(고칠 수 있을 때) 꺾임점 더해 옮김. */ | { - kind: "grow"; - piece: string; + kind: "vertex"; + line: number; + vertex: number; + /** 끌기 앞 자리 · 같이 가는 지점(같은 자리)이 있나. */ + from: Vertex; + follow: boolean; + before: PlanNetwork; + points: Vertex[]; + last: [Vertex[], PlanNetwork] | null; + } + | { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null } + /** 노드 · 선 누름 — 놓으면(고른 것이었으면) 넓힘 · 끌면 노드 옮김 · 고른 토막이면 꺾임점 더해 옮김. */ + | { + kind: "press"; x: number; y: number; - hit: { line: number; segment: number; point: Vertex } | null; + node: PlanNode | null; + on: { line: number; segment: number; point: Vertex } | null; + grow: boolean; } /** 빈 곳 끌기 — 보이는 px 시작 · 지금. */ | { kind: "box"; box: ViewBox; shift: boolean }; export function createPlanEdit(params: PlanEditParams): PlanEdit { - let selected: PlanSelection | null = null; + const sel = createPlanSelect(params.network, params.onSelect); let drag: Drag | null = null; /** 「연도 마커」 끄는 중 미리보기 — 놓일 자리 · 놓을 수 있음 · 글. */ let markDrop: { point: Vertex; ok: boolean; text: string } | null = null; - const pieceIds = (network: PlanNetwork): Set => - new Set(network.pieces.map((piece) => piece.id)); + const lineIndexOf = (network: PlanNetwork, id: string): number => + network.lines.findIndex((line) => line.id === id); - /** 씨 구간 → 넓이만큼 구간 묶음. */ - function expand(seed: string, scope: PlanScope): string[] { - const network = params.network(); - const piece = network.pieces.find((p) => p.id === seed); - if (!piece) return []; - if (scope === "piece") return [seed]; - return network.pieces - .filter((p) => (scope === "line" ? p.line === piece.line : p.year === piece.year)) - .map((p) => p.id); - } - - function choose(next: PlanSelection | null): void { - selected = next && next.pieces.length ? next : null; - params.onSelect(); - } - - function select(pieces: string[], mark: string | null = null): void { - choose({ pieces: [...new Set(pieces)], scope: "piece", seed: pieces[0] ?? null, mark }); - } - - function setScope(scope: PlanScope): void { - const seed = selected?.seed ?? selected?.pieces[0]; - if (!seed) return; - choose({ pieces: expand(seed, scope), scope, seed, mark: null }); - } - - function lineIndex(): number { - const network = params.network(); - if (!selected) return -1; - const lines = new Set( - selected.pieces.map((id) => network.pieces.find((p) => p.id === id)?.line), - ); - if (lines.size !== 1) return -1; - const [id] = lines; - return network.lines.findIndex((line) => line.id === id); - } - - /** 고른 구간의 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */ - function apply(step: (network: PlanNetwork, index: number) => PlanNetwork | string): void { - const network = params.network(); - const index = lineIndex(); + /** 꼴 바꿈 하나 — 까닭 글이면 알림. */ + function apply(next: PlanNetwork | string): void { if (!params.editable()) return; - if (index < 0) return void showToast("선 하나의 구간만 골랐을 때 됩니다.", "info"); - const next = step(network, index); if (typeof next === "string") return void showToast(next, "info"); params.commit(next); - sync(); + sel.sync(); } function dropMark(): void { const network = params.network(); - const piece = network.pieces.find((p) => p.id === selected?.mark); - if (!piece || !params.editable()) return; - params.commit(removeMark(network, piece.line, piece.from_m, 1e-3)); - sync(); + const piece = network.pieces.find((p) => p.id === sel.get()?.mark); + if (piece) apply(removeMark(network, piece.line, piece.from_m, 1e-3)); } - /** Delete — 마크 · 선 통째 고름 = 그 선. */ - function dropSelected(): void { - if (selected?.mark) return dropMark(); + function dropNode(node: PlanNode): void { const network = params.network(); - const index = lineIndex(); - const whole = - index >= 0 && - network.pieces - .filter((p) => p.line === network.lines[index].id) - .every((p) => selected!.pieces.includes(p.id)); - if (!whole) - return void showToast("선 넓이로 고르면 지웁니다(다시 누름 · 속성 판 [선]).", "info"); - apply((net, i) => removeLine(net, i)); + const index = lineIndexOf(network, node.line); + if (index >= 0) apply(removeVertex(network, index, node.vertex)); } - /** 사라진 구간 뺌 — 부르는 쪽(페이지 `refresh`)이 다시 그림(여기서 onSelect 를 부르면 돌고 돎). */ - function sync(): void { - if (!selected) return; - const ids = pieceIds(params.network()); - const pieces = selected.pieces.filter((id) => ids.has(id)); - selected = pieces.length - ? { - ...selected, - pieces, - seed: selected.seed && ids.has(selected.seed) ? selected.seed : pieces[0], - mark: selected.mark && ids.has(selected.mark) ? selected.mark : null, - } - : null; + function dropRegion(line: string): void { + const network = params.network(); + const index = lineIndexOf(network, line); + if (index >= 0) apply(removeLine(network, index)); } - const pieceAt = (network: PlanNetwork, line: string, atM: number): string | null => - network.pieces.find((p) => p.line === line && atM >= p.from_m - 1e-6 && atM <= p.to_m + 1e-6) - ?.id ?? null; + function dropAll(): void { + const network = params.network(); + if (!network.lines.length) return; + apply(removeAll(network)); + showToast("노선을 모두 지웠습니다 — 되돌리기(Ctrl+Z) · [저장] 때 정본.", "info"); + } + + /** Delete · 판 [지움] — 고름 꼴마다. */ + function dropSelected(): void { + const picked = sel.get(); + if (!picked) return; + if (picked.kind === "mark") return dropMark(); + if (picked.kind === "node") return dropNode(picked.node!); + if (picked.kind === "segment") return apply(removeSegments(params.network(), picked.segments)); + if (picked.kind === "line") return dropRegion(picked.line!); + if (picked.kind === "all") return dropAll(); + showToast("연도 구간은 지우지 않습니다 — 노드 · 선 · 구역으로 고르세요.", "info"); + } /** 화면 px 둘레 마크(조각 시작) — 조각 차례. */ function markAt(network: PlanNetwork, px: number, py: number, map: ScreenMap): number | null { @@ -245,12 +230,33 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { return best; } - /** 잠금 까닭 — 지점에 놓인 점. */ - function lockOf(network: PlanNetwork, line: number, vertex: number): string | null { - const point = network.lines[line].vertices[vertex]; - return params.base().some((p) => samePoint(p, point)) - ? "지점 자리 점은 지점 핀을 끌어 옮깁니다." - : null; + /** 지점 12 px 안이면 그 자리(노드가 붙음) — `skip` 자리 지점(같이 가는 것) 빼고. */ + function pointNear(px: number, py: number, map: ScreenMap, skip: Vertex): Vertex | null { + const near = params + .base() + .filter((p) => !samePoint(p, skip)) + .map((p) => ({ p, gap: screenGap(map, p, px, py) })) + .filter((item) => item.gap <= SNAP_PX) + .sort((a, b) => a.gap - b.gap)[0]; + return near ? near.p : null; + } + + function hit(px: number, py: number, map: ScreenMap): PlanHit { + const network = params.network(); + const mark = markAt(network, px, py, map); + if (mark !== null) return { mark: network.pieces[mark].id, node: null, segment: null }; + const vertex = vertexAt(network, px, py, map); + if (vertex) { + const node = { line: network.lines[vertex.line].id, vertex: vertex.vertex }; + return { mark: null, node, segment: nodeSegment(network, node) }; + } + const on = lineAt(network, px, py, map); + if (!on) return { mark: null, node: null, segment: null }; + return { + mark: null, + node: null, + segment: { line: network.lines[on.line].id, index: on.segment }, + }; } /** 「연도 마커」를 이 자리에 놓으면 — 다음 꼴 · 새 뒤 구간 id(못 놓음 = null). */ @@ -260,66 +266,62 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { map: ScreenMap, ): { point: Vertex; next: PlanNetwork | null; piece: string | null } { const network = params.network(); - const hit = lineAt(network, px, py, map); - if (!hit) return { point: map.toMetric(px, py), next: null, piece: null }; - const next = splitPiece(network, network.lines[hit.line].id, hit.atM); + const on = lineAt(network, px, py, map); + if (!on) return { point: map.toMetric(px, py), next: null, piece: null }; + const next = splitPiece(network, network.lines[on.line].id, on.atM); const added = next.pieces.find((piece) => !network.pieces.some((p) => p.id === piece.id)); - return { point: hit.point, next: added ? next : null, piece: added?.id ?? null }; + return { point: on.point, next: added ? next : null, piece: added?.id ?? null }; } - /** 구간 누름 — Shift = 더함 · 뺌 · 이미 고른 곳 = 넓힘(놓을 때) · 아니면 그 구간. */ - function pressPiece( - piece: string, - shift: boolean, - x: number, - y: number, - hit: { line: number; segment: number; point: Vertex } | null, - ): void { - if (shift && selected) { - const has = selected.pieces.includes(piece); - const pieces = has - ? selected.pieces.filter((id) => id !== piece) - : [...selected.pieces, piece]; - return choose({ pieces, scope: "piece", seed: has ? pieces[0] : piece, mark: null }); + function splitAt(px: number, py: number, map: ScreenMap): boolean { + if (!params.enabled() || !params.editable()) return false; + const place = markPlace(px, py, map); + if (!place.next || !place.piece) { + showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info"); + return false; } - if (selected?.pieces.includes(piece) && !selected.mark) { - drag = { kind: "grow", piece, x, y, hit }; - return; - } - select([piece]); + params.commit(place.next); + sel.pieces([place.piece], place.piece); + return true; } - /** 고른 선 꼭짓점 손잡이 그릴 선(고른 구간이 든 선). */ - const pickedLines = (network: PlanNetwork): Set => - new Set( - (selected?.pieces ?? []).map((id) => network.pieces.find((p) => p.id === id)?.line ?? ""), - ); + /** 노드 끌기 시작 — 지점 자리면 지점도 같이. */ + function startVertex(line: number, vertex: number): Drag { + const before = params.network(); + const from = before.lines[line].vertices[vertex]; + const points = params.base(); + const follow = points.some((p) => samePoint(p, from)); + return { kind: "vertex", line, vertex, from, follow, before, points, last: null }; + } + + /** 고른 노드 · 토막 · 구역이 든 선(꺾임점 손잡이). */ + function pickedLines(network: PlanNetwork): Set { + const picked = sel.get(); + if (!picked) return new Set(); + if (picked.kind === "all") return new Set(network.lines.map((line) => line.id)); + const ids = picked.segments.map((seg) => seg.line); + for (const id of picked.pieces) ids.push(network.pieces.find((p) => p.id === id)?.line ?? ""); + if (picked.line) ids.push(picked.line); + return new Set(ids); + } const layer: PlanMapLayer = { draw(context, map) { const network = params.network(); const colors = planColors(); - const path = (points: Vertex[]): void => { + const picked = sel.get(); + context.lineCap = "round"; + context.lineJoin = "round"; + for (const points of sel.paths()) { context.beginPath(); points.forEach((point, i) => { const [sx, sy] = map.toScreen(point); if (i) context.lineTo(sx, sy); else context.moveTo(sx, sy); }); - context.stroke(); - }; - context.lineCap = "round"; - context.lineJoin = "round"; - context.strokeStyle = colors.active; - context.globalAlpha = 0.8; - context.lineWidth = 8; - for (const id of selected?.pieces ?? []) { - const piece = network.pieces.find((p) => p.id === id); - const vertices = network.lines.find((line) => line.id === piece?.line)?.vertices; - if (piece && vertices) path(sliceLine(vertices, piece.from_m, piece.to_m)); + strokeCased(context, colors.active, 8); // 밝은 바탕 대비 = 검은 테(600-1) } - context.globalAlpha = 1; - // 꺾임점 손잡이(고른 구간이 든 선) + // 꺾임점 손잡이(고른 것이 든 선) const lines = pickedLines(network); context.lineWidth = 2; context.fillStyle = "#ffffff"; @@ -331,11 +333,16 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { context.strokeRect(sx - 4, sy - 4, 8, 8); } } - // 고른 마크 테 - const mark = network.pieces.find((p) => p.id === selected?.mark); + // 고른 노드 · 마크 테 + const node = picked?.kind === "node" ? picked.node : null; + const nodePoint = node + ? network.lines.find((line) => line.id === node.line)?.vertices[node.vertex] + : null; + const mark = network.pieces.find((p) => p.id === picked?.mark); const markLine = network.lines.find((line) => line.id === mark?.line)?.vertices; - if (mark && markLine) { - const [sx, sy] = map.toScreen(pointAtDistance(markLine, mark.from_m)); + const ring = nodePoint ?? (mark && markLine ? pointAtDistance(markLine, mark.from_m) : null); + if (ring) { + const [sx, sy] = map.toScreen(ring); context.beginPath(); context.arc(sx, sy, 12, 0, Math.PI * 2); context.lineWidth = 3; @@ -378,48 +385,28 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { } }, press(px, py, map, button, shift) { - if (!params.enabled()) return false; + if (!params.enabled() || button !== 0) return false; // 우클릭 = 메뉴(UI_Menu) const network = params.network(); - const editable = params.editable(); const mark = markAt(network, px, py, map); - const vertex = vertexAt(network, px, py, map); - if (button === 2) { - if (!editable) return false; - if (mark !== null) { - const piece = network.pieces[mark]; - params.commit(removeMark(network, piece.line, piece.from_m, 1e-3)); - sync(); - return true; - } - if (!vertex || lockOf(network, vertex.line, vertex.vertex)) return false; // 지점 = 지도 메뉴 - const next = removeVertex(network, vertex.line, vertex.vertex); - if (typeof next === "string") showToast(next, "info"); - else params.commit(next); - sync(); - return true; - } if (mark !== null) { const piece = network.pieces[mark]; - select([piece.id], piece.id); - if (editable) drag = { kind: "mark", index: mark, before: network, last: null }; + sel.pieces([piece.id], piece.id); + if (params.editable()) drag = { kind: "mark", index: mark, before: network, last: null }; return true; } - if (vertex) { - const id = network.lines[vertex.line].id; - const atM = lineLength(network.lines[vertex.line].vertices.slice(0, vertex.vertex + 1)); - const piece = pieceAt(network, id, atM); - if (piece && !selected?.pieces.includes(piece)) pressPiece(piece, shift, px, py, null); - const lock = lockOf(network, vertex.line, vertex.vertex); - if (editable && !shift) - drag = lock - ? { kind: "locked", note: lock, told: false } - : { kind: "vertex", ...vertex, before: network, last: null }; - return true; - } - const hit = lineAt(network, px, py, map); - if (hit) { - const piece = pieceAt(network, network.lines[hit.line].id, hit.atM); - if (piece) pressPiece(piece, shift, px, py, hit); + const at = hit(px, py, map); + if (at.segment) { + if (shift) return (sel.segments([at.segment], true), true); + const grow = sel.holds({ + node: at.node ?? undefined, + segment: at.node ? undefined : at.segment, + }); + if (!grow) { + if (at.node) sel.node(at.node); + else sel.segments([at.segment]); + } + const on = at.node ? null : lineAt(network, px, py, map); + drag = { kind: "press", x: px, y: py, node: at.node, on, grow }; return true; } const [vx, vy] = map.rerotate(px, py); @@ -432,31 +419,34 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { [drag.box.x1, drag.box.y1] = map.rerotate(px, py); return; } - if (drag.kind === "grow") { + if (drag.kind === "press") { if (Math.hypot(px - drag.x, py - drag.y) <= DRAG_PX) return; - const hit = drag.hit; - if (!params.editable() || !hit) return void (drag = null); - params.commit(insertVertex(params.network(), hit.line, hit.segment, hit.point)); - const before = params.network(); - drag = { kind: "vertex", line: hit.line, vertex: hit.segment + 1, before, last: null }; - } - if (drag.kind === "locked") { - if (!drag.told) showToast(drag.note, "info"); - drag.told = true; - return; + const { node, on, grow } = drag; + const network = params.network(); + if (!params.editable()) return void (drag = null); + if (node) drag = startVertex(lineIndexOf(network, node.line), node.vertex); + else if (grow && on) { + params.commit(insertVertex(network, on.line, on.segment, on.point)); + drag = startVertex(on.line, on.segment + 1); + } else return void (drag = null); } const cursor = map.toMetric(px, py); if (drag.kind === "vertex") { - drag.last = moveVertex(drag.before, drag.line, drag.vertex, cursor); - } else { - const piece = drag.before.pieces[drag.index]; - const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices; - const atM = distanceAlong(vertices, cursor); - const off = screenGap(map, pointAtDistance(vertices, atM), px, py) > MARK_OFF_PX; - drag.last = off - ? removeMark(drag.before, piece.line, piece.from_m, 1e-3) - : moveMark(drag.before, drag.index, atM); + const to = pointNear(px, py, map, drag.from) ?? cursor; + drag.last = drag.follow + ? [movePointsAt(drag.points, drag.from, to), moveNodesAt(drag.before, drag.from, to)] + : [drag.points, moveVertex(drag.before, drag.line, drag.vertex, to)]; + params.showPoints(...drag.last); + return; } + if (drag.kind !== "mark") return; + const piece = drag.before.pieces[drag.index]; + const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices; + const atM = distanceAlong(vertices, cursor); + const off = screenGap(map, pointAtDistance(vertices, atM), px, py) > MARK_OFF_PX; + drag.last = off + ? removeMark(drag.before, piece.line, piece.from_m, 1e-3) + : moveMark(drag.before, drag.index, atM); params.show(drag.last); }, dragOver(px, py, map) { @@ -476,11 +466,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { drop(px, py, map) { markDrop = null; if (!params.enabled() || !params.editable()) return false; - const place = markPlace(px, py, map); - if (!place.next || !place.piece) - return (void showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info"), true); - params.commit(place.next); - select([place.piece], place.piece); + splitAt(px, py, map); return true; }, leave() { @@ -489,56 +475,83 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { release(map) { const done = drag; drag = null; - if (!done || done.kind === "locked") return; - if (done.kind === "grow") { - if (selected) { - const scope = NEXT_SCOPE[selected.scope]; - choose({ pieces: expand(done.piece, scope), scope, seed: done.piece, mark: null }); - } + if (!done) return; + if (done.kind === "press") { + if (done.grow) sel.grow(); return; } if (done.kind === "box") { const { box, shift } = done; if (Math.hypot(box.x1 - box.x0, box.y1 - box.y0) <= DRAG_PX) { - if (!shift) choose(null); + if (!shift) sel.clear(); return; } const network = params.network(); const toView = (point: Vertex): Vertex => map.rerotate(...map.toScreen(point)); - const ids = piecesInBox(network, toView, box, box.x1 < box.x0); - select(shift && selected ? [...selected.pieces, ...ids] : ids); + const found = segmentsInBox(network, toView, box, box.x1 < box.x0); + const now = sel.get(); + sel.segments(shift && now?.kind === "segment" ? [...now.segments, ...found] : found); return; } if (!done.last) return; - params.show(done.before); - params.commit(done.last); - sync(); + if (done.kind === "vertex") { + params.showPoints(done.points, done.before); + params.commitPoints(...done.last); + } else { + params.show(done.before); + params.commit(done.last); + } + sel.sync(); }, }; + /** 왼쪽 목록 넓이 — 씨 구간(처음 고른 구간) 기준. */ + function setScope(scope: PlanScope): void { + const network = params.network(); + const seed = network.pieces.find((p) => p.id === sel.get()?.pieces[0]); + if (!seed) return; + if (scope === "line") return sel.region(seed.line); + if (scope === "piece") return sel.pieces([seed.id]); + const same = network.pieces.filter((p) => p.year === seed.year).map((p) => p.id); + sel.pieces([seed.id, ...same], null, "year"); + } + return { layer, - selected: () => selected, - select, + selected: sel.get, + paths: sel.paths, + select: (pieces, mark = null) => sel.pieces(pieces, mark), setScope, - flipLine: () => apply((network, index) => reverseLine(network, index)), - dropLine: () => apply((network, index) => removeLine(network, index)), + selectNode: sel.node, + selectRegion: sel.region, + selectYear(year) { + const ids = params + .network() + .pieces.filter((p) => p.year === year) + .map((p) => p.id); + sel.pieces(ids, null, "year"); + }, + selectAll: sel.all, + dropSelected, + dropNode, dropMark, - branchLine: () => apply((network, index) => branchFromEnd(network, index)), - lineIndex, + dropRegion, + dropAll, + splitAt, + hit, key(event) { - if (!selected || !params.enabled()) return false; + if (!sel.get() || !params.enabled()) return false; if (keyIs(event, "plan.delete") && params.editable()) { dropSelected(); return true; } if (event.key === "Escape") { - choose(null); + sel.clear(); return true; } return false; }, - sync, + sync: sel.sync, box: () => (drag?.kind === "box" ? { ...drag.box, crossing: drag.box.x1 < drag.box.x0 } : null), }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts index 9075b136a..eb9f09827 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -55,7 +55,7 @@ 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: "급경사 색(15° · 35° · 40° 이상)", on: false }, + { key: "slope", icon: "angle", title: "급경사 색(기울기 % 범위 · 범례 ⚙ 로 바꿈)", on: false }, ], [ { key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true }, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts index 54256e16e..d07d5131a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts @@ -1,6 +1,7 @@ /* ============================================================================= * D03_PlanAnalysis_UI_LayerLegend.ts * D03 바탕 겹 받은 자료 꼴 · 범례 줄 · 겹별 수(PLAN 569-2 · 581 · 584-1 · 584-3) — 켠 겹의 받은 범위 안 수 · 넓이. + * 급경사 = 고른 범위(603)마다 % · ° · 비율(1 % 칸 수로 바로 셈). * 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기). * ========================================================================== */ @@ -23,6 +24,12 @@ import { type LineLayerKey, type PlanLayerKey, } from "./D03_PlanAnalysis_UI_LayerDefs"; +import { + SLOPE_MAX_PCT, + bandRatios, + pctToDeg, + type SlopeBand, +} from "./logic/D03_PlanAnalysis_SlopeBands"; /** 선 겹 — 그릴 선 · 받은 범위 안 가닥 · 길이 m. */ export interface PreparedLines { @@ -39,7 +46,15 @@ export type Prepared = Record & { road: { all: PreparedLayer; small: PreparedLayer }; /** 올린 임도망도(584-3). */ forestRoadFile: { layer: PreparedLayer; info: PlanForestRoads }; - slope: { raster: PlanRaster; image: HTMLImageElement }; + /** 급경사(603) — 값 그림 칸 · 고른 범위 · 칠한 그림(blob 주소) · 칠하기 ms. */ + slope: { + raster: PlanRaster; + image: HTMLImageElement; + values: ImageData; + bands: SlopeBand[]; + url: string; + paintMs: number; + }; hillshade: { raster: PlanRaster; image: HTMLImageElement }; landslide: { raster: PlanRaster; image: HTMLImageElement }; /** 산사태취약지역(584-3) — 점(경위도) · 지번 맞는 필지 테두리. */ @@ -55,6 +70,19 @@ export type Prepared = Record & { 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)"; @@ -86,13 +114,7 @@ export function legendRows( data: Partial, ): LegendItem[][] { const rows: LegendItem[][] = []; - if (on.slope && data.slope) - rows.push( - data.slope.raster.bands.map((band) => ({ - label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`, - color: band.color, - })), - ); + if (on.slope && data.slope) rows.push(slopeRow(data.slope)); if (on.landslide && data.landslide) rows.push(LANDSLIDE_LEGEND); if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones)); if (on.zones && data.zones) { @@ -211,6 +233,17 @@ export const layerCounts = (data: Partial): Record => band_road_m2: data.bands.info.road.area_m2, }), ...(data.hillshade && { hillshade_px: data.hillshade.image.naturalWidth }), + ...(data.slope && { + slope_px: data.slope.image.naturalWidth, + slope_cells: (data.slope.raster.counts ?? []).reduce((sum, n) => sum + n, 0), + slope_paint_ms: data.slope.paintMs, + ...Object.fromEntries( + bandRatios(data.slope.raster.counts ?? [], data.slope.bands).map((r, i) => [ + `slope_band_${i}`, + r, + ]), + ), + }), ...(data.landslide && { landslide_px: data.landslide.image.naturalWidth }), ...Object.fromEntries( LINE_KEYS.flatMap((key) => { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index fd78809cc..91d33f7fa 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -16,6 +16,7 @@ * 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선. * 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. * 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`). + * 급경사 = 서버 값 그림을 고른 범위대로 칠함 · 범례 ⚙ → 범위 모달(603 · `D03_PlanAnalysis_UI_SlopeBands`). * GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을 * MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 · * WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈). @@ -72,6 +73,14 @@ import { import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend"; import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint"; import { createGlBase, GL_FONT, type GlImage } from "./D03_PlanAnalysis_UI_MapGl"; +import { + openSlopeBands, + paintSlope, + readSlopeBands, + slopeGear, + slopeValues, + writeSlopeBands, +} from "./D03_PlanAnalysis_UI_SlopeBands"; import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; @@ -200,9 +209,39 @@ export function createPlanLayers(opts: { gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain)); const rows = meta ? legendRows(on, data) : []; legend.replaceChildren(...rows.map((row) => createLegend(row))); + if (meta && on.slope && data.slope) legend.firstElementChild?.append(gear); legend.hidden = !rows.length; } + /** 급경사 범위(603) — 바꾸면 범례 바로 · 그림은 다시 칠함(늦은 칠하기는 버림). */ + let slopeBands = readSlopeBands(); + let slopeTurn = 0; + const gear = slopeGear(() => + openSlopeBands(slopeBands, (bands) => { + slopeBands = bands; + writeSlopeBands(bands); + if (data.slope) data.slope.bands = bands; + sync(); + void repaintSlope(); + }), + ); + async function repaintSlope(): Promise { + const got = data.slope; + if (!got) return; + const turn = ++slopeTurn; + got.paintMs = -1; // 칠하는 중(검증 창구) + const started = performance.now(); + const painted = await paintSlope(got.values, got.bands); + if (turn !== slopeTurn || data.slope !== got) return URL.revokeObjectURL(painted.url); + const old = got.url; + Object.assign(got, { image: painted.image, url: painted.url }); + got.paintMs = Math.round(performance.now() - started); + gl.setData("slope", glImage(got.raster, painted.url)); + if (old) URL.revokeObjectURL(old); + sync(); + map.redrawBase(); + } + sync(); useGl(glOn); @@ -257,9 +296,13 @@ export function createPlanLayers(opts: { ); return info; }); + // 거절은 아래 catch 가 알림 — 기다림 몫은 삼킴(처리 안 된 거절 없음) pending.set( "forestRoadFile", - job.then(() => undefined), + job.then( + () => undefined, + () => undefined, + ), ); try { return await job; @@ -353,7 +396,22 @@ export function createPlanLayers(opts: { } as GeoJsonCollection); if (info.missing.length) showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info"); - } else if (key === "slope" || key === "hillshade" || key === "landslide") { + } else if (key === "slope") { + const raster = await fetchPlanRaster(projectId, key); + const values = slopeValues(await loadImage(raster.image)); + const bands = slopeBands; + const started = performance.now(); + const painted = await paintSlope(values, bands); + if (data.slope?.url) URL.revokeObjectURL(data.slope.url); + data.slope = { raster, values, bands, ...painted, paintMs: 0 }; + data.slope.paintMs = Math.round(performance.now() - started); + gl.setData("slope", glImage(raster, painted.url)); + // 칠하는 동안 범위가 바뀜 = 새 범위로 다시 + if (bands !== slopeBands) { + data.slope.bands = slopeBands; + void repaintSlope(); + } + } else if (key === "hillshade" || key === "landslide") { const raster = await fetchPlanRaster(projectId, key); const image = await loadImage(raster.image); data[key] = { raster, image }; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index 106c4d2fa..ee67d6a4d 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -16,6 +16,7 @@ * 모두 `view` 를 바꾸고 `draw()` → 한 화면 한 번 `glCamera` 로 MapLibre 를 맞춘 뒤 캔버스를 그림. * ========================================================================== */ +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 type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -60,6 +61,9 @@ export function planColors(): { }; } +/** 줌아웃 한도(600-3) — 화면 폭 약 600 km(한반도 · 위성 z6). 휠 하한 배율 = 이 폭이 화면에 드는 값(B05 는 0.5 그대로). */ +export const MAP_MAX_VIEW_WIDTH_M = 600_000; + /** 위성이 없을 때 맞출 점 둘레로 둘 가짜 도엽 여유(m). */ const FAKE_MARGIN_M = 600; @@ -400,9 +404,11 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { function flush(): void { cancelAnimationFrame(frame); frame = 0; - if (baseDirty) glCamera(); - if (baseDirty) paint(baseContext, true); - if (topDirty) paint(context, false); + perfTime("plan:flush", () => { + if (baseDirty) perfTime("plan:glCamera", glCamera); + if (baseDirty) perfTime("plan:paintBase", () => paint(baseContext, true)); + if (topDirty) perfTime("plan:paintTop", () => paint(context, false)); + }); baseDirty = topDirty = false; } /** 다음 화면에 한 번 — 바탕까지(화면 · 겹이 바뀜) 또는 위만(마우스). */ @@ -449,10 +455,12 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { }); canvas.addEventListener("pointermove", (event) => { if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중 - const [px, py] = at(event); - const map = screenMap(); - for (const layer of layers) layer.hover?.(px, py, map); - drawTop(); + perfTime("plan:pointermove", () => { + const [px, py] = at(event); + const map = screenMap(); + for (const layer of layers) layer.hover?.(px, py, map); + drawTop(); + }); }); canvas.addEventListener("pointerleave", () => { for (const layer of layers) layer.leave?.(); @@ -496,6 +504,13 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { getMeta: () => meta, unrotateDelta: rotation.unrotateDelta, rotateWheel: rotation.wheel, + minScale: () => + meta && view.mapRect.width > 0 + ? Math.min( + 0.5, + (view.width * meta.width_meters) / (view.mapRect.width * MAP_MAX_VIEW_WIDTH_M), + ) + : 0.5, draw, }); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts index 0102c9d31..fc656d204 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts @@ -2,40 +2,72 @@ * D03_PlanAnalysis_UI_Measure.ts * D03 거리재기 겹(PLAN 562-3) — 두 점을 누르면 그 사이 길이(m) · 원지반 기울기(%) · 높이 차(m). * 셋째 누름 = 새로 잼 · Esc = 지움. 지반고 = 받은 지형 `heightAt` · 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`). + * 600-2 — 첫 누름이 노선 위면 「노선 따라」: 둘째 점 = 이어진 노선 위 가장 가까운 자리 · 길이 = 노선 따라 + * (`logic/D03_PlanAnalysis_Measure`) · 첫 누름이 선 밖이면 곧은 거리 그대로. * ========================================================================== */ import { groundSlopePct, segmentLength } from "./logic/D03_PlanAnalysis_Draw"; -import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { + nearestOnRoute, + routeSpan, + type RoutePos, + type RouteSpan, +} from "./logic/D03_PlanAnalysis_Measure"; +import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; -import { strokeCased } from "./D03_PlanAnalysis_UI_DrawPaint"; +import { lineAt, strokeCased } from "./D03_PlanAnalysis_UI_DrawPaint"; export interface PlanMeasure { layer: PlanMapLayer; /** 지움(Esc · 끔). */ clear: () => void; - /** 검증 창구 — 잰 값(두 점이 다 있으면). */ - result: () => { lengthM: number; slopePct: number | null; riseM: number | null } | null; + /** 검증 창구 — 잰 값(두 점이 다 있으면) · `alongRoute` = 노선 따라 쟀나. */ + result: () => { + lengthM: number; + slopePct: number | null; + riseM: number | null; + alongRoute: boolean; + } | null; } const fmt = (value: number, digits = 1): string => value.toFixed(digits); const signed = (value: number, digits = 1): string => `${value >= 0 ? "+" : ""}${fmt(value, digits)}`; -export function createPlanMeasure(heightAt: () => (x: number, y: number) => number): PlanMeasure { +export function createPlanMeasure( + heightAt: () => (x: number, y: number) => number, + network: () => PlanNetwork, +): PlanMeasure { let from: Vertex | null = null; let to: Vertex | null = null; let fixed = false; + /** 첫 누름이 노선 위였던 자리 · 둘째 점이 노선 위에 놓인 길. */ + let fromPos: RoutePos | null = null; + let span: RouteSpan | null = null; const measure = (a: Vertex, b: Vertex) => { const z = heightAt(); const rise = z(b[0], b[1]) - z(a[0], a[1]); + const lengthM = span ? span.lengthM : segmentLength(a, b); return { - lengthM: segmentLength(a, b), - slopePct: groundSlopePct(a, b, z), + lengthM, + slopePct: span + ? Number.isFinite(rise) && lengthM > 0 + ? (rise / lengthM) * 100 + : null + : groundSlopePct(a, b, z), riseM: Number.isFinite(rise) ? rise : null, + alongRoute: !!span, }; }; + /** 둘째 점 놓기 — 노선 위 첫 점이면 이어진 노선 위 가까운 자리 · 아니면 누른 자리. */ + const place = (point: Vertex): void => { + const near = fromPos ? nearestOnRoute(network(), point, fromPos) : null; + span = near && fromPos ? routeSpan(network(), fromPos, near) : null; + to = near && span ? near.point : point; + }; + const layer: PlanMapLayer = { draw(context, map: ScreenMap) { if (!from) return; @@ -50,18 +82,33 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb context.strokeStyle = colors.casing; context.stroke(); }; + context.lineCap = "round"; + context.lineJoin = "round"; if (to) { const [bx, by] = map.toScreen(to); context.beginPath(); - context.moveTo(ax, ay); - context.lineTo(bx, by); - strokeCased(context, colors.measure, 2.5, [6, 4]); + if (span) { + span.path.forEach((point, i) => { + const [sx, sy] = map.toScreen(point); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + strokeCased(context, colors.measure, 4); + } else { + context.moveTo(ax, ay); + context.lineTo(bx, by); + strokeCased(context, colors.measure, 2.5, [6, 4]); + } dot(ax, ay); dot(bx, by); const { lengthM, slopePct, riseM } = measure(from, to); - const text = - `${fmt(lengthM)} m · ` + - (slopePct === null ? "지형 밖" : `${signed(slopePct)} % · Δh ${signed(riseM ?? 0)} m`); + const rise = riseM === null ? "지형 밖" : `Δh ${signed(riseM)} m`; + const text = span + ? `노선 따라 ${fmt(lengthM)} m · ` + + (span.stations ? `측점 ${fmt(span.stations[0])} ~ ${fmt(span.stations[1])} m · ` : "") + + rise + : `${fmt(lengthM)} m · ` + + (slopePct === null ? "지형 밖" : `${signed(slopePct)} % · ${rise}`); const [mx, my] = [(ax + bx) / 2, (ay + by) / 2 - 14]; map.upright(context, mx, my, () => { context.font = "12px system-ui, sans-serif"; @@ -81,21 +128,26 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb if (button !== 0) return false; const point = map.toMetric(px, py); if (!from || fixed) { - [from, to, fixed] = [point, null, false]; + // 첫 누름 — 노선 위(선 8 px · 꺾임점 12 px)면 노선 따라 · 아니면 곧은 거리 + const net = network(); + const hit = lineAt(net, px, py, map); + fromPos = hit ? { line: net.lines[hit.line].id, atM: hit.atM } : null; + [from, to, fixed, span] = [hit ? hit.point : point, null, false, null]; } else { - [to, fixed] = [point, true]; + place(point); + fixed = true; } return true; }, hover(px, py, map) { - if (from && !fixed) to = map.toMetric(px, py); + if (from && !fixed) place(map.toMetric(px, py)); }, }; return { layer, clear: () => { - [from, to, fixed] = [null, null, false]; + [from, to, fixed, fromPos, span] = [null, null, false, null, null]; }, result: () => (from && to && fixed ? measure(from, to) : null), }; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts new file mode 100644 index 000000000..da66c2a69 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts @@ -0,0 +1,106 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Menu.ts + * D03 지도 우클릭 메뉴(PLAN 591-2 · 598-2 · 요구 463 16 · 22) — 부품 상자 `createMapContextMenu` 하나 · 누른 자리마다 항목. + * 긋는 중 = 긋기 끝 · 핀 = 그 지점에서 긋기 · 지우기 · 마커 = 마커 지움 · + * 노드 = 노드 지움 · 지점으로 · 이어 긋기 / 갈래 시작 · 선 위 = 갈래 시작 · 여기서 연도 구역 나눔 · 다시 긋기(599) · + * 빈 땅 = 여기서 긋기 시작 · 핀 · 노드 밖 = 지점 놓기(노드 둘레면 그 노드에 붙음). + * 우클릭 즉시 지움 없음 — 지움은 메뉴 · 고르고 Delete. B05 우클릭(`bindNodeSplice`)과 코드 안 나눔. + * 겹 = 맨 끝(맨 위) · 오른쪽 누름만 받음. + * ========================================================================== */ + +import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; +import { samePoint, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw"; +import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit"; +import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; +import type { PlanPoints } from "./D03_PlanAnalysis_UI_Points"; + +export interface PlanMenuParams { + network: () => PlanNetwork; + points: () => Vertex[]; + marks: PlanPoints; + edit: PlanEdit; + draw: PlanDraw; + editable: () => boolean; + /** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */ + respan: (line: string, from: number, to: number) => boolean; +} + +export interface PlanMenu { + layer: PlanMapLayer; + /** 메뉴를 띄울 지도 그릇(우클릭 자리 = 그릇 기준). */ + mount: (viewport: HTMLElement) => void; +} + +export function createPlanMenu(params: PlanMenuParams): PlanMenu { + const { marks, edit, draw } = params; + const menu = createMapContextMenu("d03-plan"); + let menuAt: [number, number] = [0, 0]; + + const layer: PlanMapLayer = { + draw: () => undefined, + press(px, py, map, button) { + if (button !== 2 || !params.editable()) return false; + const items: MapContextMenuItem[] = []; + const pin = marks.pinAt(px, py, map); + const at = pin === null ? edit.hit(px, py, map) : null; + const start = (x: number, y: number): void => { + const label = { + tip: "이어 긋기", + point: "지점에서 긋기 시작", + line: "갈래 시작", + empty: "여기서 긋기 시작", + }[draw.aim(x, y, map)]; + items.push([label, () => draw.startAt(x, y, map)]); + }; + if (draw.mode() === "draw" && draw.stroke()) + items.push(["긋기 끝", () => draw.setMode("idle")]); + else if (pin !== null) { + const [sx, sy] = map.toScreen(params.points()[pin]); // 핀 머리를 눌러도 지점 자리에서 + items.push([`${marks.name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, map)]); + items.push([`${marks.name(pin)} 지우기`, () => marks.remove(pin)]); + } else if (at?.mark) { + const mark = at.mark; + items.push([ + "마커 지움", + () => { + edit.select([mark], mark); + edit.dropMark(); + }, + ]); + } else if (at?.node) { + const node = at.node; + const point = params.network().lines.find((l) => l.id === node.line)!.vertices[node.vertex]; + const [sx, sy] = map.toScreen(point); + start(sx, sy); + items.push("-", ["노드 지움", () => edit.dropNode(node)]); + if (!marks.full() && !params.points().some((p) => samePoint(p, point))) + items.push(["지점으로", () => marks.place(sx, sy, map)]); + } else if (at?.segment) { + const seg = at.segment; + start(px, py); + items.push(["여기서 연도 구역 나눔", () => edit.splitAt(px, py, map)]); + items.push(["다시 긋기", () => params.respan(seg.line, seg.index, seg.index + 1)]); + } else start(px, py); + if (pin === null && !at?.node && !at?.mark && !marks.full()) + items.push("-", ["지점 놓기", () => marks.place(px, py, map)]); + menu.open(menuAt[0], menuAt[1], items); + return true; + }, + }; + + return { + layer, + mount(viewport) { + viewport.append(menu.element); + viewport.addEventListener( + "contextmenu", + (event) => { + const rect = viewport.getBoundingClientRect(); + menuAt = [event.clientX - rect.left, event.clientY - rect.top]; + }, + true, + ); + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index a685c96de..41cecdd06 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -19,10 +19,8 @@ import "./D03_PlanAnalysis_UI_Style.css"; import { ROUTES } from "@config/config_frontend"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; -import { pickFiles } from "@ui/ui_template_drop"; import { askConfirm } from "@ui/ui_template_modal"; import { createProgressCircle } from "@ui/ui_template_progress"; -import { createText } from "@ui/ui_template_text"; import { createButton, hideLoadingOverlay, @@ -42,9 +40,12 @@ import { fetchTerrainExtent, savePlanNetwork, startTerrain, + type PlaceHit, type PlanJob, type PlanNetworkView, type PlanSurface, + type TerrainAim, + type TerrainStart, } from "./D03_PlanAnalysis_Api"; import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw"; @@ -56,9 +57,11 @@ import { type PlanMapLayer, } from "./D03_PlanAnalysis_UI_Map"; import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure"; +import { createPlanMenu } from "./D03_PlanAnalysis_UI_Menu"; import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points"; import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces"; import { createPlanProps } from "./D03_PlanAnalysis_UI_Props"; +import { createPlanSite } from "./D03_PlanAnalysis_UI_Site"; import { emptyNetwork, fromServer, @@ -75,16 +78,26 @@ const POLL_MS = 1000; const INTERRUPTED = "끊김(서버 다시 켜짐 등)"; /** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */ const SITE_RADIUS_M = 1000; +/** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */ +const COVER_MAX_M = 10000; /** 되돌리기 쌓는 수. */ const UNDO_MAX = 100; -/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름)은 안 읽음. */ -const DRAFT_VERSION = 4; +/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름 · 위치 없음)은 안 읽음. */ +const DRAFT_VERSION = 5; -/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선(되돌리기 · 캐시 · 고침 판정). */ +/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */ +interface Site { + lon: number; + lat: number; + address: string | null; +} + +/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선 + 위치(되돌리기 · 캐시 · 고침 판정). */ interface Snap { network: PlanNetwork; points: Vertex[]; pointNames: string[]; + site: Site | null; } interface Draft extends Snap { @@ -100,10 +113,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { let editable = false; let points: Vertex[] = []; // 지점(긋기에 붙는 점 · 591-2) let pointNames: string[] = []; // 지점 이름(596 · 597-2) + let site: Site | null = null; // 위치(캐시 · [저장] 때 정본 596-2) + let savedSite: Site | null = null; let epsg = 0; let yearStart = new Date().getFullYear(); // 새 선 처음 해 let network: PlanNetwork = emptyNetwork(); - let savedJson = JSON.stringify({ network, points, pointNames }); // 못 읽은 채 떠남 = 고침 없음 + let savedJson = JSON.stringify({ network, points, pointNames, site }); // 못 읽은 채 떠남 = 고침 없음 let undoStack: Snap[] = []; let redoStack: Snap[] = []; let surface: PlanSurface | null = null; @@ -113,6 +128,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음) let busy = false; let failed: string | null = null; + let note = ""; // 위치 칸 알림(기준점 없음 · 못 불러옴) let measuring = false; let lastSaveMs: number | null = null; @@ -127,6 +143,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { refresh(); }, }); + const stepPoints = (nextPoints: Vertex[], next: PlanNetwork, nextNames = pointNames): void => + step({ network: next, points: nextPoints, pointNames: nextNames, site }); // 지점 한 걸음 — 위치 그대로 const edit = createPlanEdit({ network: () => network, base: () => points, @@ -135,6 +153,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { network = normalizePieces(next, yearStart); map.redraw(); }, + commitPoints: stepPoints, + showPoints: (nextPoints, next) => { + [points, network] = [nextPoints, next]; + map.redraw(); + }, enabled: () => draw.mode() === "idle" && !measuring, editable: () => editable, onSelect: () => refresh(), @@ -145,17 +168,30 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { editable: () => editable, yearStart: () => yearStart, commit, + heightAt: () => z, + epsg: () => epsg, + points: () => points, + addPoint: (at) => stepPoints([...points, at], network, [...pointNames, ""]), + commitPoints: stepPoints, + respan: draw.respan, }); - const pieces = createPlanPieces({ network: () => network, edit }); - const measure = createPlanMeasure(() => z); + const pieces = createPlanPieces({ + network: () => network, + edit, + editable: () => editable, + yearOptions: props.yearOptions, + commit, + }); + const measure = createPlanMeasure( + () => z, + () => network, + ); const marks = createPlanPoints({ points: () => points, network: () => network, names: () => pointNames, epsg: () => epsg, - draw, - commit: (nextPoints, nextNames, next) => - step({ network: next, points: nextPoints, pointNames: nextNames }), + commit: (nextPoints, nextNames, next) => stepPoints(nextPoints, next, nextNames), center: () => viewMid, show: (nextPoints, next) => { [points, network] = [nextPoints, next]; @@ -164,6 +200,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { idle: () => draw.mode() === "idle" && !measuring, editable: () => editable, }); + const menu = createPlanMenu({ + network: () => network, + points: () => points, + marks, + edit, + draw, + editable: () => editable, + respan: draw.respan, + }); // 지도 보기 가운데 — 그릴 때마다 작업 좌표로(지점 [추가] 자리 · 보기만 옮김 = 안 받음 596-4) const viewWatch: PlanMapLayer = { draw(context, screen) { @@ -191,30 +236,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { main.className = "d03-plan"; main.append(map.root); - const terrainText = createText("", "muted"); - terrainText.classList.add("d03-plan__hint"); - const retryButton = createButton({ - label: "다시 받기", - variant: "filled", - size: 28, - onClick: () => { + const siteBox = createPlanSite({ + onPick: (hit) => void pickSite(hit), + onRetry: () => { [failed, lastTried, ready] = [null, "", true]; - void ensureCovered(); + void ensureCovered(true); }, + onUpload: (files) => void uploadRoads(files), }); - const terrainCard = createPanelCard("지형", false); - const roadButton = createButton({ - label: "임도망 올리기", - variant: "ghost", - size: 28, - onClick: () => - pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, (files) => { - void backdrop.uploadRoads(files); - }), - }); - roadButton.title = - "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)"; - terrainCard.append(terrainText, retryButton, roadButton); + const siteCard = createPanelCard("위치", false); + siteCard.append(...siteBox.parts); const pointCard = createPanelCard("지점", false); pointCard.append(marks.panel); const drawCard = createPanelCard("그리기", false); @@ -232,7 +263,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { actions.append(saveButton, resetButton); const panel = document.createElement("div"); panel.className = "ui-sidebar-panel d03-plan__panel"; - panel.append(terrainCard, pointCard, drawCard, pieceCard, actions); + panel.append(siteCard, pointCard, drawCard, pieceCard, actions); attachCollapsible(panel); // 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3) mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { @@ -245,7 +276,27 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout"); const allVertices = (): Vertex[] => network.lines.flatMap((line) => line.vertices); - const snap = (): Snap => ({ network, points, pointNames }); + const snap = (): Snap => ({ network, points, pointNames, site }); + /** 위치를 옮기고 [저장] 전 — 받기 · 범위 물음에 새 기준점을 실음. */ + const moved = (): TerrainAim["center"] => + site && JSON.stringify(site) !== JSON.stringify(savedSite) + ? { lon: site.lon, lat: site.lat } + : null; + /** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */ + const around = (at: Site | null): Vertex[] => { + const tm = tmParams(epsg); + if (!at || !tm) return []; + const [x, y] = lonLatToTm(tm, at.lon, at.lat); + return [ + [x - SITE_RADIUS_M, y - SITE_RADIUS_M], + [x + SITE_RADIUS_M, y + SITE_RADIUS_M], + ]; + }; + const fitTo = (box: Vertex[]): void => { + if (!box.length) return; + map.update({ fit: box }); + map.fit(); + }; const dirty = (): boolean => JSON.stringify(snap()) !== savedJson; const keepDraft = (): void => dirty() @@ -256,10 +307,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { function step(next: Snap): void { undoStack = [...undoStack.slice(1 - UNDO_MAX), snap()]; redoStack = []; - [network, points, pointNames] = [ + [network, points, pointNames, site] = [ normalizePieces(next.network, yearStart), next.points, next.pointNames, + next.site, ]; keepDraft(); refresh(); @@ -267,14 +319,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { } function commit(next: PlanNetwork): void { - step({ network: next, points, pointNames }); + step({ network: next, points, pointNames, site }); } function undo(): void { const last = undoStack.pop(); if (!last) return; redoStack.push(snap()); - ({ network, points, pointNames } = last); + ({ network, points, pointNames, site } = last); keepDraft(); refresh(); } @@ -283,7 +335,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const next = redoStack.pop(); if (!next) return; undoStack.push(snap()); - ({ network, points, pointNames } = next); + ({ network, points, pointNames, site } = next); keepDraft(); refresh(); } @@ -297,7 +349,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { refresh(); } - /** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림 · 우클릭 메뉴(`marks.pins`)는 맨 끝(맨 위). */ + /** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림(`marks.pins`) · 우클릭 메뉴(`menu.layer`)는 맨 끝(맨 위). */ const layerList = (): PlanMapLayer[] => [ ...backdrop.layers, draw.layer, @@ -306,20 +358,23 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { ...(measuring ? [measure.layer] : []), viewWatch, marks.pins, + menu.layer, ]; /** 점들이 받은 범위 안인가. */ const inside = (list: Vertex[]): boolean => !!have && list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]); - const outside = (): boolean => !!have && !inside([...points, ...allVertices()]); /** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) · * 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */ - async function ensureCovered(): Promise { + async function ensureCovered(again = false): Promise { if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return; - const extra = [...points, ...allVertices()]; - if (inside(extra)) return; // 받을 때 서버가 여유를 더함 + const [lo, hi] = around(site); + const near = ([x, y]: Vertex): boolean => + !lo || Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M; + const extra = [...points, ...allVertices()].filter(near); + if (!again && inside(extra)) return; // 받을 때 서버가 여유를 더함 const key = JSON.stringify([have, extra]); if (key === lastTried) return; lastTried = key; @@ -340,34 +395,88 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { props.render(); pieces.render(); marks.render(); - retryButton.hidden = !editable || busy || !failed; - roadButton.hidden = !editable; - if (!busy) - terrainText.textContent = failed - ? `지형 받기 실패 — ${failed}` - : !surface - ? editable - ? "받은 지형 없음 — 기준점 둘레를 받습니다(길이 · 기울기는 지형을 받은 뒤)." - : "받은 지형 없음" - : outside() - ? "지점 또는 그은 선이 받은 지형 밖 — 이어 받습니다." - : "받은 지형 안"; + siteBox.render({ + address: site?.address ?? null, + note: failed ? `지형 받기 실패 — ${failed}` : note, + retry: !busy && !!failed, + editable, + }); saveButton.hidden = resetButton.hidden = !editable; saveButton.disabled = busy || !dirty(); } - async function download(extra: Vertex[]): Promise { + /** 받기 — 멂(받은 범위에서 5 km 넘음) · 다른 TM 띠 = 물음 한 번 뒤 옛 범위 버리고 새로(`reset`) · + * 아니오 = 안 받음(거짓). 이미 도는 일(다른 창)이면 그것을 지켜봄. */ + async function download(extra: Vertex[]): Promise { busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음) + const where: TerrainAim = { center: moved() }; try { - await startTerrain(projectId, null, extra); + let answer: TerrainStart = await startTerrain(projectId, null, extra, where); + if (answer.status === "far" || answer.status === "band") { + const band = answer.status === "band"; + const ok = await askConfirm({ + title: band ? "다른 좌표 띠로 옮김" : "멀리 옮김", + message: band ? `${answer.message} 그은 선 · 지점을 지웁니다.` : answer.message, + okLabel: "새로 받기", + danger: band, + }); + if (!ok) { + busy = false; + refresh(); + return false; + } + if (answer.status === "band") clearForBand(answer.center_epsg); + answer = await startTerrain(projectId, null, [], { ...where, reset: true }); + } } catch (error) { - // 이미 도는 일(다른 창)이면 그것을 지켜봄 if (!/이미 돌고/.test(errorText(error))) { busy = false; - return void showToast(errorText(error), "error"); + refresh(); + showToast(errorText(error), "error"); + return false; } } await follow(); + return true; + } + + /** 다른 TM 띠 — 작업 좌표가 바뀜 · 선 · 지점을 걷고 되돌리기를 비움(옛 좌표라 못 되돌림). */ + function clearForBand(next: number): void { + epsg = next; + [network, points, pointNames] = [emptyNetwork(), [], []]; + [undoStack, redoStack, have] = [[], [], null]; + map.update({ epsg, fit: around(site) }); // 맞춤 상자도 새 띠 좌표로 + map.fit(); + keepDraft(); + } + + /** 위치 칸 고름 — 기준점 캐시(한 걸음 · [저장] 때 정본) → 지도 이동 → 둘레 받기(멂 · 띠 = 물음 · 아니오 = 되돌림). */ + async function pickSite(hit: PlaceHit): Promise { + if (busy) return void showToast("지형을 받는 중 — 끝난 뒤 다시 고르세요.", "info"); + epsg ||= hit.lon < 126 ? 5185 : hit.lon < 128 ? 5186 : 5187; // 기준점 없던 프로젝트(서버 띠 규칙) + [note, failed, lastTried, ready] = ["", null, "", false]; // 걸음의 조용히 받기는 막고 아래에서 한 번 + step({ ...snap(), site: { lon: hit.lon, lat: hit.lat, address: hit.address || hit.title } }); + ready = true; + fitTo(around(site)); + if (await download([])) return; // 새 자리 둘레(center) — 지점 · 선은 끝난 뒤 조용히 받기가 + undo(); // 물음에 아니오 · 받기 못 함 = 위치 그대로 + redoStack.pop(); + fitTo(around(site)); + } + + /** 임도망 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */ + async function uploadRoads(files: File[]): Promise { + const info = await backdrop.uploadRoads(files); + const tm = tmParams(epsg); + if (!info) return; + const roads: Vertex[] = + tm && info.bounds + ? [ + lonLatToTm(tm, info.bounds[0], info.bounds[1]), + lonLatToTm(tm, info.bounds[2], info.bounds[3]), + ] + : []; + fitTo([...around(site), ...roads]); } /** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */ @@ -429,14 +538,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { ? fromServer(server.lines, server.marks, server.pieces, yearStart) : emptyNetwork(); points = view.base.points.map((p): Vertex => [p.x, p.y]); - pointNames = view.base.points.map(() => ""); // 594-1 이름 칸이 들어오면 `p.name ?? ""` + pointNames = view.base.points.map((p) => p.name ?? ""); + site = savedSite = view.base.site; if (view.base.epsg) epsg = view.base.epsg; savedJson = JSON.stringify(snap()); } function serverBody(): Parameters[1] { const { lines, pieces } = network; - return { epsg, points, lines, marks: marksOf(network), pieces }; + const body = { epsg, points, point_names: pointNames, lines, marks: marksOf(network), pieces }; + return moved() && site ? { ...body, site } : body; } /** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */ @@ -473,7 +584,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { draw.setMode("idle"); showLoadingOverlay(); try { - const body = { epsg, points, lines: [], marks: [], pieces: [] }; + const body = { epsg, points, point_names: pointNames, lines: [], marks: [], pieces: [] }; takeServer(await savePlanNetwork(projectId, body)); clearState("d03-plan", projectId); undoStack = []; @@ -506,6 +617,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { base: () => points, have: () => have, busy: () => busy, + site: () => site, + pickSite, lineNames: () => lineNames(network.lines), screenOf: map.screenOf, screenOfLonLat: map.screenOfLonLat, @@ -546,32 +659,24 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { }; window.addEventListener("keydown", onKey); map.setLayers(layerList()); - marks.mount(map.root); + menu.mount(map.root); try { editable = !!(await fetchSessionUser())?.is_forest_service; draw.setEditable(editable); const view = await fetchPlanNetwork(projectId); - const { epsg: workEpsg, site } = view.base; - if (!workEpsg) { - terrainText.textContent = "기준점이 없습니다 — 예정노선 등록에서 정하세요."; - return; + if (!view.base.epsg) { + note = "기준점이 없습니다 — 주소를 찾아 정하세요."; + ready = true; + return refresh(); } - epsg = workEpsg; + epsg = view.base.epsg; takeServer(view); const draft = readState("d03-plan", projectId); if (draft?.version === DRAFT_VERSION && draft.network && Array.isArray(draft.points)) - ({ network, points, pointNames } = draft); - const tm = tmParams(epsg); - const siteXy = site && tm ? lonLatToTm(tm, site.lon, site.lat) : null; - const around: Vertex[] = siteXy - ? [ - [siteXy[0] - SITE_RADIUS_M, siteXy[1] - SITE_RADIUS_M], - [siteXy[0] + SITE_RADIUS_M, siteXy[1] + SITE_RADIUS_M], - ] - : []; + ({ network, points, pointNames, site } = draft); const drawn = [...points, ...allVertices()]; - map.update({ fit: drawn.length ? drawn : around, epsg }); + map.update({ fit: drawn.length && !moved() ? drawn : around(site), epsg }); refresh(); await loadSurface(); refresh(); @@ -582,12 +687,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { await follow(); } else if (editable && !job.error) { // 들어올 때 받은 범위가 기준점 둘레 · 지점을 못 덮으면 바로 받기(서버가 셈 · 실패 뒤엔 [다시 받기]) - const extent = await fetchTerrainExtent(projectId, null, []); + const extent = await fetchTerrainExtent(projectId, null, [], moved()); ready = true; if (!extent.covered) await download([]); else await ensureCovered(); } } catch (error) { - terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`; + note = `불러오지 못했습니다 — ${errorText(error)}`; + refresh(); } } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts index 9cca359f9..c0824ac84 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts @@ -1,13 +1,26 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Pieces.ts - * D03 좌측 「구역 · 연도」 목록 몸(PLAN 597) — 해 줄(같은 해) · 선 머리 줄(선) · 구간 줄(구간). - * 속성 판(`UI_Props.ts`)에서 떼어냄 — 목록 = 보기 + 고르기. + * D03 좌측 「구역 · 연도」 목록 몸(PLAN 597 · 요구 463 14) — 구역 = 그은 꺾은선 하나(본선 · 갈래). + * 해 줄(같은 해) · 구역 줄(색 띠 = 지도 선과 같은 `routeColor(차례)` · 이름 칸 · 연도 고름 · 지움) · + * 구간 줄(측점 글 작게 · 연도 고름). 누름 = 고름 · 해 고침 · 이름 · 지움은 한 걸음(되돌리기 · 캐시 = 페이지). + * 연도 고름은 속성 판과 같은 셈(`setLineYear` · `setPiecesYear`) · 보기만 계정 = 값만. * ========================================================================== */ -import { createText } from "@ui/ui_template_text"; +import { createIconButton } from "@ui/ui_template_button"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { createListRow } from "@ui/ui_template_list_row"; +import { askConfirm } from "@ui/ui_template_modal"; +import { routeColor } from "@ui/ui_template_palette"; import { stationLabel } from "../common_util/common_util_station"; import { lineLength } from "./logic/D03_PlanAnalysis_Draw"; -import { lineNames, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan"; +import { + lineNames, + nameError, + setLineYear, + setPiecesYear, + type PlanNetwork, +} from "./logic/D03_PlanAnalysis_Plan"; +import { setLineName } from "./logic/D03_PlanAnalysis_PointEdit"; import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit"; import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; @@ -17,6 +30,10 @@ const meters = (m: number): string => `${m.toFixed(1)} m`; export interface PlanPiecesParams { network: () => PlanNetwork; edit: PlanEdit; + editable: () => boolean; + /** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */ + yearOptions: () => Array<{ value: string; text: string }>; + commit: (next: PlanNetwork) => void; } export interface PlanPieces { @@ -25,79 +42,190 @@ export interface PlanPieces { render: () => void; } -/** 좌측 줄 — 누름 = 고름(div · 열쇠 Enter 도). */ -const pickRow = (row: HTMLElement, onPick: () => void): HTMLElement => { - row.tabIndex = 0; - row.setAttribute("role", "button"); - row.addEventListener("click", onPick); - row.addEventListener("keydown", (event) => event.key === "Enter" && onPick()); - return row; -}; - export function createPlanPieces(params: PlanPiecesParams): PlanPieces { const { edit } = params; const list = document.createElement("div"); list.className = "d03-plan__pieces"; + let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기) + let syncs: Array<() => void> = []; + + /** 줄 안 칸 눌림은 줄 고름으로 안 번지게. */ + const isControl = (event: Event): boolean => + !!(event.target as Element).closest("input, select, button, .ui-list-row__trailing"); + + /** 줄 하나 — 누름 = 고름(열쇠 Enter 도) · 줄 안 칸은 제외. */ + const pickable = (row: HTMLElement, onPick: () => void): HTMLElement => { + row.tabIndex = 0; + row.addEventListener("click", (event) => isControl(event) || onPick()); + row.addEventListener("keydown", (event) => { + if (event.key === "Enter" && event.target === row) onPick(); + }); + return row; + }; + + /** 연도 고름 칸 — 값이 섞이면 「여러 해」. */ + function yearSelect( + years: () => number[], + label: string, + pick: (year: number) => void, + ): { root: HTMLElement; sync: () => void } { + const field = createSelectField({ + options: [], + size: 24, + ariaLabel: label, + disabled: !params.editable(), + onChange: (value) => value && pick(Number(value)), + }); + const sync = (): void => { + const set = new Set(years()); + const options = params.yearOptions(); + field.setOptions( + set.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options, + set.size > 1 ? "" : String([...set][0]), + ); + }; + sync(); + return { root: field.root, sync }; + } function render(): void { const network = params.network(); const names = lineNames(network.lines); const picked = new Set(edit.selected()?.pieces ?? []); const scope = edit.selected()?.scope; + const editable = params.editable(); + const key = JSON.stringify([ + network.lines.map((line) => line.id), + network.pieces.map((piece) => [piece.id, piece.line]), + [...picked], + scope, + editable, + params.yearOptions().length, + ]); + if (key === shown) { + syncs.forEach((sync) => sync()); + return; + } + shown = key; + syncs = []; const rows: HTMLElement[] = []; + const byYear = new Map(); for (const piece of network.pieces) byYear.set(piece.year, [...(byYear.get(piece.year) ?? []), piece]); for (const [year, pieces] of [...byYear].sort(([a], [b]) => a - b)) { - const row = document.createElement("div"); - row.className = "d03-plan__piece d03-plan__piece-year"; - row.dataset.year = String(year); - row.classList.toggle( - "is-selected", - scope === "year" && pieces.every((p) => picked.has(p.id)), - ); const length = pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0); - row.append(createText(`${year}년 · 구간 ${pieces.length} · ${meters(length)}`, "muted")); - rows.push( - pickRow(row, () => { + const row = createListRow({ + label: `${year}년`, + sub: `구간 ${pieces.length} · ${meters(length)}`, + size: 28, + active: scope === "year" && pieces.every((p) => picked.has(p.id)), + onClick: () => { edit.select([pieces[0].id]); edit.setScope("year"); - }), - ); + }, + }); + row.dataset.year = String(year); + rows.push(row); } + network.lines.forEach((line, index) => { const pieces = network.pieces.filter((piece) => piece.line === line.id); if (!pieces.length) return; - const head = document.createElement("div"); - head.className = "d03-plan__piece d03-plan__piece-head"; - head.dataset.line = line.id; - head.classList.toggle( - "is-selected", - scope === "line" && pieces.every((p) => picked.has(p.id)), + const lineNow = (): (typeof network.lines)[number] => + params.network().lines.find((entry) => entry.id === line.id) ?? line; + const piecesNow = (): typeof pieces => + params.network().pieces.filter((piece) => piece.line === line.id); + + const nameField = createInputField({ size: 24, placeholder: names[index] }); + nameField.input.value = line.name ?? ""; + nameField.input.setAttribute("aria-label", "구역 이름"); + nameField.input.dataset.role = "line-name"; + nameField.input.disabled = !editable; + nameField.input.addEventListener("input", () => + nameField.setError(nameError(nameField.input.value) ?? undefined), ); - head.append(createText(`${names[index]} · ${meters(lineLength(line.vertices))}`, "muted")); + nameField.input.addEventListener("change", () => { + const error = nameError(nameField.input.value); + nameField.setError(error ?? undefined); + const text = nameField.input.value.trim(); + if (!error && text !== (lineNow().name ?? "")) + params.commit(setLineName(params.network(), line.id, text)); + }); + + const year = yearSelect( + () => piecesNow().map((piece) => piece.year), + "구역 연도", + (value) => params.commit(setLineYear(params.network(), line.id, value)), + ); + year.root.dataset.role = "line-year"; + const drop = createIconButton({ + icon: "trash", + title: "구역 지움", + size: 24, + onClick: async () => { + const ok = await askConfirm({ + message: `「${lineNames(params.network().lines)[index]}」 구역을 지울까요?`, + okLabel: "지움", + danger: true, + }); + if (!ok) return; + edit.dropRegion(line.id); + }, + }); + drop.dataset.role = "line-drop"; + drop.hidden = !editable; + const tail = document.createElement("span"); + tail.className = "d03-plan__line-tail"; + tail.append(year.root, drop); + + const head = createListRow({ + label: "", + sub: `구간 ${pieces.length} · ${meters(lineLength(line.vertices))}`, + stripe: routeColor(index), + size: 28, + trailing: tail, + active: scope === "line" && pieces.every((p) => picked.has(p.id)), + }); + head.querySelector(".ui-list-row__label")?.replaceChildren(nameField.root); + head.dataset.line = line.id; rows.push( - pickRow(head, () => { + pickable(head, () => { edit.select([pieces[0].id]); edit.setScope("line"); }), ); + syncs.push(() => { + year.sync(); + nameField.input.placeholder = lineNames(params.network().lines)[index] ?? ""; + if (document.activeElement !== nameField.input) + nameField.input.value = lineNow().name ?? ""; + const sub = head.querySelector(".ui-list-row__sub"); + if (sub) + sub.textContent = `구간 ${piecesNow().length} · ${meters(lineLength(lineNow().vertices))}`; + }); + for (const piece of pieces) { - const row = document.createElement("div"); - row.className = "d03-plan__piece"; - row.classList.toggle("is-selected", picked.has(piece.id)); - row.dataset.piece = piece.id; - row.append( - createText( - `${station(piece.from_m)} ~ ${station(piece.to_m)} (${meters(piece.to_m - piece.from_m)})`, - "muted", - ), - createText(`${piece.year}년`, "muted"), + const pieceNow = (): (typeof pieces)[number] => + params.network().pieces.find((entry) => entry.id === piece.id) ?? piece; + const pieceYear = yearSelect( + () => [pieceNow().year], + "구간 연도", + (value) => params.commit(setPiecesYear(params.network(), [piece.id], value)), ); - rows.push(pickRow(row, () => edit.select([piece.id]))); + const row = createListRow({ + label: `${station(piece.from_m)} ~ ${station(piece.to_m)}`, + sub: meters(piece.to_m - piece.from_m), + size: 28, + trailing: pieceYear.root, + active: picked.has(piece.id), + }); + row.classList.add("d03-plan__piece-row"); + row.dataset.piece = piece.id; + rows.push(pickable(row, () => edit.select([piece.id]))); + syncs.push(pieceYear.sync); } }); - if (!rows.length) rows.push(createText("선을 그으면 구역이 생깁니다.", "muted")); list.replaceChildren(...rows); } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts index 7565f8f35..18e0b57d1 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts @@ -1,27 +1,39 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Points.ts * D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로. - * 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`). - * 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지). - * 지도 우클릭 = 메뉴(부품 상자 `createMapContextMenu`) — 핀: 여기서 긋기 · 지움 / 그 밖: 긋기 시작(빈 땅 = 새 본선 · - * 선 위 = 갈래 · 선 끝 = 이어 긋기) · 지점 놓기(꺾임점 둘레면 그 점에) · 긋는 중 = 긋기 끝. - * 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 우클릭 메뉴 · 겹 맨 끝 = 맨 위 · - * 꺾임점 · 마크 오른쪽 누름 = 고르기 겹이 먼저 지움). + * 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 이름 · 🗑) · 핀 = 늘 맨 위(`pointLayer`). + * [추가] = 지도 보기 가운데에 바로 놓음(597-2) · 줄 누름 = 그 줄만 펼침(이름 · 경도 · 위도 — 칸 떠남 · Enter 가 한 걸음). + * 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 노드 12 px 안에 놓으면 그 노드에 붙음(598-3) · + * 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지). + * 지도 우클릭 메뉴 = `D03_PlanAnalysis_UI_Menu`(핀 집기 · 놓기 · 지우기를 여기서 빌림). + * 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 겹 맨 끝 = 맨 위). * ========================================================================== */ import { createIconButton } from "@ui/ui_template_button"; -import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; +import { createButton } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; import { createListRow } from "@ui/ui_template_list_row"; import { pointColor } from "@ui/ui_template_palette"; -import { createText } from "@ui/ui_template_text"; -import { moveNodesAt, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { + moveNodesAt, + nameError, + samePoint, + type PlanNetwork, + type Vertex, +} from "./logic/D03_PlanAnalysis_Plan"; +import { + addPoint, + MAX_POINTS, + movePointTo, + parseLonLat, + pointLabel, + renamePoint, + type PointsState, +} from "./logic/D03_PlanAnalysis_PointEdit"; import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm"; -import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw"; import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; -/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */ -const MAX_POINTS = 40; /** 핀 머리 = 꼭지 위 px(`pointLayer` 그림과 같음). */ const PIN_HEAD_PX = 18; @@ -31,7 +43,6 @@ export interface PlanPointsParams { names: () => string[]; network: () => PlanNetwork; epsg: () => number; - draw: PlanDraw; /** 한 걸음 — 지점 · 이름 · 노선 함께. */ commit: (points: Vertex[], names: string[], network: PlanNetwork) => void; /** 지도 보기 가운데(작업 좌표 · 못 잼 = null) — [추가] 자리. */ @@ -49,19 +60,33 @@ export interface PlanPoints { input: PlanMapLayer; pins: PlanMapLayer; render: () => void; - /** 메뉴를 띄울 지도 그릇(우클릭 자리 = 그릇 기준). */ - mount: (viewport: HTMLElement) => void; + /** 우클릭 메뉴가 빌림 — 핀 집기 · 지점 놓기(노드 둘레면 그 노드) · 지우기 · 이름 · 더 놓을 수 있나. */ + pinAt: (px: number, py: number, map: ScreenMap) => number | null; + place: (px: number, py: number, map: ScreenMap) => void; + remove: (i: number) => void; + name: (i: number) => string; + full: () => boolean; } export function createPlanPoints(params: PlanPointsParams): PlanPoints { const list = document.createElement("div"); list.className = "d03-plan__points"; - const hint = createText("", "muted"); - hint.classList.add("d03-plan__hint"); + const addButton = createButton({ + label: "추가", + iconStart: "plus", + variant: "ghost", + size: 24, + onClick: addAtCenter, + }); + addButton.dataset.role = "points-add"; + const head = document.createElement("div"); + head.className = "d03-plan__points-head"; + head.append(addButton); const panel = document.createElement("div"); - panel.append(list, hint); - const menu = createMapContextMenu("d03-plan"); - let menuAt: [number, number] = [0, 0]; + panel.append(head, list); + let openIndex: number | null = null; // 펼친 줄(하나만) + let syncEditor: (() => void) | null = null; + let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기) let drag: { index: number; points: Vertex[]; @@ -69,7 +94,14 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { last: [Vertex[], PlanNetwork] | null; } | null = null; - const name = (i: number): string => `지점 ${i + 1}`; + const name = (i: number): string => pointLabel(params.names(), i); + const state = (): PointsState => ({ + points: params.points(), + names: params.names(), + network: params.network(), + }); + const commitState = (next: PointsState): void => + params.commit(next.points, next.names, next.network); /** 핀 집기 — 꼭지 · 머리(보이는 화면 위쪽) 둘레 · 뒤 지점(위에 그려짐) 먼저. */ function pinAt(px: number, py: number, map: ScreenMap): number | null { @@ -88,6 +120,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { } function remove(i: number): void { + openIndex = null; params.commit( params.points().filter((_, k) => k !== i), params.names().filter((_, k) => k !== i), @@ -95,61 +128,40 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { ); } - /** 지점 놓기 — 꺾임점 둘레(SNAP_PX)면 그 점 자리(노드에 붙음). */ - function place(px: number, py: number, map: ScreenMap): void { - if (params.points().length >= MAX_POINTS) return; + /** 붙음(598-3) — 화면 px 둘레(SNAP_PX) 가장 가까운 노드 자리 · `skip` 자리 노드(같이 가는 것) 빼고. */ + function nodeNear(px: number, py: number, map: ScreenMap, skip?: Vertex): Vertex | null { const node = params .network() .lines.flatMap((line) => line.vertices) + .filter((point) => !skip || !samePoint(point, skip)) .map((point) => ({ point, gap: screenGap(map, point, px, py) })) .filter((item) => item.gap <= SNAP_PX) .sort((a, b) => a.gap - b.gap)[0]; - const at = node?.point ?? map.toMetric(px, py); + return node ? node.point : null; + } + + /** 지점 놓기 — 노드 둘레면 그 노드 자리(노드에 붙음). */ + function place(px: number, py: number, map: ScreenMap): void { + if (params.points().length >= MAX_POINTS) return; + const at = nodeNear(px, py, map) ?? map.toMetric(px, py); params.commit([...params.points(), [at[0], at[1]]], [...params.names(), ""], params.network()); } - function openMenu(px: number, py: number, map: ScreenMap): boolean { - if (!params.editable()) return false; - const draw = params.draw; - const pin = pinAt(px, py, map); - const items: MapContextMenuItem[] = []; - if (draw.mode() === "draw" && draw.stroke()) - items.push(["긋기 끝", () => draw.setMode("idle")]); - else if (pin !== null) { - const [sx, sy] = map.toScreen(params.points()[pin]); // 핀 머리를 눌러도 지점 자리에서 - items.push([`${name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, map)]); - items.push([`${name(pin)} 지우기`, () => remove(pin)]); - } else { - const label = { - tip: "이어 긋기", - point: "지점에서 긋기 시작", - line: "갈래 시작", - empty: "여기서 긋기 시작", - }[draw.aim(px, py, map)]; - items.push([label, () => draw.startAt(px, py, map)]); - } - if (pin === null && params.points().length < MAX_POINTS) - items.push(["지점 놓기", () => place(px, py, map)]); - menu.open(menuAt[0], menuAt[1], items); - return true; - } - - const pins = pointLayer(params.points); - pins.press = (px, py, map, button) => (button === 2 ? openMenu(px, py, map) : false); + const pins = pointLayer(params.points, params.names); const input: PlanMapLayer = { draw: () => undefined, press(px, py, map, button) { if (button !== 0 || !params.editable() || !params.idle()) return false; const index = pinAt(px, py, map); - if (index === null) return false; + if (index === null || nodeNear(px, py, map) !== null) return false; // 노드 위 = 고르기 겹(노드 고름 · 끌면 지점 같이) drag = { index, points: params.points(), network: params.network(), last: null }; return true; }, hover(px, py, map) { if (!drag) return; - const to = map.toMetric(px, py); const from = drag.points[drag.index]; + const to = nodeNear(px, py, map, from) ?? map.toMetric(px, py); const points = drag.points.map((p, k): Vertex => (k === drag!.index ? to : p)); drag.last = [points, moveNodesAt(drag.network, from, to)]; params.show(...drag.last); @@ -163,12 +175,101 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { }, }; + /** [추가] — 지도 보기 가운데에 바로 놓고 그 줄을 펼침. */ + function addAtCenter(): void { + const at = params.center(); + if (!at || !params.editable() || params.points().length >= MAX_POINTS) return; + openIndex = params.points().length; + commitState(addPoint(state(), at)); + } + + /** 펼친 줄 — 이름 · 경도 · 위도 칸(Enter · 칸 떠남 = 한 걸음 · 잘못 쓴 값은 까닭만 보이고 안 받음). */ + function editorOf(i: number): { root: HTMLElement; sync: () => void } { + const tm = tmParams(params.epsg()); + const editable = params.editable(); + const field = (label: string, role: string, placeholder?: string) => { + const f = createInputField({ label, size: 24, placeholder }); + f.input.dataset.role = role; + f.input.disabled = !editable || (role !== "point-name" && !tm); + return f; + }; + const nameField = field("이름", "point-name", `지점 ${i + 1}`); + const lonField = field("경도", "point-lon"); + const latField = field("위도", "point-lat"); + const lonLatNow = (): [number, number] | null => { + const p = params.points()[i]; + return tm && p ? tmToLonLat(tm, p[0], p[1]) : null; + }; + const sync = (): void => { + const set = (f: ReturnType, text: string): void => { + if (document.activeElement !== f.input) f.input.value = text; + }; + set(nameField, params.names()[i] ?? ""); + const ll = lonLatNow(); + set(lonField, ll ? ll[0].toFixed(6) : ""); + set(latField, ll ? ll[1].toFixed(6) : ""); + }; + nameField.input.addEventListener("input", () => + nameField.setError(nameError(nameField.input.value.trim()) ?? undefined), + ); + nameField.input.addEventListener("change", () => { + const text = nameField.input.value.trim(); + const error = nameError(text); + nameField.setError(error ?? undefined); + if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text)); + }); + const moved = (): void => { + const parsed = parseLonLat(lonField.input.value, latField.input.value); + const bad = typeof parsed === "string"; + lonField.setError(bad && parsed.startsWith("경도") ? parsed : undefined); + latField.setError(bad && parsed.startsWith("위도") ? parsed : undefined); + if (bad || !tm) return; + const now = lonLatNow(); + if ( + now && + now[0].toFixed(6) === parsed[0].toFixed(6) && + now[1].toFixed(6) === parsed[1].toFixed(6) + ) + return; + commitState(movePointTo(state(), i, tm, parsed[0], parsed[1])); + }; + lonField.input.addEventListener("change", moved); + latField.input.addEventListener("change", moved); + const root = document.createElement("div"); + root.className = "d03-plan__point-edit"; + root.append(nameField.root, lonField.root, latField.root); + sync(); + return { root, sync }; + } + function render(): void { const tm = tmParams(params.epsg()); const editable = params.editable(); + const points = params.points(); + if (openIndex !== null && openIndex >= points.length) openIndex = null; + addButton.hidden = !editable; + addButton.disabled = points.length >= MAX_POINTS || !params.center(); + const key = `${points.length}|${openIndex}|${editable}|${tm ? 1 : 0}`; + const lonLatOf = (i: number): string | undefined => { + const ll = tm ? tmToLonLat(tm, points[i][0], points[i][1]) : null; + return ll ? `${ll[0].toFixed(6)}, ${ll[1].toFixed(6)}` : undefined; + }; + if (key === shown) { + // 틀 그대로 — 이름 · 좌표 글과 펼친 칸 값만 갈아 끼움 + points.forEach((_, i) => { + const row = list.children[i]?.querySelector(".ui-list-row"); + const label = row?.querySelector(".ui-list-row__label"); + const sub = row?.querySelector(".ui-list-row__sub"); + if (label) label.textContent = name(i); + if (sub) sub.textContent = lonLatOf(i) ?? ""; + }); + syncEditor?.(); + return; + } + shown = key; + syncEditor = null; list.replaceChildren( - ...params.points().map((point, i) => { - const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null; + ...points.map((_, i) => { const drop = createIconButton({ icon: "trash", title: `${name(i)} 지우기`, @@ -176,22 +277,36 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { onClick: () => remove(i), }); drop.hidden = !editable; - return createListRow({ + const row = createListRow({ label: name(i), - sub: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : undefined, + sub: lonLatOf(i), stripe: pointColor(i), size: 28, trailing: drop, + active: i === openIndex, }); + row.dataset.point = String(i); + row.tabIndex = 0; + const toggle = (): void => { + openIndex = openIndex === i ? null : i; + render(); + }; + row.addEventListener("click", (event) => { + if (!(event.target as Element).closest(".ui-list-row__trailing")) toggle(); + }); + row.addEventListener("keydown", (event) => { + if (event.key === "Enter" && event.target === row) toggle(); + }); + const item = document.createElement("div"); + item.append(row); + if (i === openIndex) { + const editor = editorOf(i); + syncEditor = editor.sync; + item.append(editor.root); + } + return item; }), ); - hint.textContent = !editable - ? params.points().length - ? "보기만 — 지점은 산림청이 놓습니다." - : "지점 없음" - : params.points().length - ? "핀을 끌면 그 자리 노선 점도 같이 옮깁니다 · 지도 우클릭 「지점 놓기」로 더함." - : "지도 우클릭 「지점 놓기」 — 긋기가 붙는 점(없어도 우클릭으로 긋기 시작)."; } return { @@ -199,16 +314,10 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { input, pins, render, - mount(viewport) { - viewport.append(menu.element); - viewport.addEventListener( - "contextmenu", - (event) => { - const rect = viewport.getBoundingClientRect(); - menuAt = [event.clientX - rect.left, event.clientY - rect.top]; - }, - true, - ); - }, + pinAt, + place, + remove, + name, + full: () => params.points().length >= MAX_POINTS, }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts index 0efadf9c6..e5d33520a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts @@ -1,30 +1,55 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Props.ts - * D03 고른 구간 속성 판 · 좌측 「구역 · 연도」 목록(PLAN 585-2 · 요구서 445 A4) — 오토캐드 특성 창 꼴. + * D03 고름 속성 판(PLAN 585-2 · 598 · 요구 463 13·16 · 11) — 오토캐드 특성 창 꼴 · 판 하나가 고름 꼴마다 몸을 바꿈. * - * 속성 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 — - * 머리 「구간 N · 선 M · L m」 · ✕ = 풂 · 넓이 [구간][선][같은 해] · 연도(모두 같으면 그 해 · 다르면 「여러 해」 → - * 고르면 고른 구간 모두 그 해 = 되돌리기 한 걸음) · 고른 구간 줄(누름 = 그 구간만) · - * [방향 바꾸기] [갈래 시점 지정] [선 지움](선 하나일 때) · [마크 지움](마크 고름) · 보기만 계정 = 값만. - * 측점 방향(585-3 · 요구 446 3) = 사용자가 정함 — 본선 = [방향 바꾸기] · 다른 선에 닿은 끝 = [갈래 시점 지정] - * (그 점이 시점 · 꼬리에 닿은 머리 = 한 선으로 이음) · 자동 판정 없음. + * 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 · ✕ = 풂. + * 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [노드 지움] [지점으로](그 자리에 지점 · 이미 있으면 숨김). + * 선(토막) — 측점 · 길이 · 지반 기울기 · 든 연도 · [다시 긋기](하나 · 599) [선 지움](가운데 = 구역 둘). + * 구역 — 길이 · 노드 수 · 연도(구역 모든 구간) · 구간 줄 · [구역 지움]. + * 전체 — 길이 · 구역 수 · 해마다 길이 · [전체 지움](되돌리기 됨). + * 연도 구간(왼쪽 해 · 구간 줄) — 연도(모두 같으면 그 해 · 다르면 「여러 해」) · 구간 줄 · 마커 — 앞뒤 해 · [마커 지움]. + * 연도 고름 = 고른 구간 모두 그 해 = 되돌리기 한 걸음 · 보기만 계정 = 값만. * 좌측 「구역 · 연도」 목록 = `UI_Pieces.ts`(페이지가 붙임). * ========================================================================== */ -import { createButton } from "@ui/ui_template_elements"; +import { createButton, showToast } from "@ui/ui_template_elements"; import { createFloatPanel } from "@ui/ui_template_float_panel"; import { createSelectField } from "@ui/ui_template_input"; -import { createSegment } from "@ui/ui_template_segment"; +import { createKv, type KvRow } from "@ui/ui_template_kv"; import { stationLabel } from "../common_util/common_util_station"; -import { lineNames, setPiecesYear, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan"; -import type { PlanEdit, PlanScope } from "./D03_PlanAnalysis_UI_Edit"; +import { groundSlopePct, lineLength, type HeightAt } from "./logic/D03_PlanAnalysis_Draw"; +import { + lineNames, + samePoint, + setPiecesYear, + type PlanNetwork, + type PlanPiece, + type Vertex, +} from "./logic/D03_PlanAnalysis_Plan"; +import { + placeNode, + segmentPath, + segmentPieces, + segmentRange, +} from "./logic/D03_PlanAnalysis_Select"; +import { nodeOff, nodeRecord } from "./logic/D03_PlanAnalysis_Span"; +import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm"; +import { + createDrawKindFields, + DRAW_DEFAULT_LENGTH_M, + DRAW_DEFAULT_SLOPE_PCT, + type DrawKindValue, +} from "./D03_PlanAnalysis_UI_DrawPanel"; +import type { PlanEdit, PlanSelection } from "./D03_PlanAnalysis_UI_Edit"; import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; +import { selectionLength } from "./D03_PlanAnalysis_UI_Select"; /** 조각 연도 고를 수 — 사업연도 시작부터. */ const YEAR_SPAN = 10; const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1); const meters = (m: number): string => `${m.toFixed(1)} m`; +const percent = (pct: number | null): string => (pct === null ? "-" : `${pct.toFixed(1)} %`); export interface PlanPropsParams { network: () => PlanNetwork; @@ -32,6 +57,15 @@ export interface PlanPropsParams { editable: () => boolean; yearStart: () => number; commit: (next: PlanNetwork) => void; + heightAt: () => HeightAt; + epsg: () => number; + /** 지점(노드 판 [지점으로] — 이미 있으면 숨김). */ + points: () => Vertex[]; + addPoint: (point: Vertex) => void; + /** 노드 판 방식 · 값 바꿈 — 노드 · 그 자리 지점 한 걸음. */ + commitPoints: (points: Vertex[], next: PlanNetwork) => void; + /** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */ + respan: (line: string, from: number, to: number) => boolean; } export interface PlanProps { @@ -46,7 +80,7 @@ export interface PlanProps { export function createPlanProps(params: PlanPropsParams): PlanProps { const { edit } = params; const float = createFloatPanel({ - title: "고른 구간", + title: "고름", draggable: true, onClose: () => edit.select([]), }); @@ -61,104 +95,237 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` })); } - function renderPanel(): void { - const picked = edit.selected(); - float.root.hidden = !picked; - if (!picked) return float.body.replaceChildren(); - const network = params.network(); - const names = lineNames(network.lines); - const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id)); - const lineIds = new Set(pieces.map((piece) => piece.line)); - const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0); - const title = float.root.querySelector(".ui-float-panel__title"); - if (title) title.textContent = `구간 ${pieces.length} · 선 ${lineIds.size} · ${meters(total)}`; - const editable = params.editable(); - - const scope = createSegment({ - options: [ - { value: "piece", label: "구간" }, - { value: "line", label: "선" }, - { value: "year", label: "같은 해" }, - ], - value: picked.scope, - size: 24, - ariaLabel: "고름 넓이", - onChange: (value) => edit.setScope(value as PlanScope), - }); - scope.root.dataset.role = "props-scope"; + const button = (label: string, role: string, onClick: () => void): HTMLButtonElement => { + const b = createButton({ label, variant: "ghost", size: 24, onClick }); + b.dataset.role = role; + return b; + }; + /** 연도 고름 — 구간 모두 그 해(되돌리기 한 걸음). */ + function yearField(pieces: PlanPiece[]): HTMLElement { const years = new Set(pieces.map((piece) => piece.year)); const options = yearOptions(); + const ids = pieces.map((piece) => piece.id); const year = createSelectField({ label: "연도", options: years.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options, value: years.size > 1 ? "" : String(pieces[0]?.year ?? ""), size: 24, ariaLabel: "고른 구간 연도", - disabled: !editable, + disabled: !params.editable(), onChange: (value) => - value && params.commit(setPiecesYear(params.network(), picked.pieces, Number(value))), + value && params.commit(setPiecesYear(params.network(), ids, Number(value))), }); year.select.dataset.role = "props-year"; + return year.root; + } + /** 구간 줄 — 누름 = 그 구간만. */ + function pieceRows(network: PlanNetwork, pieces: PlanPiece[]): HTMLElement { + const names = lineNames(network.lines); const rows = document.createElement("div"); rows.className = "d03-plan__props-rows"; for (const piece of pieces) { const index = network.lines.findIndex((line) => line.id === piece.line); - const row = createButton({ - label: - `${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` + + const row = button( + `${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` + `${meters(piece.to_m - piece.from_m)} · ${piece.year}년`, - variant: "ghost", - size: 24, - onClick: () => edit.select([piece.id]), - }); + "props-piece", + () => edit.select([piece.id]), + ); row.dataset.piece = piece.id; rows.append(row); } + return rows; + } - const actions = document.createElement("div"); - actions.className = "d03-plan__row"; - const index = edit.lineIndex(); - if (editable && index >= 0) { - const flip = createButton({ - label: "방향 바꾸기", - variant: "ghost", - size: 24, - onClick: edit.flipLine, - }); - flip.title = "측점 No.0 쪽(시점 쪽)을 반대 끝으로 바꿉니다."; - flip.disabled = !!network.lines[index].branch; - flip.dataset.role = "props-flip"; - const drop = createButton({ - label: "선 지움", - variant: "ghost", - size: 24, - onClick: edit.dropLine, - }); - drop.dataset.role = "props-drop"; - const branch = createButton({ - label: "갈래 시점 지정", - variant: "ghost", - size: 24, - onClick: edit.branchLine, - }); - branch.title = "다른 선에 닿은 끝을 시점(No.0)으로 · 그 선의 갈래로 붙입니다."; - branch.hidden = !!network.lines[index].branch; - branch.dataset.role = "props-branch"; - actions.append(flip, branch, drop); + /** 노드 방식 · 값(598-4 · 599-1 부품) — 방식 고름 = 바로 · 수 칸 = Enter · 칸 떠날 때 · 앞 노드 기준 다시 놓음. */ + function nodeKind(line: number, vertex: number): HTMLElement { + const record = params.network().lines[line].nodes?.[vertex] ?? null; + let value: DrawKindValue = { + kind: record?.kind ?? "free", + lengthM: record?.length_m ?? DRAW_DEFAULT_LENGTH_M, + slopePct: record?.slope_pct ?? DRAW_DEFAULT_SLOPE_PCT, + }; + const apply = (): void => { + const network = params.network(); + const next = nodeRecord(value.kind, value.lengthM, value.slopePct); + const out = placeNode(network, params.points(), line, vertex, next, params.heightAt()); + if (typeof out === "string") return void showToast(out, "info"); + if (JSON.stringify(out.network.lines) !== JSON.stringify(network.lines)) + params.commitPoints(out.points, out.network); + }; + const fields = createDrawKindFields({ + value, + onChange: (next) => { + const kindChanged = next.kind !== value.kind; + value = next; + if (kindChanged) apply(); + }, + }); + fields.root.addEventListener("change", apply); + fields.root.dataset.role = "props-node-kind"; + fields.setDisabled(!params.editable()); + return fields.root; + } + + /** 고름 꼴마다 — 머리 글 · 몸 · 단추. */ + function body( + picked: PlanSelection, + network: PlanNetwork, + ): { title: string; parts: HTMLElement[]; actions: HTMLButtonElement[] } { + const names = lineNames(network.lines); + const nameOf = (id: string | null): string => + names[network.lines.findIndex((line) => line.id === id)] ?? ""; + const z = params.heightAt(); + const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id)); + const length = selectionLength(edit.paths()); + + if (picked.kind === "node") { + const { line, vertex } = picked.node!; + const vertices = network.lines.find((l) => l.id === line)!.vertices; + const point = vertices[vertex]; + const tm = tmParams(params.epsg()); + const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null; + const height = z(point[0], point[1]); + const rows: KvRow[] = [ + { + label: "경위도", + value: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : null, + }, + { label: "지반고", value: Number.isFinite(height) ? `${height.toFixed(2)} m` : null }, + { label: "측점", value: station(lineLength(vertices.slice(0, vertex + 1))) }, + ]; + if (vertex > 0) { + const [a, b] = [vertices[vertex - 1], vertices[vertex]]; + rows.push( + { label: "앞 선 길이", value: meters(lineLength([a, b])) }, + { label: "앞 선 기울기", value: percent(groundSlopePct(a, b, z)) }, + ); + } + const entry = network.lines.find((l) => l.id === line)!; + if (nodeOff(entry, vertex, z)) rows.push({ label: "기록", value: "⚠ 지금 자리와 어긋남" }); + const parts: HTMLElement[] = [createKv(rows)]; + if (vertex > 0) parts.push(nodeKind(network.lines.indexOf(entry), vertex)); + const toPoint = button("지점으로", "props-to-point", () => + params.addPoint([point[0], point[1]]), + ); + toPoint.hidden = params.points().some((p) => samePoint(p, point)); + return { + title: `${nameOf(line)} · 노드 ${vertex + 1}`, + parts, + actions: [ + button("노드 지움", "props-drop-node", () => edit.dropNode(picked.node!)), + toPoint, + ], + }; } - if (editable && picked.mark) { - const dropMark = createButton({ - label: "마크 지움", - variant: "ghost", - size: 24, - onClick: edit.dropMark, - }); - dropMark.dataset.role = "props-drop-mark"; - actions.append(dropMark); + + if (picked.kind === "segment") { + const single = picked.segments.length === 1 ? picked.segments[0] : null; + const years = [ + ...new Set( + segmentPieces(network, picked.segments).map( + (id) => network.pieces.find((p) => p.id === id)?.year, + ), + ), + ].sort(); + const rows: KvRow[] = [{ label: "길이", value: meters(length) }]; + if (single) { + const [from, to] = segmentRange(network, single)!; + const [a, b] = segmentPath(network, single)!; + rows.unshift({ label: "측점", value: `${station(from)} ~ ${station(to)}` }); + rows.push({ label: "지반 기울기", value: percent(groundSlopePct(a, b, z)) }); + } + rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") }); + const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)]; + if (single) + actions.unshift( + button("다시 긋기", "props-respan", () => + params.respan(single.line, single.index, single.index + 1), + ), + ); + return { + title: single + ? `${nameOf(single.line)} · 선 ${single.index + 1}` + : `선 ${picked.segments.length}개`, + parts: [createKv(rows)], + actions, + }; } - float.body.replaceChildren(scope.root, year.root, rows, actions); + + if (picked.kind === "line") { + const vertices = network.lines.find((l) => l.id === picked.line)?.vertices ?? []; + return { + title: nameOf(picked.line), + parts: [ + createKv([ + { label: "길이", value: meters(length) }, + { label: "노드", value: `${vertices.length}개` }, + ]), + yearField(pieces), + pieceRows(network, pieces), + ], + actions: [button("구역 지움", "props-drop-line", () => edit.dropRegion(picked.line!))], + }; + } + + if (picked.kind === "all") { + const byYear = new Map(); + for (const p of network.pieces) + byYear.set(p.year, (byYear.get(p.year) ?? 0) + p.to_m - p.from_m); + return { + title: "전체", + parts: [ + createKv([ + { label: "길이", value: meters(length) }, + { label: "구역", value: `${network.lines.length}개` }, + ...[...byYear] + .sort(([a], [b]) => a - b) + .map(([year, m]) => ({ label: `${year}년`, value: meters(m) })), + ]), + ], + actions: [button("전체 지움", "props-drop-all", edit.dropAll)], + }; + } + + // 연도 구간 · 마커 + const lineIds = new Set(pieces.map((piece) => piece.line)); + const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0); + const parts: HTMLElement[] = [yearField(pieces), pieceRows(network, pieces)]; + const actions: HTMLButtonElement[] = []; + const mark = network.pieces.find((p) => p.id === picked.mark); + if (mark) { + const before = network.pieces.find( + (p) => p.line === mark.line && Math.abs(p.to_m - mark.from_m) < 1e-3, + ); + parts.unshift( + createKv([ + { label: "측점", value: station(mark.from_m) }, + { label: "앞 · 뒤 해", value: `${before?.year ?? "-"}년 · ${mark.year}년` }, + ]), + ); + actions.push(button("마커 지움", "props-drop-mark", edit.dropMark)); + } + return { + title: `구간 ${pieces.length} · 구역 ${lineIds.size} · ${meters(total)}`, + parts, + actions, + }; + } + + function renderPanel(): void { + const picked = edit.selected(); + float.root.hidden = !picked; + if (!picked) return float.body.replaceChildren(); + const { title, parts, actions } = body(picked, params.network()); + const head = float.root.querySelector(".ui-float-panel__title"); + if (head) head.textContent = title; + float.root.dataset.kind = picked.kind; + const row = document.createElement("div"); + row.className = "d03-plan__row"; + if (params.editable()) row.append(...actions); + float.body.replaceChildren(...parts, row); } return { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts new file mode 100644 index 000000000..282f6cc13 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts @@ -0,0 +1,236 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Select.ts + * D03 고름 상태(PLAN 598-1 · 요구 463 9 · 13) — 노드 → 선(토막) → 구역 → 전체 · 연도 구간(왼쪽 해 줄) · 마커. + * 넓힘 = 같은 것을 다시 누름 — 씨(처음 누른 노드 · 토막)에서 한 단 위 · 전체 다음 = 씨로 돌아옴. + * 「같은 해」 = 왼쪽 목록 해 줄로만(`year`). + * 상태 = 화면 기억(저장 안 함) · 꼴이 바뀌면 `sync` 가 사라진 것을 뺌. + * ========================================================================== */ + +import { lineLength, sliceLine } from "./logic/D03_PlanAnalysis_Draw"; +import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { + lineSegments, + nodeSegment, + sameSegment, + segmentPath, + segmentPieces, + type PlanNode, + type PlanSegment, +} from "./logic/D03_PlanAnalysis_Select"; + +export type PlanSelectKind = "node" | "segment" | "line" | "all" | "pieces" | "mark"; +/** 왼쪽 목록 밝힘 꼴(구간 · 선 · 같은 해). */ +export type PlanScope = "piece" | "line" | "year"; + +export interface PlanSelection { + kind: PlanSelectKind; + /** 노드 고름 · 노드 씨. */ + node: PlanNode | null; + /** 토막 고름(Shift · 끌어 고르기 = 여럿). */ + segments: PlanSegment[]; + /** 구역 고름 · 씨가 든 구역. */ + line: string | null; + /** 고름에 든 연도 구간 id(구간 · 마커 고름 = 그대로 · 그 밖 = 걸친 구간). */ + pieces: string[]; + /** 고른 마커(그 마커가 여는 구간 id). */ + mark: string | null; + scope: PlanScope; + /** 넓힘 씨 — 처음 누른 노드 · 토막. */ + seed: { node: PlanNode } | { segment: PlanSegment } | null; +} + +const NEXT: Partial> = { + node: "segment", + segment: "line", + line: "all", +}; + +export interface PlanSelect { + get: () => PlanSelection | null; + clear: () => void; + node: (node: PlanNode) => void; + /** 토막 — `toggle` = 지금 토막 고름에 더함 · 뺌. */ + segments: (segments: PlanSegment[], toggle?: boolean) => void; + region: (line: string) => void; + all: () => void; + /** 연도 구간 — 마커 고름 = 그 마커가 여는 구간. */ + pieces: (ids: string[], mark?: string | null, scope?: PlanScope) => void; + /** 넓힘 한 단(씨 기준). */ + grow: () => void; + /** 이 노드 · 토막이 지금 고름 안인가(다시 누름 = 넓힘). */ + holds: (hit: { node?: PlanNode; segment?: PlanSegment }) => boolean; + /** 밝힐 꺾은선. */ + paths: () => Vertex[][]; + sync: () => void; +} + +export function createPlanSelect(network: () => PlanNetwork, onChange: () => void): PlanSelect { + let selected: PlanSelection | null = null; + + function piecesOf(next: Omit): string[] { + const net = network(); + if (next.kind === "segment") return segmentPieces(net, next.segments); + if (next.kind === "line") + return net.pieces.filter((p) => p.line === next.line).map((p) => p.id); + if (next.kind === "all") return net.pieces.map((p) => p.id); + return []; + } + + function choose( + next: Omit & Partial, + quiet = false, + ): void { + selected = { + ...next, + pieces: next.pieces ?? piecesOf(next), + scope: next.scope ?? (next.kind === "line" ? "line" : "piece"), + }; + if (!quiet) onChange(); + } + + const empty = { node: null, segments: [], line: null, mark: null, seed: null }; + + function node(at: PlanNode): void { + choose({ ...empty, kind: "node", node: at, line: at.line, seed: { node: at } }); + } + + function segments(list: PlanSegment[], toggle = false): void { + let next = list; + if (toggle && selected?.kind === "segment") { + const has = (s: PlanSegment): boolean => selected!.segments.some((t) => sameSegment(s, t)); + next = [ + ...selected.segments.filter((s) => !list.some((t) => sameSegment(s, t))), + ...list.filter((s) => !has(s)), + ]; + } + if (!next.length) return clear(); + choose({ + ...empty, + kind: "segment", + segments: next, + line: next[0].line, + seed: { segment: next[0] }, + }); + } + + function clear(): void { + selected = null; + onChange(); + } + + /** 씨 기준 한 단 — 노드 → 토막 → 구역 → 전체 → 씨. */ + function grow(): void { + if (!selected?.seed) return; + const seed = selected.seed; + const next = NEXT[selected.kind]; + const line = "node" in seed ? seed.node.line : seed.segment.line; + if (next === "segment") { + const seg = "node" in seed ? nodeSegment(network(), seed.node) : seed.segment; + if (seg) choose({ ...empty, kind: "segment", segments: [seg], line, seed }); + } else if (next === "line") choose({ ...empty, kind: "line", line, seed }); + else if (next === "all") choose({ ...empty, kind: "all", line, seed }); + else if ("node" in seed) node(seed.node); + else choose({ ...empty, kind: "segment", segments: [seed.segment], line, seed }); + } + + function holds(hit: { node?: PlanNode; segment?: PlanSegment }): boolean { + const s = selected; + if (!s || s.kind === "pieces" || s.kind === "mark") return false; + if (s.kind === "all") return true; + const line = hit.node?.line ?? hit.segment?.line; + if (s.kind === "line") return line === s.line; + if (s.kind === "node") + return !!hit.node && hit.node.line === s.node!.line && hit.node.vertex === s.node!.vertex; + if (hit.segment) return s.segments.some((t) => sameSegment(t, hit.segment!)); + return s.segments.some( + (t) => + t.line === hit.node!.line && + (t.index === hit.node!.vertex || t.index + 1 === hit.node!.vertex), + ); + } + + function paths(): Vertex[][] { + const s = selected; + const net = network(); + if (!s || s.kind === "node") return []; + if (s.kind === "segment") + return s.segments + .map((seg) => segmentPath(net, seg)) + .filter((p): p is [Vertex, Vertex] => !!p); + if (s.kind === "line") return net.lines.filter((l) => l.id === s.line).map((l) => l.vertices); + if (s.kind === "all") return net.lines.map((l) => l.vertices); + return s.pieces.flatMap((id) => { + const piece = net.pieces.find((p) => p.id === id); + const v = net.lines.find((l) => l.id === piece?.line)?.vertices; + return piece && v ? [sliceLine(v, piece.from_m, piece.to_m)] : []; + }); + } + + /** 사라진 것 뺌 — 부르는 쪽(페이지 `refresh`)이 다시 그림(여기서 onChange 를 부르면 돌고 돎). */ + function sync(): void { + const s = selected; + if (!s) return; + const net = network(); + const vertexCount = (line: string | null): number => + net.lines.find((l) => l.id === line)?.vertices.length ?? 0; + const okNode = (n: PlanNode): boolean => n.vertex < vertexCount(n.line); + const okSeg = (g: PlanSegment): boolean => g.index + 1 < vertexCount(g.line); + if (s.kind === "pieces" || s.kind === "mark") { + const ids = new Set(net.pieces.map((p) => p.id)); + const pieces = s.pieces.filter((id) => ids.has(id)); + selected = pieces.length + ? { ...s, pieces, mark: s.mark && ids.has(s.mark) ? s.mark : null } + : null; + if (selected?.kind === "mark" && !selected.mark) selected = { ...selected, kind: "pieces" }; + return; + } + const seed = + s.seed && ("node" in s.seed ? okNode(s.seed.node) : okSeg(s.seed.segment)) ? s.seed : null; + const segments = s.segments.filter(okSeg); + const alive = { + all: net.lines.length > 0, + line: vertexCount(s.line) > 0, + node: !!s.node && okNode(s.node), + segment: segments.length > 0, + }[s.kind]; + if (alive) choose({ ...s, segments, seed, pieces: undefined }, true); + else selected = null; + } + + return { + get: () => selected, + clear, + node, + segments, + region: (line) => choose({ ...empty, kind: "line", line, seed: lineSeed(line) }), + all: () => choose({ ...empty, kind: "all", seed: null }), + pieces(ids, mark = null, scope = "piece") { + const list = [...new Set(ids)]; + if (!list.length) return clear(); + const net = network(); + choose({ + ...empty, + kind: mark ? "mark" : "pieces", + line: net.pieces.find((p) => p.id === list[0])?.line ?? null, + pieces: list, + mark, + scope, + }); + }, + grow, + holds, + paths, + sync, + }; + + /** 구역 씨 = 첫 토막(구역에서 넓힘 = 전체 → 첫 토막). */ + function lineSeed(line: string): { segment: PlanSegment } | null { + const first = lineSegments(network(), line)[0]; + return first ? { segment: first } : null; + } +} + +/** 고름 길이(m) — 토막 · 구역 · 전체 · 구간. */ +export function selectionLength(paths: Vertex[][]): number { + return paths.reduce((sum, path) => sum + lineLength(path), 0); +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts new file mode 100644 index 000000000..b788950eb --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts @@ -0,0 +1,126 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Site.ts + * D03 「위치」 칸(PLAN 596-2 · 요구 463-3~6 · 19) — 지금 기준점 주소 한 줄 · 주소 · 장소 찾기 + * (서버 VWorld `searchPlaces` · 부품 `createFindList` · Enter = 찾기 · ↑ ↓ Enter = 고름) · + * 알림 한 줄(받기 실패 · 끊김 · 기준점 없음 · 못 불러옴만) + [다시 받기] · [임도망 올리기]. + * 고름 = 페이지가 받기 물음(멂 · 다른 TM 띠) → 기준점 캐시 → 지도 이동 → 둘레 받기 · [저장] 때 정본(등록과 한 칸). + * ========================================================================== */ + +import { pickFiles } from "@ui/ui_template_drop"; +import { createButton } from "@ui/ui_template_elements"; +import { createFindList, type FindList, type FindRow } from "@ui/ui_template_find_list"; +import { t } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; +import { searchPlaces, type PlaceHit } from "./D03_PlanAnalysis_Api"; + +/** 찾기 글 최소 길이(서버 VWorld 한 글자 = 쓸모없는 후보). */ +const MIN_QUERY = 2; + +export interface PlanSiteParams { + /** 후보 고름 — 받기 · 캐시 · 이동은 페이지. */ + onPick: (hit: PlaceHit) => void; + onRetry: () => void; + onUpload: (files: File[]) => void; +} + +export interface PlanSiteState { + address: string | null; + /** 알림(실패 · 끊김 · 기준점 없음 · 오류) — 빈 값 = 숨김. */ + note: string; + retry: boolean; + editable: boolean; +} + +export interface PlanSite { + /** 칸 몸(카드에 바로 붙임 · 카드 간격을 그대로 씀). */ + parts: HTMLElement[]; + render: (state: PlanSiteState) => void; +} + +/** 주소 · 장소 찾기 칸(부품 `createFindList`) — Enter = 찾기 · ↑ ↓ Enter · 누름 = 고름 · + * 등록(D01) 지도 머리 · 이 「위치」 칸이 같이 씀. */ +export function createPlaceFind(onPick: (hit: PlaceHit) => void): FindList { + const find = createFindList({ placeholder: t("DE_Reg_Find") }); + const list = find.root.querySelector(".ui-find__list")!; + let asked = 0; // 옛 찾기 답 버림 + // 빈 알림 · 빈 목록은 숨김(칸 사이 빈 틈 없앰) + const say = (text: string): void => { + find.hint.textContent = text; + find.hint.hidden = !text; + }; + const show = (rows: FindRow[]): void => { + find.setRows(rows); + list.hidden = !rows.length; + }; + say(""); + show([]); + + async function run(): Promise { + const query = find.search.value.trim(); + show([]); + if (query.length < MIN_QUERY) return say(t("DE_Reg_FindShort")); + const at = ++asked; + say(t("DE_Reg_FindBusy")); + try { + const hits = await searchPlaces(query); + if (at !== asked) return; + say(hits.length ? "" : t("DE_Reg_FindNone")); + show( + hits.map((hit, i) => ({ + key: String(i), + tag: t(hit.kind === "place" ? "DE_Reg_FindPlace" : "DE_Reg_FindAddress"), + name: hit.title, + sub: hit.address !== hit.title ? hit.address : undefined, + onPick: () => { + show([]); + find.search.value = hit.title; + onPick(hit); + }, + })), + ); + } catch (error) { + if (at === asked) + say(`${t("DE_Reg_FindFail")} — ${error instanceof Error ? error.message : error}`); + } + } + // 고를 줄이 있으면 부품이 Enter 를 먼저 씀(고름) · 없으면 찾기 · 글을 고치면 옛 줄 걷음 + find.search.addEventListener("input", () => show([])); + find.search.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || event.defaultPrevented || event.isComposing) return; + event.preventDefault(); + void run(); + }); + return find; +} + +export function createPlanSite(params: PlanSiteParams): PlanSite { + const address = createText("", "muted", 14); + const note = createText("", "error"); + const find = createPlaceFind(params.onPick); + + const retry = createButton({ + label: "다시 받기", + variant: "filled", + size: 28, + onClick: params.onRetry, + }); + const upload = createButton({ + label: "임도망 올리기", + variant: "ghost", + size: 28, + onClick: () => + pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, params.onUpload), + }); + upload.title = "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)"; + return { + parts: [address, find.root, note, retry, upload], + render(state) { + address.textContent = state.address ?? ""; + address.hidden = !state.address; + note.textContent = state.note; + note.hidden = !state.note; + find.root.hidden = upload.hidden = !state.editable; + retry.hidden = !state.editable || !state.retry; + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts new file mode 100644 index 000000000..fb0cb92f1 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts @@ -0,0 +1,236 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_SlopeBands.ts + * D03 급경사 범위 옵션(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달 + * (범위 줄 = 아래 % · 위 % · 색 · 지움 · 칸 옆 ° 환산 · [범위 추가] · [기본값] · [적용] · 빈 값 · 겹침 막음). + * 기억 = 화면 취향(`aislo:pref:d03.slope-bands` · 계정에 붙음 · 프로젝트 무관). + * 칠하기 = 서버 값 그림(회색 · 칸 = 기울기 % 내림) → 범위 색 그림(서버 왕복 없음 · CLAUDE.md 5장). + * 범위 · 비율 셈 = `logic/D03_PlanAnalysis_SlopeBands.ts` · 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`. + * ========================================================================== */ + +import { createButton, createIconButton } from "@ui/ui_template_button"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +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 { + DEFAULT_SLOPE_BANDS, + SLOPE_COLORS, + SLOPE_MAX_BANDS, + SLOPE_MAX_PCT, + bandOf, + checkBands, + parseBands, + pctToDeg, + type SlopeBand, +} from "./logic/D03_PlanAnalysis_SlopeBands"; + +const PREF_KEY = "aislo:pref:d03.slope-bands"; +/** 칠한 칸 투명도(겹 투명도 0.55 는 따로). */ +const ALPHA = 150; + +export const readSlopeBands = (): SlopeBand[] => parseBands(readByKey(PREF_KEY)); +export const writeSlopeBands = (bands: SlopeBand[]): void => + writeByKey(PREF_KEY, JSON.stringify(bands)); + +/** 값 그림 → 칸 값(빨강 자리 = 회색 값). */ +export function slopeValues(image: HTMLImageElement): ImageData { + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d", { willReadFrequently: true })!; + context.drawImage(image, 0, 0); + return context.getImageData(0, 0, canvas.width, canvas.height); +} + +/** 범위대로 칠한 그림(주소 = blob · 다 쓰면 부르는 쪽이 `URL.revokeObjectURL`). */ +export async function paintSlope( + values: ImageData, + bands: SlopeBand[], +): Promise<{ url: string; image: HTMLImageElement }> { + const rgb = bands.map((band) => hexRgb(themeColor(band.color, "#facc15"))); + const lut = new Uint32Array(256); + const view = new DataView(lut.buffer); + for (let v = 0; v < 256; v++) { + const i = bandOf(v, bands); + if (i >= 0) view.setUint32(v * 4, ((rgb[i] << 8) | ALPHA) >>> 0, false); + } + const out = new ImageData(values.width, values.height); + const src = values.data; + const dst = new Uint32Array(out.data.buffer); + for (let p = 0; p < dst.length; p++) dst[p] = lut[src[p * 4]]; + const canvas = document.createElement("canvas"); + canvas.width = values.width; + canvas.height = values.height; + canvas.getContext("2d")!.putImageData(out, 0, 0); + const blob = await new Promise((done, fail) => + canvas.toBlob( + (got) => (got ? done(got) : fail(new Error("그림을 만들지 못했습니다"))), + "image/png", + ), + ); + const url = URL.createObjectURL(blob); + return { url, image: await loadImage(url) }; +} + +/** `#rrggbb` → 0xrrggbb(못 읽으면 노랑). */ +function hexRgb(color: string): number { + const hex = /^#?([0-9a-f]{6})$/i.exec(color.trim())?.[1]; + return hex ? parseInt(hex, 16) : 0xfacc15; +} + +/** 범례 급경사 줄 ⚙. */ +export const slopeGear = (onClick: () => void): HTMLButtonElement => { + const button = createIconButton({ + icon: "settings", + title: "급경사 범위 바꾸기(기울기 %)", + size: 20, + onClick, + }); + button.dataset.role = "slope-bands"; + return button; +}; + +const degText = (value: string): string => { + const pct = Number(value); + return value.trim() && Number.isFinite(pct) ? `≈ ${pctToDeg(pct).toFixed(1)}°` : "—"; +}; + +/** 옵션 모달 — [적용] = 막을 것 없으면 `onApply` 후 닫음. */ +export function openSlopeBands(current: SlopeBand[], onApply: (bands: SlopeBand[]) => void): void { + let rows: Array<{ root: HTMLElement; read: () => SlopeBand }> = []; + const list = document.createElement("div"); + const error = createText("", "error"); + error.hidden = true; + const addButton = createButton({ + label: "범위 추가", + variant: "ghost", + size: 28, + iconStart: "plus", + onClick: () => { + const last = rows.at(-1)?.read(); + const low = last && Number.isFinite(last.high) ? Math.min(last.high, SLOPE_MAX_PCT - 1) : 0; + const color = SLOPE_COLORS[rows.length % SLOPE_COLORS.length].color; + add({ low, high: SLOPE_MAX_PCT, color }); + }, + }); + addButton.dataset.role = "slope-band-add"; + + function refresh(): void { + addButton.disabled = rows.length >= SLOPE_MAX_BANDS; + error.hidden = true; + } + + function add(band: SlopeBand): void { + const field = (label: string, value: number) => { + const got = createInputField({ + label, + type: "number", + unit: "%", + min: 0, + max: SLOPE_MAX_PCT, + size: 28, + value: Number.isFinite(value) ? String(value) : "", + }); + const deg = createText(degText(got.input.value), "muted"); + got.input.addEventListener("input", () => { + deg.textContent = degText(got.input.value); + refresh(); + }); + got.root.append(deg); + return got; + }; + const low = field("아래", band.low); + const high = field("위", band.high); + const color = createSelectField({ + label: "색", + size: 28, + options: SLOPE_COLORS.map((c) => ({ value: c.color, text: c.name })), + value: band.color, + }); + const chip = createLegend([{ label: "", color: `var(${band.color})` }]); + color.select.addEventListener("change", () => + chip + .querySelector(".ui-legend__mark")! + .style.setProperty("--legend-color", `var(${color.select.value})`), + ); + const root = document.createElement("div"); + root.className = "d03-plan__row"; + root.dataset.role = "slope-band"; + const entry = { + root, + read: (): SlopeBand => ({ + low: low.input.value.trim() === "" ? NaN : Number(low.input.value), + high: high.input.value.trim() === "" ? NaN : Number(high.input.value), + color: color.select.value, + }), + }; + const remove = createIconButton({ + icon: "trash", + title: "이 범위 지움", + size: 28, + onClick: () => { + rows = rows.filter((row) => row !== entry); + root.remove(); + refresh(); + }, + }); + root.append(low.root, high.root, color.root, chip, remove); + rows.push(entry); + list.append(root); + refresh(); + } + + function fill(bands: SlopeBand[]): void { + rows = []; + list.replaceChildren(); + bands.forEach(add); + } + + openModal({ + title: "급경사 범위(기울기 %)", + closeLabel: "닫기", + width: "40rem", + mount: (body, close, actions) => { + fill(current); + const reset = createButton({ + label: "기본값", + variant: "ghost", + size: 28, + onClick: () => fill(DEFAULT_SLOPE_BANDS), + }); + reset.dataset.role = "slope-band-default"; + const apply = createButton({ + label: "적용", + size: 28, + onClick: () => { + const bands = rows.map((row) => row.read()); + const why = checkBands(bands); + if (why) { + error.lastChild!.textContent = why; + error.hidden = false; + return; + } + onApply(bands.sort((a, b) => a.low - b.low)); + close(); + }, + }); + apply.dataset.role = "slope-band-apply"; + actions.append(apply); + const foot = document.createElement("div"); + foot.className = "d03-plan__row"; + foot.append(addButton, reset); + body.append( + createText( + `0~${SLOPE_MAX_PCT} % · 아래 포함 · 위 뺌 · 겹치면 안 됨 · 기본 = 15 · 35 · 40°(지식DB)`, + "hint", + ), + list, + foot, + error, + ); + }, + }); +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index ed11d04f3..617aaca50 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -106,28 +106,6 @@ gap: 4px; } -.d03-plan__piece-head { - margin: 6px 0 0; - font-weight: 600; - color: var(--color-text); -} - -.d03-plan__piece { - display: flex; - gap: var(--spacing-8); - align-items: center; - justify-content: space-between; -} - -.d03-plan__piece > .ui-text { - margin: 0; -} - -.d03-plan__piece > .ui-field { - flex: 0 0 6.5rem; - margin: 0; -} - /* 지형 받기 범위 미리보기 — 카카오 지도 */ .d03-plan__extent > .ui-text { margin: 0 0 6px; @@ -152,12 +130,6 @@ margin-bottom: 6px; } -/* 580-3 고른 선 · 조각 줄 밝힘 */ -.d03-plan__piece.is-selected { - background: var(--color-accent-soft); - border-radius: var(--radius-md); -} - /* 585-2 고른 구간 속성 판 — 도구줄 아래 오른쪽 · 몸 스크롤 · 구간 줄 = 고스트 단추 */ .d03-plan__props { top: calc(var(--spacing-8) * 6); @@ -188,12 +160,22 @@ justify-content: flex-start; } -.d03-plan__piece[role="button"] { - cursor: pointer; +/* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */ +.d03-plan__points-head { + display: flex; + justify-content: flex-end; + margin-bottom: var(--spacing-8); } -.d03-plan__piece-year { - font-weight: 600; +.d03-plan__point-edit { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-8) var(--spacing-8) calc(var(--spacing-8) * 2); +} + +.d03-plan__point-edit > .ui-field { + margin: 0; } /* 599 그리기 칸 — 방식 칸(마디 고름 2 × 2 · 길이 · 기울기 두 열) */ @@ -213,3 +195,22 @@ .d03-plan__draw-fields .ui-field { margin: 0; } + +/* 597 구역 · 연도 목록 — 구역 줄(이름 칸 · 연도 고름 · 지움) · 구간 줄(측점 글 작게) */ +.d03-plan__line-tail { + display: flex; + gap: var(--spacing-4); + align-items: center; +} + +.d03-plan__pieces .ui-list-row__label > .ui-field { + margin: 0; +} + +.d03-plan__piece-row { + padding-left: calc(var(--spacing-8) * 2); +} + +.d03-plan__piece-row .ui-list-row__label { + font-size: var(--text-caption); +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.ts new file mode 100644 index 000000000..1a017648c --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.ts @@ -0,0 +1,97 @@ +/* ============================================================================= + * D03_PlanAnalysis_Measure.ts + * D 예정노선 거리재기 「노선 따라」(PLAN 600-2 · 요구 463-23) — 첫 누름이 노선 위면 둘째 누름까지 + * 노선(망)을 따라간 길 · 같은 선 = 누가 차 · 갈래 ↔ 본선 = 갈래 고리(`branch`)를 따라 올라가 만나는 점까지. + * DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`). + * 서로 이어지지 않은 본선끼리 = 길 없음(null) → 부르는 쪽이 곧은 거리로. + * ========================================================================== */ + +import { + distanceAlong, + lineLength, + pointAtDistance, + segmentLength, + sliceLine, +} from "./D03_PlanAnalysis_Draw"; +import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan"; + +/** 노선 위 한 자리 — 선 id · 그 선 위 누가(m). */ +export interface RoutePos { + line: string; + atM: number; +} + +export interface RouteSpan { + lengthM: number; + /** 두 자리 사이 노선 따라 꺾은선(앞 자리 → 뒤 자리). */ + path: Vertex[]; + /** 같은 선 위일 때만 — 두 누가(m). */ + stations: [number, number] | null; +} + +/** 자리에서 시작해 갈래 고리를 따라 올라간 (선 · 그 선에 들어선 누가) 목록 — 첫 항목 = 자리 자신. */ +function upChain(network: PlanNetwork, pos: RoutePos): RoutePos[] { + const chain: RoutePos[] = [pos]; + const seen = new Set([pos.line]); + let line = network.lines.find((entry) => entry.id === pos.line); + while (line?.branch) { + const parent = network.lines.find((entry) => entry.id === line!.branch!.line); + if (!parent || seen.has(parent.id)) break; + seen.add(parent.id); + chain.push({ + line: parent.id, + atM: lineLength(parent.vertices.slice(0, line.branch.vertex + 1)), + }); + line = parent; + } + return chain; +} + +/** 두 자리 사이 노선 따라 길 — 이어지지 않으면 null. */ +export function routeSpan(network: PlanNetwork, a: RoutePos, b: RoutePos): RouteSpan | null { + const up = upChain(network, a); + const down = upChain(network, b); + const ia = up.findIndex((step) => down.some((other) => other.line === step.line)); + if (ia < 0) return null; + const ib = down.findIndex((step) => step.line === up[ia].line); + const legs: Array<[string, number, number]> = []; + // a → 갈래 첫 점(0 m) → 붙은 선 … 만나는 선 + for (let k = 0; k < ia; k += 1) legs.push([up[k].line, up[k].atM, 0]); + legs.push([up[ia].line, up[ia].atM, down[ib].atM]); + // 만나는 선 → … → b + for (let k = ib - 1; k >= 0; k -= 1) legs.push([down[k].line, 0, down[k].atM]); + const path: Vertex[] = []; + let lengthM = 0; + for (const [id, from, to] of legs) { + const vertices = network.lines.find((entry) => entry.id === id)?.vertices ?? []; + if (vertices.length < 2) continue; + lengthM += Math.abs(to - from); + const piece = sliceLine(vertices, from, to); + if (from > to) piece.reverse(); + for (const point of piece) { + const last = path[path.length - 1]; + if (!last || segmentLength(last, point) > 1e-9) path.push(point); + } + } + const same = ia === 0 && ib === 0; + return { lengthM, path, stations: same ? [a.atM, b.atM] : null }; +} + +/** 점에서 가장 가까운 노선 위 자리 — `from` 과 이어진 선 안에서만. */ +export function nearestOnRoute( + network: PlanNetwork, + point: Vertex, + from: RoutePos, +): (RoutePos & { point: Vertex }) | null { + let best: (RoutePos & { point: Vertex }) | null = null; + let bestGap = Infinity; + for (const line of network.lines) { + if (line.vertices.length < 2) continue; + const atM = distanceAlong(line.vertices, point); + const gap = segmentLength(pointAtDistance(line.vertices, atM), point); + if (gap >= bestGap || !routeSpan(network, from, { line: line.id, atM })) continue; + best = { line: line.id, atM, point: pointAtDistance(line.vertices, atM) }; + bestGap = gap; + } + return best; +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts new file mode 100644 index 000000000..6cb3c8aad --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts @@ -0,0 +1,77 @@ +/* ============================================================================= + * D03_PlanAnalysis_PointEdit.ts + * D03 지점 칸 손 고침 계산(PLAN 597-2 · 요구 463 7 · 8) — 화면 · fetch 없음(logic/). + * 이름 규칙 = `Plan.ts` 의 `nameError`(20 자 · 파일 이름 못 쓰는 글자 · 서버 400 짝) · 빈 값 = 「지점 N」 보임. + * 좌표 = 경위도 손 고침 → 작업 좌표(TM) · 그 자리 노선 노드도 같이(`moveNodesAt`). + * ========================================================================== */ + +import { moveNodesAt, type PlanNetwork, type Vertex } from "./D03_PlanAnalysis_Plan"; +import { lonLatToTm, type TmParams } from "./D03_PlanAnalysis_Tm"; + +/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */ +export const MAX_POINTS = 40; + +/** 보이는 이름 — 빈 값 = 「지점 N」. */ +export function pointLabel(names: string[], index: number): string { + return names[index]?.trim() || `지점 ${index + 1}`; +} + +/** 경위도 글 → 값 · 못 읽거나 범위 밖이면 까닭 글. */ +export function parseLonLat(lonText: string, latText: string): [number, number] | string { + const lon = Number(lonText.trim()); + const lat = Number(latText.trim()); + if (!lonText.trim() || !Number.isFinite(lon) || Math.abs(lon) > 180) return "경도는 −180 ~ 180"; + if (!latText.trim() || !Number.isFinite(lat) || Math.abs(lat) > 90) return "위도는 −90 ~ 90"; + return [lon, lat]; +} + +export interface PointsState { + points: Vertex[]; + names: string[]; + network: PlanNetwork; +} + +/** 지점 더함 — 이름 비움. 상한이면 그대로. */ +export function addPoint(state: PointsState, at: Vertex): PointsState { + if (state.points.length >= MAX_POINTS) return state; + return { + ...state, + points: [...state.points, [at[0], at[1]]], + names: [...state.names, ""], + }; +} + +/** 이름 바꿈 — 빈 값 허용. */ +export function renamePoint(state: PointsState, index: number, name: string): PointsState { + const names = state.points.map((_, k) => state.names[k] ?? ""); + names[index] = name.trim(); + return { ...state, names }; +} + +/** 경위도로 옮김 — 그 자리 노선 노드도 같이. */ +export function movePointTo( + state: PointsState, + index: number, + tm: TmParams, + lon: number, + lat: number, +): PointsState { + const to = lonLatToTm(tm, lon, lat); + const from = state.points[index]; + return { + ...state, + points: state.points.map((p, k): Vertex => (k === index ? to : p)), + network: moveNodesAt(state.network, from, to), + }; +} + +/** 선 이름 바꿈(597-3) — 빈 값 = 차례 이름으로 돌아감(`name` 비움). */ +export function setLineName(network: PlanNetwork, lineId: string, name: string): PlanNetwork { + const text = name.trim(); + return { + ...network, + lines: network.lines.map((line) => + line.id === lineId ? { ...line, name: text || null } : line, + ), + }; +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts new file mode 100644 index 000000000..4b44d001a --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts @@ -0,0 +1,287 @@ +/* ============================================================================= + * D03_PlanAnalysis_Select.ts + * D 예정노선 고름 층 셈(PLAN 598 · 요구 463 9 · 12 · 13·16 · 15) — 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체. + * 저장 꼴은 그대로(`PlanNetwork`) — 선 = 「구역 id + 토막 차례」로 가리킴 · 낱 자료 없음. + * 토막 지움 = 끝 토막은 끝 점 뺌 · 가운데 토막은 구역이 둘로(뒤쪽 = 새 구역 · 갈래 고리 · 조각이 따라감). + * 지점 ↔ 노드 = 같은 자리(1 mm)로 묶음 — 묶인 노드 · 지점 찾기. + * 노드 판(598-4) 방식 · 값 바꿈 = 앞 노드 기준 같은 방향으로 다시 놓음 · 뒤 노드 그대로(어긋남 ⚠ = 599-2 `nodeOff`). + * DOM · fetch · sessionStorage 없음 · 조각 맞춤은 부르는 쪽(`normalizePieces`). + * ========================================================================== */ + +import { + compassPoint, + fixedLengthPoint, + fixedSlopePoint, + lineLength, + segmentLength, + type HeightAt, +} from "./D03_PlanAnalysis_Draw"; +import { + moveNodesAt, + moveVertex, + piecesInBox, + removeLine, + samePoint, + orderLines, + type NodeRecord, + type PlanLine, + type PlanNetwork, + type PlanPiece, + type Vertex, + type ViewBox, +} from "./D03_PlanAnalysis_Plan"; + +/** 선(토막) — 구역 id · 토막 차례(꼭짓점 `index` → `index + 1`). */ +export interface PlanSegment { + line: string; + index: number; +} + +/** 노드 — 구역 id · 꼭짓점 차례. */ +export interface PlanNode { + line: string; + vertex: number; +} + +const EPS = 1e-6; +const round3 = (value: number): number => Math.round(value * 1000) / 1000; + +/** 꺾임점마다 선 위 거리. */ +export function chainages(vertices: ReadonlyArray): number[] { + const out = [0]; + for (let i = 1; i < vertices.length; i += 1) + out.push(out[i - 1] + segmentLength(vertices[i - 1], vertices[i])); + return out; +} + +export const sameSegment = (a: PlanSegment, b: PlanSegment): boolean => + a.line === b.line && a.index === b.index; + +/** 구역 토막 모두. */ +export function lineSegments(network: PlanNetwork, line: string): PlanSegment[] { + const entry = network.lines.find((l) => l.id === line); + const count = Math.max(0, (entry?.vertices.length ?? 0) - 1); + return Array.from({ length: count }, (_, index) => ({ line, index })); +} + +/** 노선망 토막 모두. */ +export const allSegments = (network: PlanNetwork): PlanSegment[] => + network.lines.flatMap((line) => lineSegments(network, line.id)); + +/** 토막 꼭짓점 둘(없으면 null). */ +export function segmentPath(network: PlanNetwork, seg: PlanSegment): [Vertex, Vertex] | null { + const v = network.lines.find((l) => l.id === seg.line)?.vertices; + return v && seg.index >= 0 && seg.index + 1 < v.length ? [v[seg.index], v[seg.index + 1]] : null; +} + +/** 토막 선 위 거리 [시작, 끝](m). */ +export function segmentRange(network: PlanNetwork, seg: PlanSegment): [number, number] | null { + const v = network.lines.find((l) => l.id === seg.line)?.vertices; + if (!v || seg.index < 0 || seg.index + 1 >= v.length) return null; + const from = lineLength(v.slice(0, seg.index + 1)); + return [from, from + segmentLength(v[seg.index], v[seg.index + 1])]; +} + +/** 선 위 거리 `atM` 이 든 토막 차례(꺾임점 위 = 뒤 토막 · 끝 = 마지막). */ +export function segmentAt(vertices: ReadonlyArray, atM: number): number { + const c = chainages(vertices); + for (let i = 0; i + 1 < c.length; i += 1) if (atM < c[i + 1] - EPS) return i; + return Math.max(0, c.length - 2); +} + +/** 노드에서 넓힐 토막 — 뒤 토막(끝 노드 = 앞 토막). */ +export function nodeSegment(network: PlanNetwork, node: PlanNode): PlanSegment | null { + const n = network.lines.find((l) => l.id === node.line)?.vertices.length ?? 0; + if (n < 2) return null; + return { line: node.line, index: Math.min(node.vertex, n - 2) }; +} + +/** 토막에 걸친 조각 id(연도 구간). */ +export function segmentPieces(network: PlanNetwork, segs: ReadonlyArray): string[] { + const out = new Set(); + for (const seg of segs) { + const range = segmentRange(network, seg); + if (!range) continue; + for (const piece of network.pieces) + if (piece.line === seg.line && piece.from_m < range[1] - EPS && piece.to_m > range[0] + EPS) + out.add(piece.id); + } + return [...out]; +} + +/** 끌어 고르기 — 토막마다 판정(창 = 두 끝 모두 안 · 걸침 = 상자와 만남 · `piecesInBox` 와 같은 셈). */ +export function segmentsInBox( + network: PlanNetwork, + toView: (point: Vertex) => Vertex, + box: ViewBox, + crossing: boolean, +): PlanSegment[] { + const segs = allSegments(network); + const probe: PlanNetwork = { + lines: segs.map((seg, i) => ({ + id: `s${i}`, + vertices: segmentPath(network, seg)!, + branch: null, + })), + pieces: segs.map((seg, i) => { + const [a, b] = segmentPath(network, seg)!; + return { id: String(i), line: `s${i}`, from_m: 0, to_m: segmentLength(a, b), year: 0 }; + }), + }; + return piecesInBox(probe, toView, box, crossing).map((i) => segs[Number(i)]); +} + +/** 새 구역 id — `line-`(있는 것과 안 겹침). */ +function nextLineId(lines: ReadonlyArray): string { + const used = new Set(lines.map((line) => line.id)); + let n = lines.length; + while (used.has(`line-${n}`)) n += 1; + return `line-${n}`; +} + +/** 새 조각 id — `p`(있는 것 중 가장 큰 수 + 1). */ +function nextPieceId(pieces: ReadonlyArray): string { + const used = pieces.map((piece) => Number(/^p(\d+)$/.exec(piece.id)?.[1] ?? 0)); + return `p${Math.max(0, ...used) + 1}`; +} + +/** 꼭짓점 [a, b) 만 남긴 선 — 노드 기록(594-1)도 같이 자름 · 뒤쪽 첫 기록 = 없음(앞 노드가 사라짐). */ +function cutVertices(entry: PlanLine, a: number, b: number): PlanLine { + const vertices = entry.vertices.slice(a, b); + if (!entry.nodes) return { ...entry, vertices }; + const nodes = entry.nodes.slice(a, b); + if (a > 0 && nodes.length) nodes[0] = null; + return { ...entry, vertices, nodes }; +} + +/** 토막 하나 지움 — 하나뿐 = 구역 지움 · 끝 토막 = 끝 점 뺌 · 가운데 = 구역 둘(뒤쪽 = 새 구역 · 갈래 아님) · + * 사라지는 노드에 갈래가 붙었으면 못 지움(까닭 글) · 조각 id 는 앞쪽이 지님(걸친 조각은 뒤쪽 새 id). */ +export function removeSegment(network: PlanNetwork, seg: PlanSegment): PlanNetwork | string { + const li = network.lines.findIndex((l) => l.id === seg.line); + const entry = network.lines[li]; + const n = (entry?.vertices.length ?? 0) - 1; + if (!entry || seg.index < 0 || seg.index >= n) return "고른 선이 없습니다."; + if (n === 1) return removeLine(network, li); + const i = seg.index; + const keepFront = i > 0; + const keepBack = i < n - 1; + const children = network.lines.filter((l) => l.branch?.line === entry.id); + if ( + children.some( + (l) => (!keepFront && l.branch!.vertex === 0) || (!keepBack && l.branch!.vertex === n), + ) + ) + return "갈래가 붙은 점이 사라집니다 — 갈래를 먼저 지우세요."; + const backId = keepFront ? nextLineId(network.lines) : entry.id; + const c = chainages(entry.vertices); + const [cut0, cut1] = [c[i], c[i + 1]]; + + const front: PlanPiece[] = []; + const back: PlanPiece[] = []; + for (const piece of network.pieces.filter((p) => p.line === entry.id)) { + if (keepFront && piece.from_m < cut0 - EPS) + front.push({ ...piece, to_m: round3(Math.min(piece.to_m, cut0)) }); + if (keepBack && piece.to_m > cut1 + EPS) + back.push({ + ...piece, + line: backId, + from_m: round3(Math.max(0, piece.from_m - cut1)), + to_m: round3(piece.to_m - cut1), + }); + } + const others = network.pieces.filter((p) => p.line !== entry.id); + for (const piece of back) + if (front.some((p) => p.id === piece.id)) + piece.id = nextPieceId([...others, ...front, ...back]); + + const lines: PlanLine[] = []; + for (const line of network.lines) { + if (line === entry) { + if (keepFront) lines.push(cutVertices(entry, 0, i + 1)); + if (keepBack) lines.push({ ...cutVertices(entry, i + 1, n + 1), id: backId, branch: null }); + continue; + } + const b = line.branch; + if (b?.line === entry.id && b.vertex > i) + lines.push({ ...line, branch: { line: backId, vertex: b.vertex - (i + 1) } }); + else lines.push(line); + } + return { ...network, lines: orderLines(lines), pieces: [...others, ...front, ...back] }; +} + +/** 토막 여럿 지움 — 구역마다 뒤 토막부터(앞 토막 차례가 안 바뀜) · 하나라도 못 지우면 까닭 글. */ +export function removeSegments( + network: PlanNetwork, + segs: ReadonlyArray, +): PlanNetwork | string { + let next = network; + const order = [...segs].sort((a, b) => + a.line === b.line ? b.index - a.index : a.line < b.line ? -1 : 1, + ); + for (const seg of order) { + const out = removeSegment(next, seg); + if (typeof out === "string") return out; + next = out; + } + return next; +} + +/** 전체 지움 — 선 · 조각 모두(지점은 부르는 쪽이 남김). */ +export const removeAll = (network: PlanNetwork): PlanNetwork => ({ + ...network, + lines: [], + pieces: [], +}); + +/** `point` 자리 노드 모두(갈래 첫 점 = 붙은 선 점이 대신 · 빼고). */ +export function nodesAt(network: PlanNetwork, point: Vertex): PlanNode[] { + const out: PlanNode[] = []; + for (const line of network.lines) + line.vertices.forEach((v, vertex) => { + if (!(vertex === 0 && line.branch) && samePoint(v, point)) + out.push({ line: line.id, vertex }); + }); + return out; +} + +/** `from` 자리 지점을 모두 `to` 로(지점 ↔ 노드 따라감). */ +export function movePointsAt(points: ReadonlyArray, from: Vertex, to: Vertex): Vertex[] { + return points.map((p): Vertex => (samePoint(p, from) ? [to[0], to[1]] : p)); +} + +/** 노드 판(598-4) — `vertex` 노드 기록을 `record` 로 · 앞 노드 기준 지금 방향으로 다시 놓음(자유 = 자리 그대로) · + * 뒤 노드 그대로 · 그 자리 지점 · 같은 자리 노드도 같이 · 첫 노드 · 자리 못 찾음 = 까닭 글. */ +export function placeNode( + network: PlanNetwork, + points: ReadonlyArray, + line: number, + vertex: number, + record: NodeRecord, + heightAt: HeightAt, +): { network: PlanNetwork; points: Vertex[] } | string { + const entry = network.lines[line]; + if (!entry || vertex < 1 || vertex >= entry.vertices.length) return "첫 노드는 방식이 없습니다."; + const [prev, here] = [entry.vertices[vertex - 1], entry.vertices[vertex]]; + const { kind, length_m: lengthM, slope_pct: slopePct } = record; + const to = + kind === "free" + ? here + : kind === "length" + ? fixedLengthPoint(prev, here, lengthM ?? 0) + : kind === "slope" + ? fixedSlopePoint(prev, here, slopePct ?? 0, heightAt) + : compassPoint(prev, here, lengthM ?? 0, slopePct ?? 0, heightAt); + if (!to) return "그 값으로 놓을 자리가 없습니다(지형 · 길이를 보세요)."; + const bound = points.some((p) => samePoint(p, here)); + let next = bound ? moveNodesAt(network, here, to) : moveVertex(network, line, vertex, to); + next = { + ...next, + lines: next.lines.map((l) => { + if (l.id !== entry.id) return l; + const nodes = l.nodes ? [...l.nodes] : l.vertices.map(() => null); + nodes[vertex] = record; + return { ...l, nodes }; + }), + }; + return { network: next, points: bound ? movePointsAt(points, here, to) : [...points] }; +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts new file mode 100644 index 000000000..2fa0ab653 --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts @@ -0,0 +1,98 @@ +/* ============================================================================= + * D03_PlanAnalysis_SlopeBands.ts + * D03 급경사 범위(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`. + * ========================================================================== */ + +export interface SlopeBand { + /** 아래 % (포함) */ + low: number; + /** 위 % (뺌 · 250 = 끝까지) */ + high: number; + /** 그림 색 이름(`--draw-map-*`) — `SLOPE_COLORS` 중 하나 */ + color: string; +} + +export const SLOPE_MAX_PCT = 250; +export const SLOPE_EMPTY = 255; +export const SLOPE_MAX_BANDS = 8; + +/** 고를 색(이름 · 그림 색 값). */ +export const SLOPE_COLORS: Array<{ name: string; color: string }> = [ + { name: "노랑", color: "--draw-map-landslide-3" }, + { name: "주황", color: "--draw-map-landslide-2" }, + { name: "빨강", color: "--draw-map-landslide-1" }, + { name: "연두", color: "--draw-map-landslide-4" }, + { name: "초록", color: "--draw-map-landslide-5" }, + { name: "하늘", color: "--draw-map-stream" }, + { name: "파랑", color: "--draw-map-zone-water" }, + { name: "보라", color: "--draw-map-zone-heritage" }, + { name: "분홍", color: "--draw-map-parcel" }, +]; + +export const DEFAULT_SLOPE_BANDS: SlopeBand[] = [ + { low: 26.8, high: 70.0, color: "--draw-map-landslide-3" }, + { low: 70.0, high: 83.9, color: "--draw-map-landslide-2" }, + { low: 83.9, high: SLOPE_MAX_PCT, color: "--draw-map-landslide-1" }, +]; + +export const pctToDeg = (pct: number): number => (Math.atan(pct / 100) * 180) / Math.PI; + +/** 범위 고칠 거리 — 없으면 null(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음 · 너무 많음). */ +export function checkBands(bands: SlopeBand[]): string | null { + if (!bands.length) return "범위가 하나는 있어야 합니다"; + if (bands.length > SLOPE_MAX_BANDS) return `범위는 ${SLOPE_MAX_BANDS}개까지입니다`; + for (const [i, band] of bands.entries()) { + const at = `${i + 1}번째 범위`; + if (!Number.isFinite(band.low) || !Number.isFinite(band.high)) return `${at} — 값이 비었습니다`; + if (band.low < 0 || band.high > SLOPE_MAX_PCT) return `${at} — 0~${SLOPE_MAX_PCT} % 안으로`; + if (band.low >= band.high) return `${at} — 아래가 위보다 작아야 합니다`; + if (!SLOPE_COLORS.some((c) => c.color === band.color)) return `${at} — 색을 고르세요`; + } + const sorted = [...bands].sort((a, b) => a.low - b.low); + for (let i = 1; i < sorted.length; i++) + if (sorted[i].low < sorted[i - 1].high) + return `범위가 겹칩니다 — ${sorted[i - 1].low}~${sorted[i - 1].high} % 와 ${sorted[i].low}~${sorted[i].high} %`; + return null; +} + +/** 저장 글 → 범위(깨졌거나 맞지 않으면 기본). */ +export function parseBands(raw: string | null): SlopeBand[] { + try { + const got = JSON.parse(raw ?? "null") as unknown; + if (Array.isArray(got)) { + const bands = got.map((b: Partial) => ({ + low: Number(b?.low), + high: Number(b?.high), + color: String(b?.color), + })); + if (!checkBands(bands)) return bands; + } + } catch { + // 깨진 값 = 기본 + } + return DEFAULT_SLOPE_BANDS.map((b) => ({ ...b })); +} + +/** 칸 값 v(0~255) → 범위 차례(없으면 −1). */ +export function bandOf(v: number, bands: SlopeBand[]): number { + if (v > SLOPE_MAX_PCT) return -1; + const mid = v + 0.5; + return bands.findIndex( + (b) => b.low <= mid && (mid < b.high || (b.high >= SLOPE_MAX_PCT && v === SLOPE_MAX_PCT)), + ); +} + +/** 1 % 칸 수 → 범위별 비율(유효 칸 대비). */ +export function bandRatios(counts: number[], bands: SlopeBand[]): number[] { + const total = counts.reduce((sum, n) => sum + n, 0) || 1; + const out = bands.map(() => 0); + counts.forEach((n, v) => { + const i = bandOf(v, bands); + if (i >= 0) out[i] += n; + }); + return out.map((n) => n / total); +} diff --git a/resources/tester/test_530_1_plan_terrain.py b/resources/tester/test_530_1_plan_terrain.py index 082c77c00..74af4f6a1 100644 --- a/resources/tester/test_530_1_plan_terrain.py +++ b/resources/tester/test_530_1_plan_terrain.py @@ -1,5 +1,6 @@ -"""D 예정노선 지형 받기(PLAN 530-1 · 561-2) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 · -옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음. +"""D 예정노선 지형 받기(PLAN 530-1 · 561-2 · 595) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 · +옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음 · 지적도 뺌 · 새로(reset) = 옛 범위 버림 · +받은 범위 좌표계 · 칸 상한 · 거리. ./venv/Scripts/python.exe -m pytest resources/tester/test_530_1_plan_terrain.py -q """ @@ -54,7 +55,6 @@ def fake(tmp_path, monkeypatch): monkeypatch.setattr(terrain, "parse_and_merge_sheets", merge) monkeypatch.setattr(terrain, "download_vworld_satellite_map", satellite) monkeypatch.setattr(terrain, "build_sheet_surface_model", dem) - monkeypatch.setattr(terrain.layers, "ensure_cadastral", lambda *a: {"count": 0}) # 569-1 return tmp_path, seen @@ -104,6 +104,39 @@ def test_one_set_and_extend_keeps_old(fake) -> None: assert len(seen["merge"]) == merges and third["box_m"] == grown +def test_reset_drops_old_box_sheets_satellite(fake) -> None: + """595-2 — 새로 = 옛 받은 범위 · 병합 도엽 · 위성 범위 버림 · 좌표계 · 가운데 기록 · + 다른 좌표계로 물으면 받은 범위 없음 · 지적도는 지형 일에 없음.""" + root, seen = fake + terrain.ensure_plan_terrain(root, [A, B], EPSG, center=(127.5, 36.0)) + old = set(seen["merge"][-1]) + far = [(A[0] + 40000.0, A[1]), (A[0] + 41000.0, A[1] + 1000.0)] + got = terrain.ensure_plan_terrain(root, far, EPSG, reset=True, center=(128.0, 36.0)) + m = terrain.DEFAULT_MARGIN_M + assert got["box_m"] == pytest.approx( + [far[0][0] - m, far[0][1] - m, far[1][0] + m, far[1][1] + m] + ) + assert not old & set(seen["merge"][-1]) # 옛 도엽 병합에서 빠짐(zip 은 남음) + assert seen["satellite"][-1]["x"][0] > A[0] + 30000 # 옛 위성 범위와 안 합침 + assert "cadastral" not in got + meta = terrain.received_meta(root) + assert meta["epsg"] == EPSG and meta["center"] == [128.0, 36.0] + assert terrain.received_box(root, EPSG) == pytest.approx(tuple(got["box_m"])) + assert terrain.received_box(root, 5187) is None # 다른 띠 = 받은 범위 아님 + # 새로 아닌 이어 받기 = 가운데 기록 그대로 + terrain.ensure_plan_terrain(root, far, EPSG) + assert terrain.received_meta(root)["center"] == [128.0, 36.0] + + +def test_cells_distance_and_limit(fake) -> None: + root, _ = fake + assert terrain.cells_of((0.0, 0.0, 2000.0, 2000.0)) == 1_000_000 # 2 m 격자 + assert terrain.distance_to_box((5.0, 5.0), (0.0, 0.0, 10.0, 10.0)) == 0 + assert terrain.distance_to_box((13.0, 14.0), (0.0, 0.0, 10.0, 10.0)) == 5 + assert terrain.MAX_CELLS == 5_000_000 and terrain.FAR_M == 5000 + assert terrain.plan_extent(root, [A, B], EPSG)["max_cells"] == terrain.MAX_CELLS + + def test_extent_no_download_and_covered(fake) -> None: root, seen = fake before = terrain.plan_extent(root, [A, B], EPSG) diff --git a/resources/tester/test_530_2_plan_router.py b/resources/tester/test_530_2_plan_router.py index 483c6932e..10bfe6970 100644 --- a/resources/tester/test_530_2_plan_router.py +++ b/resources/tester/test_530_2_plan_router.py @@ -1,5 +1,6 @@ -"""D 지형 API(PLAN 530-1 · 561-2 · 591-2) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 · 선이 -없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌. +"""D 지형 API(PLAN 530-1 · 561-2 · 591-2 · 595) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 · +선이 없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌 · 위치 칸 가운데 = 둘레 덧 · 멀다 · 다른 띠 +물음 · 새로(reset) · 칸 상한 413 · 서버 다시 켜짐 = 끊김. 옛 분석 API 시험(GET · PUT analysis · 꺾임점 고침)은 옛 추천 엔진과 함께 걷음(PLAN 562-2) · 노선 정본 시험 = `test_530_2_plan_analyze.py` · 지형 셈 시험 = `test_530_1_plan_terrain.py`. @@ -20,6 +21,8 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Router as router_mod PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2" P0, P2 = (0.0, 0.0), (900.0, 400.0) URL = f"/api/projects/{PID}/plan/terrain" +OX, OY = 200000.0, 500000.0 # 595 시험 = 중부 띠 안(경도 127°) +Q0, Q2 = (OX + P0[0], OY + P0[1]), (OX + P2[0], OY + P2[1]) class _Pool: @@ -51,6 +54,7 @@ def client(tmp_path, monkeypatch): test = TestClient(app) test.plan = plan test.info = info + test.root = tmp_path return test @@ -90,14 +94,14 @@ def test_post_runs_one_set(client, monkeypatch) -> None: saved: list = [] works: list = [] - def ensure(root, points, epsg, margin_m, report): + def ensure(root, points, epsg, margin_m, report, **kw): calls.append((points, epsg, margin_m)) return {"sheets": {"downloaded": ["1"]}, "models": [{"model_file_path": "m"}]} async def save(project_id, models, epsg): saved.append((models, epsg)) - def start(project_id, kind, work): + def start(project_id, kind, work, root=None): works.append(work) return {"status": "accepted", "kind": kind} @@ -116,3 +120,154 @@ def test_post_runs_one_set(client, monkeypatch) -> None: # 옛 화면 꼴(points 몸) — 여유 처음 값 · 전체 시점 · 종점 post({"points": []}) assert calls[-1] == ([P0, P2], 5186, None) + + +# ── 595 위치 바꿈 · 상한 · 끊김 ── + + +@pytest.fixture +def posting(client, monkeypatch): + """POST 받기 — 일은 바로 돌림 · ensure 부름(점 · 좌표계 · reset · center) 기록.""" + calls: list = [] + + def ensure(root, points, epsg, margin_m, report, reset=False, center=None): + calls.append({"points": points, "epsg": epsg, "reset": reset, "center": center}) + return {"sheets": {"downloaded": []}, "models": []} + + async def save(project_id, models, epsg): + return None + + works: list = [] + + def start(project_id, kind, work, root=None): + works.append(work) + return {"status": "accepted", "kind": kind} + + def post(body): + res = client.post(URL, json=body) + while works: # 일은 응답 뒤 돌림(앱 고리 밖) + asyncio.run(works.pop()(lambda percent, message: None)) + return res + + monkeypatch.setattr(router_mod.terrain, "ensure_plan_terrain", ensure) + monkeypatch.setattr(router_mod, "_save_models", save) + monkeypatch.setattr(router_mod, "_start_job", start) + client.plan["points"] = [{"x": Q0[0], "y": Q0[1]}, {"x": Q2[0], "y": Q2[1]}] + client.calls = calls + client.run_post = post + return client + + +def _have(client, box, epsg=5186, center=None): + meta = {"box": list(box), "margin_m": 350.0, "epsg": epsg} + if center: + meta["center"] = list(center) + folder = router_mod.terrain.models_dir_of(client.root) + folder.mkdir(parents=True, exist_ok=True) + (folder / router_mod.terrain.TERRAIN_META).write_text(json.dumps(meta), "utf-8") + + +def test_center_near_adds_site_ring(posting) -> None: + """가까운 위치 바꿈 = 지점 + 새 가운데 둘레 · 받은 범위와 합침.""" + from common_util.common_util_crs import lonlat_to_tm, tm_to_lonlat + + _have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0)) + lon, lat = tm_to_lonlat(OX + 3000.0, OY, 5186) # 받은 범위에서 1.5 km + res = posting.run_post({"center": {"lon": lon, "lat": lat}}).json() + assert res["status"] == "accepted" + call = posting.calls[-1] + x, y = lonlat_to_tm(lon, lat, 5186) + r = router_mod.terrain.SITE_RADIUS_M - router_mod.terrain.DEFAULT_MARGIN_M + assert call["points"][:2] == [Q0, Q2] and call["points"][2] == pytest.approx((x - r, y - r)) + assert call["reset"] is False and call["center"] == (lon, lat) + # extent 에도 캐시 가운데 = 둘레가 상자에 들어감 + got = posting.get(f"{URL}/extent", params={"lon": lon, "lat": lat}).json() + assert got["box_m"][2] == pytest.approx(x + router_mod.terrain.SITE_RADIUS_M) + + +def test_center_far_asks_then_reset(posting) -> None: + """5 km 넘음 = 「far」(받지 않음) · reset = 옛 범위 버리고 새 ±1 km(지점 안 넣음).""" + from common_util.common_util_crs import tm_to_lonlat + + _have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0)) + lon, lat = tm_to_lonlat(OX + 8000.0, OY, 5186) # 6.5 km + body = {"center": {"lon": lon, "lat": lat}} + far = posting.run_post(body).json() + assert far["status"] == "far" and far["distance_m"] == pytest.approx(6500, abs=2) + assert "km" in far["message"] and posting.calls == [] + res = posting.run_post({**body, "reset": True}).json() + assert res["status"] == "accepted" + call = posting.calls[-1] + assert call["reset"] is True and len(call["points"]) == 2 # 둘레 두 귀만 + assert call["points"][0][0] == pytest.approx(OX + 8000 - 650, abs=1) + + +def test_center_other_band_asks_then_moves(posting) -> None: + """다른 TM 띠 = 「band」 · 확인(reset) 뒤 그 띠 · 그 뒤엔 받은 지형 띠를 따름.""" + posting.plan["lines"] = [{"id": "a"}] + body = {"center": {"lon": 129.1017, "lat": 36.8128}} # 용화(동부 5187) + ask = posting.run_post(body).json() + assert ask == { + "status": "band", + "epsg": 5186, + "center_epsg": 5187, + "message": ask["message"], + } + assert posting.calls == [] + # 확인 전 extent = 가운데 무시(옛 띠 그대로) + assert posting.get(f"{URL}/extent", params={"lon": 129.1, "lat": 36.8}).json()["epsg"] == 5186 + posting.run_post({**body, "reset": True}) + call = posting.calls[-1] + assert call["epsg"] == 5187 and call["reset"] is True and len(call["points"]) == 2 + # 받은 뒤([저장] 전) — 가운데 없이 물어도 받은 지형 띠 · 가운데 둘레 + (x0, y0), (x1, y1) = call["points"] + box = [x0 - 400, y0 - 400, x1 + 400, y1 + 400] + _have(posting, box, epsg=5187, center=(129.1017, 36.8128)) + got = posting.get(f"{URL}/extent").json() + assert got["epsg"] == 5187 and got["covered"] is True + + +def test_cells_limit_413(posting) -> None: + posting.plan["points"].append( + {"x": OX + 6000.0, "y": OY + 6000.0} + ) # 6.7 × 6.7 km = 1,100 만 칸 + res = posting.run_post({}) + assert res.status_code == 413 and "상한 500만" in res.json()["detail"] + assert posting.calls == [] + + +def test_progress_interrupted_after_restart(client, monkeypatch) -> None: + """도는 기록이 남았는데 기억에 없음(서버 다시 켜짐) = 끊김 · 받은 적 없음 = percent 100.""" + root = client.root + + async def tile_root(project_id): + return root + + monkeypatch.setattr(router_mod, "_tile_root", tile_root) + url = f"/api/projects/{PID}/plan/progress" + router_mod._JOBS.clear() + idle = client.get(url).json()["terrain"] + assert idle == {**router_mod.IDLE} + + path = router_mod._job_path(root, "terrain") + seen = [] + + async def work(report): + report(65, "5/5 사업 지형면 만드는 중") + seen.append(json.loads(path.read_text("utf-8"))) + + async def run(): + router_mod._start_job(PID, "terrain", work, root) + await asyncio.sleep(0.05) + + asyncio.run(run()) + assert seen[0]["running"] is True and seen[0]["percent"] == 65 # 도는 동안 기록 + assert json.loads(path.read_text("utf-8"))["running"] is False # 끝 기록 + path.write_text(json.dumps(seen[0]), "utf-8") # 도는 중 꺼짐 + router_mod._JOBS.clear() # 다시 켜짐 = 기억 빔 + got = client.get(url).json()["terrain"] + assert got["running"] is False and got["interrupted"] is True + assert got["percent"] == 65 and got["message"] is None and got["error"] is None + # 끝난 기록 = 끊김 아님 + path.write_text(json.dumps({**seen[0], "running": False}), "utf-8") + assert client.get(url).json()["terrain"] == {**router_mod.IDLE} diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 542dd501b..7090fc893 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -17,10 +17,11 @@ def _no_hex(path: str) -> None: def test_d03_points_parts(): - """591-2 — D02 걷음 · 지점 칸 = 목록 줄 · 아이콘 단추 · 우클릭 메뉴 부품.""" + """591-2 — D02 걷음 · 지점 칸 = 목록 줄 · 아이콘 단추 · 우클릭 메뉴 부품(598-2 = `UI_Menu`).""" assert not (ROOT / "D02_PlanBase" / "D02_PlanBase_UI_Page.ts").exists() ts = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts") - assert "createListRow(" in ts and "createIconButton(" in ts and "createMapContextMenu(" in ts + assert "createListRow(" in ts and "createIconButton(" in ts + assert "createMapContextMenu(" in _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts") for raw in ('createElement("button")', 'createElement("input")', 'el("input"'): assert raw not in ts @@ -35,6 +36,13 @@ def test_d03_parts(): layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클 + site = _src( + "D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts" + ) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품 + assert "createFindList(" in site and "searchPlaces(" in site and "createPlanSite(" in ts + assert "createPlaceFind(" in _src("D01_PlanRegister/D01_PlanRegister_UI_Page.ts") + for raw in ('createElement("button")', 'createElement("input")'): + assert raw not in site, raw # 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품 for src in (ts, draw, draw_panel, tools, layers): for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'): @@ -87,10 +95,14 @@ def test_d03_parts(): # 599-4 — 연도 구역 마커 = 그리기 칸 단추(끌어 놓기) · 지도 도구줄에서 뺌 assert "YEAR_MARK_DRAG" in draw_panel and '"year-mark"' not in tools props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판 - assert "createSelectField(" in props and "createFloatPanel(" in props + assert "createSelectField(" in props and "createFloatPanel(" in props and "createKv(" in props assert 'createElement("button")' not in props and 'createElement("select")' not in props - assert 'createElement("select")' not in three and "createSelectField" not in three # 601 선 고름 걷음 - assert "createCheckbox" not in three and "setCorridors(" in three # 601 노선망 예상 코리도 한 번에 + assert ( + 'createElement("select")' not in three and "createSelectField" not in three + ) # 601 선 고름 걷음 + assert ( + "createCheckbox" not in three and "setCorridors(" in three + ) # 601 노선망 예상 코리도 한 번에 for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"): assert dead not in css, dead assert "b05-route__3d-modal" not in three and "b05-route__3d-body" not in three # 빌림 끊음 diff --git a/resources/tester/test_560_4_leave_save.py b/resources/tester/test_560_4_leave_save.py index 20fc22a06..b585c2d61 100644 --- a/resources/tester/test_560_4_leave_save.py +++ b/resources/tester/test_560_4_leave_save.py @@ -113,7 +113,7 @@ def test_de_pages_guard_leave(): if name: assert f'discard: () => clearState("{name}", projectId)' in call, path # D03 — 못 읽은 채(프로젝트 없음 · 불러오기 실패) 떠나면 고침 없음 - assert "let savedJson = JSON.stringify({ network, points });" in _src( + assert "let savedJson = JSON.stringify({ network, points, pointNames, site });" in _src( "D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts" ) e03 = _src("E03_StabSection/E03_StabSection_UI_Page.ts") diff --git a/resources/tester/test_569_1_plan_layers.py b/resources/tester/test_569_1_plan_layers.py index 56fdf0019..7ee16ea06 100644 --- a/resources/tester/test_569_1_plan_layers.py +++ b/resources/tester/test_569_1_plan_layers.py @@ -1,5 +1,5 @@ -"""D 노선분석 지도 겹(PLAN 569-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 · -겹친 필지 거름 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈. +"""D 노선분석 지도 겹(PLAN 569-1 · 595-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 · +겹친 필지 거름 · 실패 조각만 다시 · 급경사 % 값 그림 · 1 % 칸 수(603) · 격자 지문 같으면 다시 안 셈. ./venv/Scripts/python.exe -m pytest resources/tester/test_569_1_plan_layers.py -q """ @@ -127,9 +127,29 @@ def test_cadastral_failures(tmp_path, monkeypatch) -> None: monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=1)) first = layers.ensure_cadastral(tmp_path, BOX, EPSG) assert first["failed"] == 1 and (tmp_path / layers.CADASTRAL_FILE).exists() + assert ( + len(json.loads((tmp_path / layers.CADASTRAL_KEY).read_text("utf-8"))["failed_tiles"]) == 1 + ) + # 595-1 — 실패 조각만 다시 · 필지는 옛 것 + 그 조각 = 통째로 받은 것과 같음 again = FakeWfs() monkeypatch.setattr(layers, "request_vworld_wfs", again) - assert layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 15 + retry = layers.ensure_cadastral(tmp_path, BOX, EPSG) + assert retry["fetched"] and again.calls == 1 and retry["failed"] == 0 + assert retry["count"] == 15 * 2 + 1 + assert not layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 1 + # 옛 열쇠(실패 조각 기록 없음) = 통째로 + key = json.loads((tmp_path / layers.CADASTRAL_KEY).read_text("utf-8")) + key.update(failed=1) + key.pop("failed_tiles") + (tmp_path / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8") + layers.ensure_cadastral(tmp_path, BOX, EPSG) + assert again.calls == 1 + 15 + # 다시 받기도 모두 실패 = 옛 필지 그대로 · 실패 조각 남김(오류 아님) + key.update(failed=1, failed_tiles=[list(layers.tiles(BOX)[0])]) + (tmp_path / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8") + monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=99)) + kept = layers.ensure_cadastral(tmp_path, BOX, EPSG) + assert kept["failed"] == 1 and kept["count"] == 15 * 2 + 1 # ── 급경사 ── @@ -149,20 +169,22 @@ def _indices(value) -> np.ndarray: return np.asarray(Image.open(io.BytesIO(raw))) -def test_slope_bands_and_north_up() -> None: +def test_slope_values_and_north_up() -> None: + """값 그림(603) — 칸 = 기울기 % 내림 · 위 = 북 · 빈 칸 · 끝 줄 = 255 · 칸 수 합 = 유효 칸.""" z = _plane(10.0, 37.0) - value = layers.slope_bands_png(z, np.ones(z.shape, bool), 2.0) - ratios = [b["ratio"] for b in value["bands"]] - assert [b["min_deg"] for b in value["bands"]] == [15.0, 35.0, 40.0] - assert ratios[0] < 0.1 and 0.4 < ratios[1] < 0.6 and ratios[2] < 0.1 # 경계 줄만 + value = layers.slope_values_png(z, np.ones(z.shape, bool), 2.0) index = _indices(value) - assert value["width"] == value["height"] == 40 - assert (index[5, 5:35] == 2).all() and (index[-5, 5:35] == 0).all() # 위 = 북 = 37° - steep = layers.slope_bands_png(_plane(45.0, 20.0), np.ones((40, 40), bool), 2.0) - assert steep["bands"][2]["ratio"] > 0.4 and steep["bands"][0]["ratio"] > 0.4 + assert value["width"] == value["height"] == 40 and "bands" not in value + assert (index[5, 5:35] == 75).all() and (index[-5, 5:35] == 17).all() # 위 = 북 = 37° = 75 % + assert (index[0] == layers.SLOPE_EMPTY).all() # 격자 끝 = 셈 못 함 + assert sum(value["counts"]) == int((index != layers.SLOPE_EMPTY).sum()) == 38 * 38 + assert len(value["counts"]) == layers.SLOPE_MAX_PCT + 1 + cliff = layers.slope_values_png(_plane(80.0, 80.0), np.ones((40, 40), bool), 2.0) + assert cliff["counts"][layers.SLOPE_MAX_PCT] == 38 * 38 # 567 % → 250 % 이상 칸 hole = np.ones(z.shape, bool) hole[25:30, 10:20] = False - assert (_indices(layers.slope_bands_png(z, hole, 2.0))[-29:-24, 10:20] == 0).all() + holed = _indices(layers.slope_values_png(z, hole, 2.0)) + assert (holed[-29:-24, 10:20] == layers.SLOPE_EMPTY).all() def test_slope_layer_fingerprint(tmp_path, monkeypatch) -> None: @@ -174,8 +196,8 @@ def test_slope_layer_fingerprint(tmp_path, monkeypatch) -> None: y = 450000.0 + np.arange(40) * 2.0 np.savez(npz, x=x, y=y, z=z, valid_mask=np.ones(z.shape, bool), resolution=[2.0]) counted = [] - real = layers.slope_bands_png - monkeypatch.setattr(layers, "slope_bands_png", lambda *a: counted.append(1) or real(*a)) + real = layers.slope_values_png + monkeypatch.setattr(layers, "slope_values_png", lambda *a: counted.append(1) or real(*a)) first = layers.slope_layer(tmp_path, models, EPSG) assert first["box_m"] == [199999.0, 449999.0, 200079.0, 450079.0] and len(first["box"]) == 4 assert layers.slope_layer(tmp_path, models, EPSG) == first and len(counted) == 1 diff --git a/resources/tester/test_581_1_plan_gis_layers.py b/resources/tester/test_581_1_plan_gis_layers.py index 6ac60270a..375896db7 100644 --- a/resources/tester/test_581_1_plan_gis_layers.py +++ b/resources/tester/test_581_1_plan_gis_layers.py @@ -200,7 +200,7 @@ def test_hillshade_saved_apart_from_slope(tmp_path) -> None: shade = layers.slope_layer(tmp_path, models, EPSG, "hillshade") slope = layers.slope_layer(tmp_path, models, EPSG, "slope") assert shade["image"].startswith("data:image/png;base64,") and shade["bands"] == [] - assert slope["bands"] and shade["box_m"] == slope["box_m"] + assert slope["counts"] and shade["box_m"] == slope["box_m"] # 603 = 값 그림 + 칸 수 assert layers.slope_layer(tmp_path, models, EPSG, "hillshade") == shade 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 87ad0b83e..103d1c14e 100644 --- a/resources/tester/test_584_3_plan_forest_road_zones.py +++ b/resources/tester/test_584_3_plan_forest_road_zones.py @@ -1,5 +1,6 @@ -"""D 노선분석 GIS 겹 더함 3(PLAN 584-3) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면 5179 · -cp949 · 받은 범위로 자름 · 범위 밖 = 받지 않음 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 · +"""D 노선분석 GIS 겹 더함 3(PLAN 584-3 · 595-4) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면 +5179 · cp949 · 기준점 ±5 km ∪ 받은 지형으로 자름 · 잘린 상자 · 기준점 거리 · 범위 밖 = 받지 않음 + +거리 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 · 도분초 · 지번) · 점 · 지적 필지 맞춤 · 표 없는 시도 · 화면 겹 정의. ./venv/Scripts/python.exe -m pytest resources/tester/test_584_3_plan_forest_road_zones.py -q @@ -81,7 +82,7 @@ def test_bad_uploads_keep_old(tmp_path) -> None: ([("a.zip", b"not a zip")], "zip 파일을 열지"), ([(n, d) for n, d in files if not n.endswith(".dbf")], ".dbf"), ([("a.zip", _zip({"readme.txt": b"x"}))], ".shp 파일이 없습니다"), - (_shp_files(tmp_path / "far", [LINES[2]]), "받은 범위 안에 임도가 없습니다"), + (_shp_files(tmp_path / "far", [LINES[2]]), "받은 지형 밖뿐입니다"), ] for upload, text in bad: with pytest.raises(ValueError, match=re.escape(text)): @@ -91,6 +92,26 @@ def test_bad_uploads_keep_old(tmp_path) -> None: assert not list((tmp_path / fr.FOLDER).glob("*.new")) +def test_clip_box_site_5km_and_distance(tmp_path) -> None: + """595-4 — 자르는 범위 = 기준점 ±5 km ∪ 받은 지형 · 잘린 상자 · 가장 가까운 임도 거리 · + 밖뿐 = 거절 + 거리.""" + site = (231000.0, 450600.0) + frame = fr.clip_box(site, BOX) + assert frame == (226000.0, 445600.0, 236000.0, 455600.0) # 5 km 상자가 받은 지형을 덮음 + assert fr.clip_box(site, (220000.0, 450000.0, 232000.0, 451000.0))[0] == 220000.0 # ∪ + assert fr.clip_box(site, None) == frame + got = fr.save_roads(tmp_path, _shp_files(tmp_path / "a", LINES), frame, EPSG, site) + assert (got["count"], got["total"]) == (2, 3) # 범위 밖 한 가닥(25 km)만 빠짐 + assert got["length_m"] == pytest.approx(2000 + 300, abs=1) # 받은 지형 밖 반도 들어옴 + assert got["distance_m"] == pytest.approx(100, abs=0.5) # 450600 ~ 450500 + w, s, e, n = got["bounds"] + assert (w, s) == pytest.approx(tm_to_lonlat(229000, 450100, EPSG), abs=1e-4) + assert (e, n) == pytest.approx(tm_to_lonlat(231500, 450500, EPSG), abs=1e-4) + far = _shp_files(tmp_path / "far", [LINES[2]]) + with pytest.raises(ValueError, match=r"가장 가까운 임도 2[0-9]\.[0-9] km\(파일 선 1가닥\)"): + fr.save_roads(tmp_path, far, frame, EPSG, site) + + def test_no_upload_is_not_found(tmp_path) -> None: with pytest.raises(FileNotFoundError): fr.road_layer(tmp_path, BOX, EPSG) @@ -255,7 +276,9 @@ def test_screen_defs() -> None: assert entry and entry.group(2) == "false", key # 처음 값 끔 assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M), key assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in page - assert re.search(r"^\s+upload:", icons, re.M) and "pickFiles(" in page # 부품 상자 고르기 창 + site = (folder / "D03_PlanAnalysis_UI_Site.ts").read_text("utf-8") # 596-2 올리기 = 위치 칸 + assert "pickFiles(" in site and "pickFiles(" not in page # 부품 상자 고르기 창 + assert "uploadRoads" in page and "uploadPlanForestRoads(" in page assert "") # 겹 모두 바탕 assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분 diff --git a/resources/tester/test_585_2_plan_select.py b/resources/tester/test_585_2_plan_select.py index fc4e4c4f9..4e9ec9e1c 100644 --- a/resources/tester/test_585_2_plan_select.py +++ b/resources/tester/test_585_2_plan_select.py @@ -1,5 +1,5 @@ -"""585-2 · 585-3 예정노선 끌어 고르기 판정 · 여러 구간 연도 · 연도 구역 마커 나눔 · 합침 · 갈래 시점 지정 -(요구 445 A4~A6 · 446) — TS `logic/D03_PlanAnalysis_Plan.ts`. +"""585-2 · 585-3 예정노선 끌어 고르기 판정 · 여러 구간 연도 · 연도 구역 마커 나눔 · 합침 +(요구 445 A4~A6 · 446) — TS `logic/D03_PlanAnalysis_Plan.ts`. 갈래 시점 지정은 걷음(요구 463-15 · PLAN 598-3). 창(오른쪽으로) = 구간 꺾은선 점이 모두 상자 안 · 걸침(왼쪽으로) = 점 하나라도 안 또는 토막이 상자 변과 만남 · 돌린 화면도 보이는 px 로 셈 · 여러 구간 연도 한 번에. @@ -46,7 +46,7 @@ writeFileSync(process.argv[2], JSON.stringify({ ...marks(), })); -/** 585-3 — 마커 = 구역 나누기만(해 그대로) · 지움 = 앞 구간이 먹음 · 갈래 처음 해 · 갈래 시점 지정. */ +/** 585-3 — 마커 = 구역 나누기만(해 그대로) · 지움 = 앞 구간이 먹음 · 갈래 처음 해. */ function marks() { const span = (net) => net.pieces.filter((p) => p.line === "main").map((p) => [p.id, p.from_m, p.to_m, p.year]); let m = plan.newLine(plan.emptyNetwork(), [0, 0]); @@ -58,17 +58,9 @@ function marks() { // 갈래 처음 해 = 붙은 자리(60 m · p2 = 2028) 구간 해 let b = plan.normalizePieces(plan.insertVertex(yeared, 0, 0, [60, 0]), Y); b = plan.normalizePieces(plan.appendVertex(plan.addBranch(b, 0, 1), 1, [60, 50]), Y); - // 갈래 시점 지정 — (60,90) 에서 그어 (60,0) 꺾임점에 닿은 본선 → 닿은 끝이 시점인 갈래 - let t = plan.normalizePieces(plan.insertVertex(m, 0, 0, [60, 0]), Y); - t = plan.normalizePieces(plan.appendVertex(plan.newLine(t, [60, 90]), 1, [60, 0]), Y); - const branched = plan.branchFromEnd(t, 1); - const lone = plan.normalizePieces(plan.appendVertex(plan.newLine(m, [0, 50]), 1, [30, 80]), Y); return { cut: span(cut), edgeSame: edge === cut, merged: span(merged), branchYear: b.pieces.filter((p) => p.line !== "main").map((p) => p.year), - branched: typeof branched === "string" ? branched : branched.lines.map((l) => [l.id, l.vertices[0], l.branch]), - again: typeof branched === "string" ? null : plan.branchFromEnd(branched, 1), - lone: plan.branchFromEnd(lone, 1), }; } """ @@ -118,14 +110,9 @@ def test_여러_구간_연도(tmp_path: Path) -> None: assert years["p1"] == years["p3"] == 2028 and years["p2"] == 2026 -def test_마커_구역_나눔_합침_갈래(tmp_path: Path) -> None: +def test_마커_구역_나눔_합침_갈래_해(tmp_path: Path) -> None: got = _run_ts(tmp_path) assert got["cut"] == [["p1", 0, 40, 2026], ["p2", 40, 100, 2026]] # 구역만 나눔 · 해 그대로 assert got["edgeSame"] # 다른 마커 1 m 안 = 안 놓음 assert got["merged"] == [["p1", 0, 100, 2026]] # 지움 = 앞 구간 해 · id 가 먹음 assert got["branchYear"] == [2028] # 갈래 처음 해 = 붙은 자리 구간 해 - lines = {line_id: (head, branch) for line_id, head, branch in got["branched"]} - head, branch = lines[next(k for k in lines if k != "main")] - assert head == [60, 0] and branch == {"line": "main", "vertex": 1} # 닿은 끝 = 시점 - assert got["again"] == "이미 갈래입니다 — 갈라지는 점이 시점입니다." - assert got["lone"].startswith("다른 선에 닿은 끝이 없습니다") diff --git a/resources/tester/test_597_point_edit.py b/resources/tester/test_597_point_edit.py new file mode 100644 index 000000000..b4f90f33a --- /dev/null +++ b/resources/tester/test_597_point_edit.py @@ -0,0 +1,120 @@ +"""597-2 예정노선 지점 칸 손 고침 계산 — 이름 규칙 · 보이는 이름 · 경위도 읽기 · 더함 · 이름 바꿈 · 경위도로 옮김 +(요구 463 7 · 8) — TS `logic/D03_PlanAnalysis_PointEdit.ts`. + +./venv/Scripts/python.exe -m pytest resources/tester/test_597_point_edit.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_PointEdit.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; +import * as pe from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.js"; +import * as tmx from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Tm.js"; +const Y = 2026; +const tm = tmx.tmParams(5187); +// 지점 (100,0) 에서 시작하는 선 · 지점 둘 +let n = plan.newLine(plan.emptyNetwork(), [100, 0]); +n = plan.normalizePieces(plan.appendVertex(n, 0, [200, 0]), Y); +const state = { points: [[100, 0], [500, 500]], names: ["", "가"], network: n }; +const named = pe.setLineName(pe.setLineName(n, n.lines[0].id, ' 본선A '), 'none', 'x'); +const cleared = pe.setLineName(named, n.lines[0].id, ' '); +const added = pe.addPoint(state, [7, 8]); +const full = { points: Array.from({ length: 40 }, (_, i) => [i, 0]), names: [], network: n }; +const [lon, lat] = tmx.tmToLonLat(tm, 100, 0); +const moved = pe.movePointTo(state, 0, tm, lon + 0.001, lat); +const back = tmx.tmToLonLat(tm, ...moved.points[0]); +writeFileSync(process.argv[2], JSON.stringify({ + errors: ["", "가나다", "a/b", "a" + String.fromCharCode(92) + "b", "a:b", "a*b", "a?b", 'a"b', "ab", "a|b", "x".repeat(20), "x".repeat(21)] + .map((s) => plan.nameError(s)), + labels: [pe.pointLabel(["", " ", "가"], 0), pe.pointLabel(["", " ", "가"], 1), pe.pointLabel(["", " ", "가"], 2), pe.pointLabel([], 3)], + parse: [pe.parseLonLat("128.5", "36.9"), pe.parseLonLat("", "36.9"), pe.parseLonLat("128.5", "abc"), pe.parseLonLat("181", "0"), pe.parseLonLat("0", "-91")], + added: [added.points, added.names, added.network === n], + lineName: [named.lines[0].name, plan.lineNames(named.lines), cleared.lines[0].name, plan.lineNames(cleared.lines)], + full: pe.addPoint(full, [1, 1]) === full, + renamed: [pe.renamePoint(state, 1, " 나 ").names, pe.renamePoint({ ...state, names: [] }, 1, "다").names], + moved: { points: moved.points, lines: moved.network.lines.map((l) => l.vertices), back, expect: [lon + 0.001, lat], untouched: moved.points[1], names: moved.names }, +})); +""" + + +@pytest.fixture(scope="module") +def got(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp_path = tmp_path_factory.mktemp("pe") + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--rootDir", str(ROOT), + "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", + "--ignoreConfig"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "out.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_name_rule(got: dict) -> None: + ok, bad = got["errors"][:2], got["errors"][2:11] + assert ok == [None, None] # 빈 이름 · 한글 = 됨 + assert all(msg and "쓸 수 없습니다" in msg for msg in bad) # 파일 이름 못 쓰는 글자 아홉 + assert got["errors"][11] is None # 20 자 + assert "20 자" in got["errors"][12] # 21 자 = 막음 + + +def test_label_blank_falls_back(got: dict) -> None: + assert got["labels"] == ["지점 1", "지점 2", "가", "지점 4"] + + +def test_parse_lonlat(got: dict) -> None: + ok, no_lon, bad_lat, big_lon, big_lat = got["parse"] + assert ok == [128.5, 36.9] + assert no_lon.startswith("경도") and bad_lat.startswith("위도") + assert big_lon.startswith("경도") and big_lat.startswith("위도") + + +def test_add_and_limit(got: dict) -> None: + points, names, network_same = got["added"] + assert points == [[100, 0], [500, 500], [7, 8]] and names == ["", "가", ""] + assert network_same # 더해도 노선 그대로 + assert got["full"] # 40 개 = 그대로 + + +def test_rename(got: dict) -> None: + assert got["renamed"][0] == ["", "나"] # 앞뒤 빈칸 뺌 + assert got["renamed"][1] == ["", "다"] # 이름 목록이 짧아도 차례 맞춤 + + +def test_move_by_lonlat_drags_node(got: dict) -> None: + moved = got["moved"] + assert moved["back"] == pytest.approx(moved["expect"], abs=1e-7) # 경위도 왕복 + assert moved["lines"][0][0] == moved["points"][0] # 그 자리 노선 노드도 같이 + assert moved["lines"][0][1] == [200, 0] # 다른 노드는 그대로 + assert moved["untouched"] == [500, 500] and moved["names"] == ["", "가"] + + +def test_line_name(got: dict) -> None: + named, names, cleared, back = got["lineName"] + assert named == "본선A" and names == ["본선A"] # 앞뒤 빈칸 뺌 · 이름 우선 + assert cleared is None and back == ["본선"] # 빈 값 = 차례 이름으로 diff --git a/resources/tester/test_598_1_plan_select_layers.py b/resources/tester/test_598_1_plan_select_layers.py new file mode 100644 index 000000000..153a37311 --- /dev/null +++ b/resources/tester/test_598_1_plan_select_layers.py @@ -0,0 +1,150 @@ +"""598-1 예정노선 고름 층 노드 → 선(토막) → 구역 → 전체 · 토막 지움(요구 463 9 · 12 · 13·16 · 15) +— TS `logic/D03_PlanAnalysis_Select.ts`. + +끝 토막 지움 = 끝 점 뺌 · 가운데 토막 지움 = 구역 둘(뒤쪽 = 새 구역 · 갈래 고리 · 조각 따라감) · +사라지는 노드에 갈래 = 까닭 글 · 끌어 고르기 토막 판정 · 지점 ↔ 노드 같은 자리. + +./venv/Scripts/python.exe -m pytest resources/tester/test_598_1_plan_select_layers.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Select.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; +import * as sel from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.js"; +const Y = 2026; +// 본선 (0,0)→(100,0)→(200,0)→(300,0) · 마크 150 → p1 [0,150] p2 [150,300] · 갈래 at 노드 2 (200,0)→(200,50) +let n = plan.newLine(plan.emptyNetwork(), [0, 0]); +for (const x of [100, 200, 300]) n = plan.appendVertex(n, 0, [x, 0]); +n = plan.normalizePieces(plan.splitPiece(plan.normalizePieces(n, Y), "main", 150), Y); +n = plan.normalizePieces(plan.setPiecesYear(n, ["p2"], 2028), Y); +n = plan.normalizePieces(plan.appendVertex(plan.addBranch(n, 0, 2), 1, [200, 50]), Y); +const show = (net) => typeof net === "string" ? net : ({ + lines: net.lines.map((l) => [l.id, l.vertices, l.branch]), + pieces: plan.normalizePieces(net, Y).pieces.map((p) => [p.id, p.line, p.from_m, p.to_m, p.year]), +}); +const seg = (line, index) => ({ line, index }); +const view = ([x, y]) => [x, -y]; +writeFileSync(process.argv[2], JSON.stringify({ + segs: sel.allSegments(n), + range: sel.segmentRange(n, seg("main", 1)), + at: [sel.segmentAt(n.lines[0].vertices, 0), sel.segmentAt(n.lines[0].vertices, 100), sel.segmentAt(n.lines[0].vertices, 300)], + nodeSeg: [sel.nodeSegment(n, { line: "main", vertex: 1 }), sel.nodeSegment(n, { line: "main", vertex: 3 })], + pieces: sel.segmentPieces(n, [seg("main", 1)]).sort(), + middle: show(sel.removeSegment(n, seg("main", 0 + 1))), + tail: show(sel.removeSegment(n, seg("main", 2))), + head: show(sel.removeSegment(n, seg("main", 0))), + only: show(sel.removeSegment(n, seg("branch-1", 0))), + parentOnly: sel.removeSegments(n, [seg("main", 0), seg("main", 1), seg("main", 2)]), + many: show(sel.removeSegments(n, [seg("main", 0), seg("main", 2)])), + box: sel.segmentsInBox(n, view, { x0: 90, y0: -10, x1: 210, y1: 10 }, false), + cross: sel.segmentsInBox(n, view, { x0: 150, y0: 10, x1: 120, y1: -10 }, true), + all: show(sel.removeAll(n)), + nodes: sel.nodesAt(n, [200, 0]), + moved: sel.movePointsAt([[200, 0], [5, 5]], [200, 0], [210, 5]), + placed: (() => { + const out = sel.placeNode(n, [[100, 0], [9, 9]], 0, 1, { kind: "length", length_m: 50, slope_pct: null }, (x) => x * 0.1); + return [out.network.lines[0].vertices, out.network.lines[0].nodes, out.points]; + })(), + placeFirst: sel.placeNode(n, [], 0, 0, { kind: "free", length_m: null, slope_pct: null }, () => 0), + nodes594: (() => { + const r = { kind: "length", length_m: 100, slope_pct: null }; + const withNodes = { ...n, lines: n.lines.map((l, i) => (i ? l : { ...l, nodes: [null, r, r, r] })) }; + const out = sel.removeSegment(withNodes, seg("main", 1)); + return out.lines.filter((l) => l.id !== "branch-1").map((l) => [l.id, l.nodes]); + })(), +})); +""" + + +def _run_ts(tmp_path: Path) -> dict: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--rootDir", str(ROOT), + "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", + "--ignoreConfig"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "out.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_토막_자리(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + assert [s["line"] for s in got["segs"]] == ["main"] * 3 + ["branch-1"] + assert got["range"] == [100, 200] + assert got["at"] == [0, 1, 2] # 꺾임점 위 = 뒤 토막 · 끝 = 마지막 + assert got["nodeSeg"] == [{"line": "main", "index": 1}, {"line": "main", "index": 2}] + assert got["pieces"] == ["p1", "p2"] # 100~200 토막에 마크 150 = 두 구간 + + +def test_가운데_토막_지움_구역_둘(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + lines = {i: (v, b) for i, v, b in got["middle"]["lines"]} + assert lines["main"] == ([[0, 0], [100, 0]], None) # 앞쪽 = 같은 id + back = next(k for k in lines if k not in ("main", "branch-1")) + assert lines[back] == ([[200, 0], [300, 0]], None) # 뒤쪽 = 새 구역(본선) + assert lines["branch-1"][1] == {"line": back, "vertex": 0} # 갈래 고리가 따라감 + pieces = {(p[1], p[2], p[3]): (p[0], p[4]) for p in got["middle"]["pieces"]} + assert pieces[("main", 0, 100)] == ("p1", 2026) # 앞 조각 id 지님 + assert pieces[(back, 0, 100)][1] == 2028 # 뒤 조각 해 그대로 + + +def test_끝_토막_지움_끝_점_뺌(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + tail = {i: v for i, v, _ in got["tail"]["lines"]} + assert tail["main"] == [[0, 0], [100, 0], [200, 0]] + head = {i: (v, b) for i, v, b in got["head"]["lines"]} + assert head["main"][0] == [[100, 0], [200, 0], [300, 0]] + assert head["branch-1"][1] == {"line": "main", "vertex": 1} # 갈래 차례 −1 + assert [p[2:4] for p in got["head"]["pieces"] if p[1] == "main"] == [[0, 50], [50, 200]] + + +def test_갈래_붙은_노드_막음_하나뿐_구역_지움(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + only = got["only"]["lines"] + assert [i for i, _, _ in only] == ["main"] # 토막 하나뿐 = 구역 지움 + assert got["parentOnly"] == "갈래가 붙은 점이 사라집니다 — 갈래를 먼저 지우세요." + many = {i: v for i, v, _ in got["many"]["lines"]} + assert many["main"] == [[100, 0], [200, 0]] # 뒤부터 지움 = 차례 안 꼬임 + + +def test_끌어_고르기_전체_지점_노드(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + assert got["box"] == [{"line": "main", "index": 1}] # 창 = 두 끝 모두 안 + assert got["cross"] == [{"line": "main", "index": 1}] # 걸침 = 상자와 만남 + assert got["all"] == {"lines": [], "pieces": []} + assert got["nodes"] == [{"line": "main", "vertex": 2}] # 갈래 첫 점 뺌 + assert got["moved"] == [[210, 5], [5, 5]] + vertices, nodes, points = got["placed"] + assert vertices == [[0, 0], [50, 0], [200, 0], [300, 0]] # 앞 노드 기준 50 m · 뒤 노드 그대로 + assert nodes[1] == {"kind": "length", "length_m": 50, "slope_pct": None} and nodes[0] is None + assert points == [[50, 0], [9, 9]] # 그 자리 지점 같이 + assert got["placeFirst"] == "첫 노드는 방식이 없습니다." + rec = {"kind": "length", "length_m": 100, "slope_pct": None} + assert got["nodes594"] == [ + ["main", [None, rec]], + ["line-2", [None, rec]], + ] # 노드 기록 같이 · 뒤쪽 첫 = 없음 diff --git a/resources/tester/test_600_2_plan_measure.py b/resources/tester/test_600_2_plan_measure.py new file mode 100644 index 000000000..846b5d58c --- /dev/null +++ b/resources/tester/test_600_2_plan_measure.py @@ -0,0 +1,138 @@ +"""D03 거리재기 「노선 따라」 셈(PLAN 600-2 · 요구 463-23) — `logic/D03_PlanAnalysis_Measure.ts`. + +같은 선 = 누가 차 · 갈래 ↔ 본선 = 갈래 고리를 따라 올라가 만나는 점까지 · 이어지지 않은 본선끼리 = null. +TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이). + +./venv/Scripts/python.exe -m pytest resources/tester/test_600_2_plan_measure.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Measure.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +# main 200 m (0,0)→(100,0)→(100,100) · b1 = main 꼭짓점 1 에서 60 m · b2 = b1 끝에서 40 m · other = 이어지지 않은 본선 +NETWORK = { + "pieces": [], + "lines": [ + {"id": "main", "vertices": [[0, 0], [100, 0], [100, 100]], "branch": None}, + {"id": "b1", "vertices": [[100, 0], [160, 0]], "branch": {"line": "main", "vertex": 1}}, + {"id": "b2", "vertices": [[160, 0], [160, 40]], "branch": {"line": "b1", "vertex": 1}}, + {"id": "other", "vertices": [[0, 500], [50, 500]], "branch": None}, + ], +} + +CASES = { + "same": ["routeSpan", NETWORK, {"line": "main", "atM": 40}, {"line": "main", "atM": 150}], + "same_back": ["routeSpan", NETWORK, {"line": "main", "atM": 150}, {"line": "main", "atM": 40}], + "branch_main_after": [ + "routeSpan", + NETWORK, + {"line": "b1", "atM": 30}, + {"line": "main", "atM": 150}, + ], + "branch_main_before": [ + "routeSpan", + NETWORK, + {"line": "b1", "atM": 30}, + {"line": "main", "atM": 60}, + ], + "twig_root": ["routeSpan", NETWORK, {"line": "b2", "atM": 40}, {"line": "main", "atM": 0}], + "twig_twig": ["routeSpan", NETWORK, {"line": "b2", "atM": 10}, {"line": "b1", "atM": 20}], + "apart": ["routeSpan", NETWORK, {"line": "main", "atM": 10}, {"line": "other", "atM": 10}], + "near_branch": ["nearestOnRoute", NETWORK, [130, 5], {"line": "main", "atM": 10}], + "near_other_excluded": ["nearestOnRoute", NETWORK, [20, 490], {"line": "main", "atM": 10}], +} + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as measure from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = {}; +for (const [key, [name, ...args]] of Object.entries(cases)) out[key] = measure[name](...args); +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def r(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp = tmp_path_factory.mktemp("measure") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--outDir", + str(out), + "--rootDir", + str(ROOT), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + 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") + job, result = tmp / "job.json", tmp / "out.json" + job.write_text(json.dumps(CASES), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(job), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_같은_선은_누가_차(r): + assert r["same"]["lengthM"] == pytest.approx(110) + assert r["same"]["stations"] == [40, 150] + assert r["same"]["path"][0] == [40, 0] + assert r["same"]["path"][-1] == [100, 50] + assert r["same_back"]["lengthM"] == pytest.approx(110) + assert r["same_back"]["path"][0] == [100, 50] + + +def test_갈래에서_본선으로(r): + """갈래 30 m 지점 → 갈래 첫 점(30) → 본선 100 m → 150 m(50) = 80 · 본선 60 m 쪽 = 30 + 40 = 70.""" + assert r["branch_main_after"]["lengthM"] == pytest.approx(80) + assert r["branch_main_after"]["stations"] is None + assert r["branch_main_after"]["path"][0] == [130, 0] + assert r["branch_main_before"]["lengthM"] == pytest.approx(70) + + +def test_갈래의_갈래는_고리를_따라_올라감(r): + """b2 끝(40) → b1 끝(160,0)까지 → b1 첫 점(60) → 본선 0 m(100) = 200 m.""" + assert r["twig_root"]["lengthM"] == pytest.approx(200) + # b2 10 m(160,10) → b2 첫 점 10 → b1 20 m(120,0) 까지 40 = 50 + assert r["twig_twig"]["lengthM"] == pytest.approx(50) + + +def test_이어지지_않은_본선은_길_없음(r): + assert r["apart"] is None + + +def test_가까운_자리는_이어진_선_안에서만(r): + assert r["near_branch"]["line"] == "b1" + assert r["near_branch"]["atM"] == pytest.approx(30) + assert r["near_other_excluded"]["line"] == "main" diff --git a/resources/tester/test_603_plan_slope_bands.py b/resources/tester/test_603_plan_slope_bands.py new file mode 100644 index 000000000..f6d1e0935 --- /dev/null +++ b/resources/tester/test_603_plan_slope_bands.py @@ -0,0 +1,172 @@ +"""D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 서버 1 % 칸 수 + +`logic/D03_PlanAnalysis_SlopeBands.ts`. + +1 % 칸 수 합 = 유효 칸 · 기본 범위(26.8 · 70.0 · 83.9 %) 비율 = 옛 15 · 35 · 40° 비율 · +범위 막음(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음) · 깨진 취향 = 기본 · 250 % 이상 칸 = 끝 범위. +TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이). + +./venv/Scripts/python.exe -m pytest resources/tester/test_603_plan_slope_bands.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import numpy as np +import pytest + +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" +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 + + +def _hills(n: int = 300) -> np.ndarray: + """0 ~ 60° 남짓 고루 섞인 언덕(구멍 하나).""" + y, x = np.mgrid[0:n, 0:n] * CELL + return 40 * np.sin(x / 37) * np.cos(y / 53) + 25 * np.sin((x + 2 * y) / 19) + 0.3 * x + + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as bands from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = { DEFAULT: bands.DEFAULT_SLOPE_BANDS }; +// 인자 null = 기본 범위 +for (const [key, [name, ...args]] of Object.entries(cases)) + out[key] = bands[name](...args.map((a) => (a === null ? bands.DEFAULT_SLOPE_BANDS : a))); +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + +C = "--draw-map-landslide-3" + + +@pytest.fixture(scope="module") +def hills() -> dict: + z = _hills() + valid = np.ones(z.shape, bool) + valid[100:140, 100:180] = False + value = layers.slope_values_png(z, valid, CELL) + deg = np.degrees(slope_rad(np.where(valid, z, np.nan), CELL)) + known = np.isfinite(deg) + old = [float(((deg >= lo) & (deg < hi) & known).sum() / known.sum()) for lo, hi in OLD_DEG] + return {"counts": value["counts"], "known": int(known.sum()), "old": old} + + +@pytest.fixture(scope="module") +def r(tmp_path_factory: pytest.TempPathFactory, hills: dict) -> dict: + tmp = tmp_path_factory.mktemp("slope_bands") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--outDir", + str(out), + "--rootDir", + str(ROOT), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + 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") + band = lambda lo, hi: {"low": lo, "high": hi, "color": C} # noqa: E731 + top = [0] * 250 + [7] + cases = { + "default_ratios": ["bandRatios", hills["counts"], None], + "user_ratios": [ + "bandRatios", + hills["counts"], + [band(10, 15), band(15, 20), band(20, 250), band(0, 10)], + ], + "top": ["bandRatios", top, [band(83.9, 250)]], + "top_closed": ["bandRatios", top, [band(83.9, 249)]], + "ok": ["checkBands", [band(10, 15), band(15, 20), band(20, 250), band(0, 10)]], + "overlap": ["checkBands", [band(10, 20), band(15, 30)]], + "empty_value": ["checkBands", [band(None, 20)]], + "out_of_range": ["checkBands", [band(10, 260)]], + "upside_down": ["checkBands", [band(30, 20)]], + "none": ["checkBands", []], + "bad_color": ["checkBands", [{"low": 1, "high": 2, "color": "red"}]], + "parse_broken": ["parseBands", "{oops"], + "parse_overlap": ["parseBands", json.dumps([band(10, 20), band(15, 30)])], + "parse_ok": ["parseBands", json.dumps([band(10, 20)])], + "deg": ["pctToDeg", 100], + } + job, result = tmp / "job.json", tmp / "out.json" + job.write_text(json.dumps(cases), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(job), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_칸_수_합은_유효_칸(hills): + assert sum(hills["counts"]) == hills["known"] + assert len(hills["counts"]) == layers.SLOPE_MAX_PCT + 1 + + +def test_기본은_지식DB_15_35_40도(r): + assert [(b["low"], b["high"]) for b in r["DEFAULT"]] == [ + (26.8, 70.0), + (70.0, 83.9), + (83.9, 250), + ] + + +def test_기본_비율은_옛_15_35_40도_비율과_같음(r, hills): + """1 % 칸 경계(26.8 → 27 · 83.9 → 84 %) 차이만 — 범례 0.1 % 자리 반 칸 안.""" + for new, old in zip(r["default_ratios"], hills["old"], strict=True): + assert old > 0.02 + assert new == pytest.approx(old, abs=0.002) + + +def test_사용자_범위_넷(r): + ratios = r["user_ratios"] + assert len(ratios) == 4 and sum(ratios) == pytest.approx(1.0) + + +def test_250_퍼센트_이상은_끝_범위(r): + assert r["top"] == [1.0] + assert r["top_closed"] == [0.0] + + +def test_범위_막음(r): + assert r["ok"] is None + assert "겹칩니다" in r["overlap"] + assert "비었습니다" in r["empty_value"] + assert "0~250" in r["out_of_range"] + assert "아래가 위보다" in r["upside_down"] + assert "하나는" in r["none"] + assert "색" in r["bad_color"] + + +def test_깨진_취향은_기본(r): + assert r["parse_broken"] == r["DEFAULT"] + assert r["parse_overlap"] == r["DEFAULT"] + assert r["parse_ok"] == [{"low": 10, "high": 20, "color": C}] + assert r["deg"] == pytest.approx(45.0) diff --git a/ui_template/ui_template_gl_map.ts b/ui_template/ui_template_gl_map.ts index e61fe4e95..c3ec62d0b 100644 --- a/ui_template/ui_template_gl_map.ts +++ b/ui_template/ui_template_gl_map.ts @@ -65,6 +65,17 @@ export function satelliteTileSource(projectId: string): SourceSpecification { }; } +/** 느린 조작 잼(600-4) — `globalThis.__aisloPerf = true` 일 때만 `performance.measure(이름)` 으로 걸린 ms 를 남김(꺼져 있으면 그냥 실행). */ +export function perfTime(name: string, run: () => T): T { + if (!(globalThis as { __aisloPerf?: boolean }).__aisloPerf) return run(); + const start = performance.now(); + try { + return run(); + } finally { + performance.measure(name, { start, end: performance.now() }); + } +} + export function createGlMap(opts: { /** 겹 차례(아래 → 위)와 꼴 — 밝음 · 어두움이 바뀌면 다시 부름. */ specs: () => GlSpec[]; @@ -190,8 +201,8 @@ export function createGlMap(opts: { camera(center, zoom, bearingDeg) { if (!map) return; if (ready && themeKey() !== theme) apply(); - map.jumpTo({ center, zoom, bearing: bearingDeg }); - if (ready) map.redraw(); // 캔버스와 같은 화면(다음 화면으로 미루지 않음) + perfTime("gl:jumpTo", () => map!.jumpTo({ center, zoom, bearing: bearingDeg })); + if (ready) perfTime("gl:redraw", () => map!.redraw()); // 캔버스와 같은 화면(다음 화면으로 미루지 않음) }, resize: () => map?.resize(), }; diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index d9f51ecfe..1f6689cbe 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -102,6 +102,13 @@ export const ui_locales_de = { DE_Reg_SiteNoAddress: ["주소를 찾지 못했습니다", "Address not found"], DE_Reg_SiteSource: ["출처", "Source"], DE_Reg_SiteRequired: ["지도에서 기준점을 찍으세요.", "Set the reference point on the map."], + DE_Reg_Find: ["주소 · 장소 찾기 (Enter)", "Find address or place (Enter)"], + DE_Reg_FindShort: ["두 글자 이상", "Two or more characters"], + DE_Reg_FindBusy: ["찾는 중…", "Searching…"], + DE_Reg_FindNone: ["찾은 곳 없음", "Nothing found"], + DE_Reg_FindFail: ["찾지 못함", "Search failed"], + DE_Reg_FindAddress: ["주소", "Address"], + DE_Reg_FindPlace: ["장소", "Place"], DE_Reg_MapType: ["바탕 지도", "Base map"], DE_Reg_MapNormal: ["일반", "Map"], DE_Reg_MapSatellite: ["위성", "Satellite"],