From f1834465c80d2f5d876fbf37c2fc9a70c84b4623 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 05:29:18 +0900 Subject: [PATCH 1/6] =?UTF-8?q?perf(B04):=20=EC=B0=BD=EC=9D=B4=20=EB=AA=A8?= =?UTF-8?q?=EC=9E=90=EB=9D=BC=EB=A9=B4=20=EA=B7=B8=20=ED=83=80=EC=9D=BC?= =?UTF-8?q?=EB=A7=8C=20=EB=84=93=ED=98=80=20=EB=8B=A4=EC=8B=9C=20=EC=9E=AC?= =?UTF-8?q?=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 0-10 이어짐. 첫 판에서는 창이 모자란 단계를 **격자 전체**로 되돌렸는데, 격자가 커질수록 그 폴백이 25/91 까지 늘어 이득이 대부분 사라졌음(실측 확장 97.7s). - 모자란 타일만 창을 4배로 넓혀 다시 잼. 단계 전체를 되돌리지 않음. - 타일 256 → 128 (실측: 128/여유32 2.1s vs 256/여유32 2.7s). - 넓힘 횟수를 계측 줄에 남김(폴백 대신). 자체검증 — 전체격자 5.2s → 2.1s(넓힘 15/91), 거리·목표행·목표열 전부 동일. `build_contour_descent` 결과도 옛 코드와 완전 동일(8.0s → 5.1s). 시험 500 통과. Co-Authored-By: Claude Opus 5 (1M context) --- ...B04_PreProcess_Engine_Watershed_Descent.py | 93 +++++++++++-------- 1 file changed, 56 insertions(+), 37 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Descent.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Descent.py index b15c16ca..5f7a54e7 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Descent.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Descent.py @@ -130,18 +130,17 @@ def rasterize_contours( # 타일만** 그 둘레 `margin` 까지 잘라 EDT 를 돌린다. # # 창 밖에 더 가까운 등고선이 있을 수 있으면(창 안 최대 거리가 여유에 닿거나 창에 낮은 -# 라인이 없으면) 그 단계를 전체 격자로 다시 돌린다 — **근사가 아니라 같은 값을 싸게 -# 구하는 것**. 값이 같은지는 `np.array_equal` 로 확인했다(거리·목표행·목표열 전부). +# 라인이 없으면) **그 타일만** 창을 4배로 넓혀 다시 잰다 — 근사가 아니라 같은 값을 싸게 +# 구하는 것. 단계 전체를 격자 전체로 되돌리면 이득이 사라진다(그 방식일 때 폴백 25/91). # -# 고른 근거(745×1035 격자·166단 중 EDT 도는 91단, 전체격자 방식 10.8s): -# 타일 128 여유 32 → 3.2s(폴백 9) 타일 256 여유 24 → 3.3s(폴백 27) -# **타일 256 여유 32 → 2.9s(폴백 9)** 타일 256 여유 48 → 3.2s(폴백 4) -# 타일 512 여유 32 → 4.4s(폴백 9) -# 여유는 타일 크기와 무관하게 **거리 실측**으로 정했다 — 한 단 아래 등고선은 주곡선 -# 5m·셀 1m 에서 대개 수십 칸 안이다. 지형이 완만해 폴백이 잦아지면(로그에 횟수가 -# 찍힌다) 여유를 늘릴 것. +# 고른 근거(745×1035 격자·166단 중 EDT 도는 91단, 전체격자 방식 5.2s): +# **타일 128 여유 32 → 2.1s(넓힘 15)** 타일 128 여유 24 → 1.9s(넓힘 52) +# 타일 128 여유 48 → 2.5s(넓힘 7) 타일 256 여유 32 → 2.7s(넓힘 15) +# 타일 512 여유 32 → 4.5s(넓힘 15) +# 여유 24 가 0.2s 빠르지만 넓힘이 52회로 지형에 예민해 32 로 뒀다. 한 단 아래 등고선은 +# 주곡선 5m·셀 1m 에서 대개 수십 칸 안이다. 넓힘 횟수는 계측 줄에 찍힌다. DESCENT_WINDOW_MARGIN_CELLS = 32 -DESCENT_TILE_CELLS = 256 +DESCENT_TILE_CELLS = 128 def _distance_to_lower( @@ -149,11 +148,11 @@ def _distance_to_lower( lower: np.ndarray, margin: int | None = None, tile: int | None = None, -) -> tuple[np.ndarray, np.ndarray, np.ndarray, bool]: +) -> tuple[np.ndarray, np.ndarray, np.ndarray, int]: """`members` 셀에서 한 단 낮은 등고 라인까지의 거리와 목표 셀(**전체 격자 좌표**). - 네 번째 값은 전체 격자로 되돌아갔는지(폴백) 여부다. 반환 순서는 `members` 의 - 행우선 순서 — 호출부가 `distance[members] = ...` 로 그대로 넣는다. + 네 번째 값은 창을 넓혀 다시 잰 타일 수다(0 = 전부 첫 창에서 끝남). 반환 순서는 + `members` 의 행우선 순서 — 호출부가 `distance[members] = ...` 로 그대로 넣는다. """ margin = DESCENT_WINDOW_MARGIN_CELLS if margin is None else margin tile = DESCENT_TILE_CELLS if tile is None else tile @@ -161,6 +160,7 @@ def _distance_to_lower( out_distance = np.zeros((rows, cols), dtype=np.float64) out_row = np.zeros((rows, cols), dtype=np.int32) out_col = np.zeros((rows, cols), dtype=np.int32) + widened = 0 for row_start in range(0, rows, tile): row_stop = min(rows, row_start + tile) @@ -169,32 +169,49 @@ def _distance_to_lower( tile_members = members[row_start:row_stop, col_start:col_stop] if not tile_members.any(): continue - win_row0 = max(0, row_start - margin) - win_row1 = min(rows, row_stop + margin) - win_col0 = max(0, col_start - margin) - win_col1 = min(cols, col_stop + margin) - window_lower = lower[win_row0:win_row1, win_col0:win_col1] - if not window_lower.any(): - return _full_distance_to_lower(members, lower) - step, (step_row, step_col) = distance_transform_edt(~window_lower, return_indices=True) - in_window = np.zeros(window_lower.shape, dtype=bool) - in_window[ - row_start - win_row0 : row_stop - win_row0, - col_start - win_col0 : col_stop - win_col0, - ] = tile_members - hit_rows, hit_cols = np.nonzero(in_window) - values = step[hit_rows, hit_cols] - # 타일 둘레로 `margin` 을 뒀으므로, 거리가 그보다 짧으면 창 밖에 더 가까운 - # 것은 있을 수 없다. 닿으면 이 단계는 통째로 전체 격자로 다시 잰다. - if values.size and float(values.max()) >= margin: - return _full_distance_to_lower(members, lower) + # 첫 창에서 안 닿으면 **그 타일만** 창을 넓혀 다시 잰다 — 단계 전체를 격자 + # 전체로 되돌리면 이득이 사라진다(실측 폴백 25/91). + reach = margin + attempt = 0 + while True: + win_row0 = max(0, row_start - reach) + win_row1 = min(rows, row_stop + reach) + win_col0 = max(0, col_start - reach) + win_col1 = min(cols, col_stop + reach) + whole = win_row0 == 0 and win_col0 == 0 and win_row1 == rows and win_col1 == cols + window_lower = lower[win_row0:win_row1, win_col0:win_col1] + if not window_lower.any(): + if whole: + return _full_distance_to_lower(members, lower) + reach *= 4 + attempt += 1 + widened += 1 + continue + step, (step_row, step_col) = distance_transform_edt( + ~window_lower, return_indices=True + ) + in_window = np.zeros(window_lower.shape, dtype=bool) + in_window[ + row_start - win_row0 : row_stop - win_row0, + col_start - win_col0 : col_stop - win_col0, + ] = tile_members + hit_rows, hit_cols = np.nonzero(in_window) + values = step[hit_rows, hit_cols] + # 타일 둘레로 `reach` 를 뒀으므로, 거리가 그보다 짧으면 창 밖에 더 + # 가까운 것은 있을 수 없다. 닿으면 창을 넓혀 다시 잰다. + if not whole and values.size and float(values.max()) >= reach: + reach *= 4 + attempt += 1 + widened += 1 + continue + break global_rows = hit_rows + win_row0 global_cols = hit_cols + win_col0 out_distance[global_rows, global_cols] = values out_row[global_rows, global_cols] = step_row[hit_rows, hit_cols] + win_row0 out_col[global_rows, global_cols] = step_col[hit_rows, hit_cols] + win_col0 - return out_distance[members], out_row[members], out_col[members], False + return out_distance[members], out_row[members], out_col[members], widened def _full_distance_to_lower( @@ -244,7 +261,8 @@ def build_contour_descent( target_row = np.zeros((rows, cols), dtype=np.int32) target_col = np.zeros((rows, cols), dtype=np.int32) band_rank = np.full((rows, cols), -1, dtype=np.int32) - window_fallbacks = 0 + window_widened = 0 + edt_steps = 0 for rank, elevation in enumerate(levels[:-1]): members = inside & (band_elevation == elevation) if not members.any(): @@ -252,8 +270,9 @@ def build_contour_descent( lower = on_contour & (burned < elevation) if not lower.any(): continue - step_distance, step_row, step_col, fell_back = _distance_to_lower(members, lower) - window_fallbacks += int(fell_back) + step_distance, step_row, step_col, widened = _distance_to_lower(members, lower) + window_widened += widened + edt_steps += 1 distance[members] = step_distance.astype(np.float32) target_row[members] = step_row target_col[members] = step_col @@ -263,7 +282,7 @@ def build_contour_descent( # 그래야 그 자리가 도로·세류선이면 적색으로 잡히고, 아니면 물이 고이는 지점으로 보인다. marks.append( ( - f"밴드별 하강거리(EDT {len(levels) - 1}회 · 전체격자 폴백 {window_fallbacks}회)", + f"밴드별 하강거리(EDT {edt_steps}단 · 창 넓힘 {window_widened}회)", time.perf_counter(), ) ) From 1553f23c0d9fa2f0f06fd2cbdab9bad290593d8a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 05:48:38 +0900 Subject: [PATCH 2/6] =?UTF-8?q?perf(B04):=20=EB=93=B1=EA=B3=A0=EC=84=A0=20?= =?UTF-8?q?=ED=94=BC=EC=B2=98=20=ED=92=80=EA=B8=B0=EB=A5=BC=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=88=EB=A7=8C=20(=EA=B2=A9=EC=9E=90=EC=99=80=20=EB=AC=B4?= =?UTF-8?q?=EA=B4=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 0-10. 「등고선 굽기」가 확장 8회 합 19s 였는데, 실측해 보니 굽기 자체는 0.8~2.0s 이고 나머지는 **피처 4,200개를 회차마다 다시 `shape()` 로 푸는 값**이었음. 푸는 결과는 격자와 무관하므로 같은 목록·같은 하한이면 그대로 쓴다(목록 객체를 함께 들고 있어 id 가 다른 목록에 재사용되지 않음, 최근 2벌만 보관). 실측 — 같은 격자 연속 4회: 2.66 · 1.90 · 1.99 · 2.03s (2회차부터 파싱 재사용), 결과 배열은 4회 모두 동일. `build_contour_descent` 전체는 옛 코드 12.8s → 7.2s 이며 band_elevation·valid·receiver·step_length·azimuth·levels 전부 `np.array_equal` 동일. 안 한 것 — 격자가 커질 때 옛 굽기를 옮겨 붙이고 **테두리만** 굽는 안은 만들어 재 봤으나 `rasterize` 호출당 고정 비용 때문에 오히려 3.2s → 10.6s 로 느렸고, 창에 걸치는 선만 넘기도록 고쳐도 3.8s 로 손해였음(창 4개 × 166단). 그래서 걷어내고 파싱 캐시만 남김. `levels` 는 피처 전체에서 나오므로 격자와 무관하다는 것도 확인함. Co-Authored-By: Claude Opus 5 (1M context) --- ...B04_PreProcess_Engine_Watershed_Descent.py | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Descent.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Descent.py index 5f7a54e7..e854be43 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Descent.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Descent.py @@ -72,12 +72,25 @@ class ContourDescent: levels: list[float] # 사용된 등고 표고(내림차순) -def rasterize_contours( - spec: GridSpec, - contour_features: list[dict[str, Any]], - elevation_floor_m: float | None = None, -) -> tuple[np.ndarray, list[float]]: - """등고선을 격자에 굽는다. 셀마다 그 위를 지나는 등고 라인의 표고(없으면 NaN).""" +_LINES_CACHE: list[tuple[Any, int, float | None, dict[float, list[Any]]]] = [] + + +def _lines_by_level( + contour_features: list[dict[str, Any]], elevation_floor_m: float | None +) -> dict[float, list[Any]]: + """등고선 피처를 표고별 선 묶음으로 푼다 — **격자와 무관**하므로 한 번만 푼다. + + 확장 회차마다 다시 부르는데 피처 4,200개를 매번 `shape()` 로 푸는 비용이 그대로 + 붙었다. 같은 목록·같은 하한이면 그대로 돌려준다(목록 객체를 함께 들고 있어 id 가 + 다른 목록에 재사용되지 않는다). + """ + for holder, count, floor, cached in _LINES_CACHE: + if ( + holder is contour_features + and count == len(contour_features) + and floor == elevation_floor_m + ): + return cached by_level: dict[float, list[Any]] = {} for feature in contour_features: geometry = feature.get("geometry") @@ -96,7 +109,18 @@ def rasterize_contours( if line.length < DRAINAGE_CONTOUR_MIN_LENGTH_M: continue by_level.setdefault(float(elevation), []).append(line) + _LINES_CACHE.append((contour_features, len(contour_features), elevation_floor_m, by_level)) + del _LINES_CACHE[:-2] + return by_level + +def rasterize_contours( + spec: GridSpec, + contour_features: list[dict[str, Any]], + elevation_floor_m: float | None = None, +) -> tuple[np.ndarray, list[float]]: + """등고선을 격자에 굽는다. 셀마다 그 위를 지나는 등고 라인의 표고(없으면 NaN).""" + by_level = _lines_by_level(contour_features, elevation_floor_m) burned = np.full((spec.n_rows, spec.n_cols), np.nan, dtype=np.float32) levels = sorted(by_level, reverse=True) transform = grid_transform(spec) From e9ad8cdfaf7d686ad2650fd4b9ea7688b74cdaf7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 07:25:28 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix(B06):=20=EB=8B=A4=EB=8B=A8=20=EA=B8=B0?= =?UTF-8?q?=EC=8A=AD=EB=A7=89=EC=9D=B4=20=EC=84=B1=ED=86=A0=EB=B6=80?= =?UTF-8?q?=EC=84=A0=EC=9D=84=20=EB=A9=B4=EC=A0=81=20=ED=8A=B8=EB=A6=BC?= =?UTF-8?q?=EC=97=90=20=EB=84=A3=EC=9D=8C=20=E2=80=94=20=EB=8B=A8=20?= =?UTF-8?q?=EC=88=98=EA=B0=80=20=EB=AC=BC=EB=9F=89=EC=97=90=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 3-5 ⓑ (2026-09-07 사용자 확정 「옹벽도 수량이 변경되어야 함」). 그동안 면적 폐회로의 바깥 경계가 **기준벽 사면(노견~벽 상단)** 까지만이었고 그 바깥은 원지반으로 봐서 면적 0 이었음. 그래서 단을 몇 개 올려도 절·성토가 한 자리도 안 변했음. - 화면이 그리는 선(`outletFill.segments`·`basinFill.segments`)을 트림 사면에 이어 붙임. 「면적은 실제로 그려지는 설계선을 따른다」는 기존 원칙과 같은 자리로 맞춘 것. - 그리지 않는 `cut` 갈래(벽이 원지반에 묻혀 성토가 없는 자리)는 뺌 — 화면과 같은 규칙. 실측(용화 route 165, 새 트림 대 옛 트림): 81.77m 0단 성토 8.4247 → 10.9027(+2.478) · 1단 이상 10.6743(+2.250) · 절토 불변 244.14m 지형이 단을 거부(0단) · 성토 3.3492 → 4.0511(+0.702) · 절토 불변 423.55m 벽이 묻힌 자리(cut) · 성토 3.6145 그대로 · 변화 없음 → 단이 서면 성토가 줄고(10.90 → 10.67), 단이 안 서는 자리도 **벽 바깥 성토가 이제 잡힘**. 후자는 단과 무관한 변화라 보고에 따로 적었음. 파이썬 짝은 없음 — 구조물 트림 면적은 TS 한 벌이고 서버는 그것을 Node 로 돌림 (`B06_Section_Engine_Areas.py` 는 구조물 없는 표준 계산). tsc 통과·시험 500 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B06_Section_UI_Cross_Culvert_Geom.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 6e1d1b33..124858b3 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -49,6 +49,7 @@ import { inletChoiceAvailability, resolveBasinChoice, } from "./B06_Section_UI_Cross_Culvert_Basin"; +import type { OutletExtrasResult } from "./B06_Section_UI_Cross_Culvert_Extra"; import { buildExtrasAt, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { @@ -644,6 +645,25 @@ export function computeCulvertLayout( trimMinSlope = { points: slope.points }; } } + // 다단 기슭막이의 성토부선까지 트림에 넣는다 — 넣지 않으면 단을 올려도 폐회로가 그대로라 + // **물량이 안 바뀐다**(2026-09-07 사용자 확정). 기준벽 사면은 노견~벽 상단까지고, 그 + // 바깥은 지금까지 원지반으로 봐서 면적이 0이었다. 화면이 그리는 선(`outletFill.segments`)과 + // 같은 선을 면적도 보게 맞춘다 — 그리지 않는 `cut` 갈래(벽이 원지반에 묻힌 자리)는 뺀다. + const drawnFillPoints = (result: OutletExtrasResult): OffsetPoint[] => + result.segments + .filter((segment) => segment.kind !== "cut") + .flatMap((segment) => segment.points); + const extendTrimSlope = (points: OffsetPoint[], outward: number): void => { + if (!points.length) return; + if (outward > 0) { + trimMaxSlope = { points: [...(trimMaxSlope?.points ?? []), ...points] }; + } else { + trimMinSlope = { points: [...(trimMinSlope?.points ?? []), ...points] }; + } + }; + extendTrimSlope(drawnFillPoints(extras), outletInfo.outward); + extendTrimSlope(drawnFillPoints(basinExtras), inletInfo.outward); + const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null; const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null; From a811286e52530bbda90be02d8f86466527812560 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 07:57:15 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(B06):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=EB=A5=BC=20B06=20=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=84=A3=EA=B2=8C=20(B=EA=B5=B0=20=EC=A2=85=EB=8B=A8=EB=B0=B0?= =?UTF-8?q?=EC=88=98=C2=B7F=EA=B5=B0=20=EC=83=9D=ED=83=9C/=EB=85=B9?= =?UTF-8?q?=ED=99=94=20=EC=97=B4=EB=A6=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 3-6 (2026-09-07 사용자 지시 5 「A군뿐 아니라 구조물 전체를 넣을 수 있어야 함」). 원인은 「B06 이 A군만 받는다」가 아니라 **레지스트리의 `enabled:false`** 였음 — B군 6종· F군 2종·G군 1종이 목록에서 통째로 빠져 있었고, 두 화면이 같은 목록을 쓰므로 B05·B06 이 똑같이 A/C/D/E/G/기타 여섯 군만 보였음. 레지스트리 주석(2026-08-17)이 그 셋을 「B05 선택지에서 빼고 **B06 개별 횡단도 옵션으로 재사용**」이라 적어 둔 자리임. - `fetchStructureTypes(includeDisabled)` 로 갈래를 둠. B06 만 참으로 부름 — B05 는 종전대로. - 함께: 3-5 ⓑ 되돌릴 자리 주석(단 2개 이상일 때만 이어 붙이는 한 줄) 표시. 자체검증 — 공용 브라우저에서 두 화면 군 목록 대조: B06 A·**B**·C·D·E·**F**·G·기타 (8) / B05 A·C·D·E·G·기타 (6, 그대로) B06 에서 B군 6종(측구·산마루측구·소단측구·도수로·절토사면 배수로·맹암거)과 F군 2종(야생동물 이동통로·비탈면 녹화)이 실제로 목록에 뜸. tsc 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Api_Structures.ts | 16 +++++++++++++--- B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts | 4 ++++ .../B06_Section_UI_Page_Structures_Panel.ts | 5 ++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index ad1bfb6d..e16ae580 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -129,18 +129,28 @@ async function requestJson(path: string, init: RequestInit = {}): Promise /** 타입 레지스트리는 서버 배포 중에 바뀌지 않으므로 탭 수명 동안 한 번만 받는다. */ let typesCache: Promise | null = null; -export function fetchStructureTypes(): Promise { +/** + * 구조물 타입 목록. + * + * `includeDisabled` 를 주면 `enabled:false` 타입(B군 종단배수·F군 생태/녹화·G군 일부)도 + * 함께 준다. 레지스트리 주석(2026-08-17)이 「B05 선택지에서 빼고 **B06 개별 횡단도 + * 옵션으로 재사용**」이라 적어 둔 그 자리다 — 2026-09-07 사용자 지시 「A군뿐 아니라 + * 구조물 전체를 넣을 수 있어야 함」으로 B06 이 그 목록을 쓴다. B05 는 종전대로 켜진 것만. + */ +export function fetchStructureTypes(includeDisabled = false): Promise { if (!typesCache) { typesCache = requestJson("/projects/structure-types", { method: "GET", }) - .then((payload) => payload.types.filter((type) => type.enabled)) + .then((payload) => payload.types) .catch((error) => { typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다. throw error; }); } - return typesCache; + return typesCache.then((types) => + includeDisabled ? types : types.filter((type) => type.enabled), + ); } export async function fetchStructures(projectId: string): Promise { diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 124858b3..6c484416 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -653,6 +653,10 @@ export function computeCulvertLayout( result.segments .filter((segment) => segment.kind !== "cut") .flatMap((segment) => segment.points); + // ↓ 되돌릴 자리(2026-09-07) — 「단이 2개 이상일 때만 반영」으로 좁히려면 아래 두 줄의 + // `drawnFillPoints(extras)` 를 `extras.walls.length ? drawnFillPoints(extras) : []` 로 + // 바꾸면 된다(basinExtras 도 같은 꼴). 다만 그러면 화면이 그리는 성토부선과 면적이 + // 다시 어긋난다 — 사용자 판단 대기 중인 항목임(PLAN 3-5 ⓑ). const extendTrimSlope = (points: OffsetPoint[], outward: number): void => { if (!points.length) return; if (outward > 0) { diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 227dd80c..90d570f0 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -313,7 +313,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc const projectId = deps.projectId; try { const [types, stored, pipeResponse] = await Promise.all([ - fetchStructureTypes(), + // B06 은 **구조물 전체**를 넣을 수 있어야 한다(2026-09-07 사용자 지시 5). B05 목록에서 + // 빠져 있던 B군 종단배수·F군 생태/녹화도 여기서는 고를 수 있다 — 레지스트리 주석이 + // 「B06 개별 횡단도 옵션으로 재사용」이라 적어 둔 그 자리다. + fetchStructureTypes(true), fetchStructures(projectId), fetchDetailPipePoints(projectId), ]); From 6b76a3f2734add8c186a30cdece2a118b1445081 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 08:06:12 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(=EA=B5=AC=EC=A1=B0=EB=AC=BC):=20C?= =?UTF-8?q?=EA=B5=B0=20=EB=B2=BD=EC=97=90=20=E3=80=8C=EC=84=A4=EC=B9=98=20?= =?UTF-8?q?=EC=B8=A1=E3=80=8D=20=EC=B9=B8=20=EC=B6=94=EA=B0=80=20+=20?= =?UTF-8?q?=EC=B9=B8=EC=9D=84=20=EC=9E=A0=EA=B8=80=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EA=B5=AC=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 3-6 「설치 측을 사용자가 고르게 할지(폼에 칸 추가) 뒤에 정할 것」 — 2026-09-07 사용자 확정 「폼 선택은 가능하게 반영하고 나중에 선택 비활성화로 하자」. - 레지스트리 C군 5종(옹벽·돌쌓기(찰)·돌쌓기(메)·흙막이·큰돌쌓기)에 `side` 선택 칸. 선택지 「자동(성토 쪽)·좌·우」, 기본은 자동 — 종전 동작(성토 나는 쪽 자동)과 같음. - 기하는 이미 그 값을 봄 — `B06_Section_UI_Cross_Revetment.ts:168` 이 "좌"/"우" 면 그쪽, 아니면 `fillSide()`. 배관(`common_util_structure_walls.ts:80`)·파이썬 짝도 `options.side` 를 이미 나름. 즉 배선은 있고 **칸만 없었음**. - 옵션 칸에 `enabled` 를 둠(기본 참). 거짓이면 폼에 **회색으로 남고** 못 고르며 필수 검사 에서도 빠짐 — 칸을 지우지 않고 잠그는 자리(사용자 「나중에 비활성화」). 자체검증 — 레지스트리 5종 모두 `(['자동(성토 쪽)','좌','우'], 기본 '자동(성토 쪽)', enabled True)` 로 읽힘. 실화면 B06 C군 폼에 「설치 측」 칸이 그 세 선택지로 뜸(값 자동). tsc 통과. 시험 499 통과 — 실패 1·오류 8 은 모두 `require is not defined in ES module scope` 인 JS 도우미 건으로 내 변경과 무관(도우미 파일 날짜 8/27·9/2, 다른 환경에서 만든 것). Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Api_Structures.ts | 2 ++ B05_Profile/B05_Profile_Structure_Types.json | 35 +++++++++++++++++++ B05_Profile/B05_Profile_Structures_Schema.py | 4 +++ .../B05_Profile_UI_Structures_Panel.ts | 9 ++++- 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index e16ae580..b5ceb147 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -26,6 +26,8 @@ export interface StructureOptionField { default: string | number | null; /** 미확정 항목(기본값 없음) — 사용자가 값을 넣어야 저장된다. */ required?: boolean; + /** 거짓이면 폼에 **회색으로** 그려지고 못 고른다 — 칸은 남기되 잠그는 자리. */ + enabled?: boolean; /** 입력 시점 — B05는 유무·종류·위치만 받고 상세 치수(detail)는 B06/B07에서 받는다 * (2026-08-17 사용자 확정). detail이면 required여도 B05 폼에 그리지 않는다. */ phase?: "b05" | "detail"; diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 3cd0db4e..9a59af11 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -639,6 +639,13 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" } ] }, @@ -697,6 +704,13 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" } ] }, @@ -755,6 +769,13 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" } ] }, @@ -813,6 +834,13 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" } ] }, @@ -871,6 +899,13 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" } ] }, diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index fc59be63..1afe9e16 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -44,6 +44,10 @@ class StructureOptionField(BaseModel): # (2026-08-17 사용자 확정). `detail`이면 required여도 B05 저장에서 강제하지 않는다 # — 필수 원칙은 유지되고 강제 시점만 B06/B07로 미뤄진다. phase: Literal["b05", "detail"] = "b05" + # 폼에 칸은 두되 **지금은 못 고르게** 할 때 거짓으로 둔다 — 회색으로 그려지고 값은 + # 기본값이 그대로 저장된다(2026-09-07 사용자: 「폼 선택은 가능하게 반영하고 나중에 + # 선택 비활성화로 하자」). 칸 자체를 없애면 나중에 켤 자리를 다시 찾아야 한다. + enabled: bool = True class StructureType(BaseModel): diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 81fabbd1..10c1395d 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -322,11 +322,18 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu input.value = String(preset ?? ""); } const label = option.unit ? `${option.label} (${option.unit})` : option.label; + // `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다. + const locked = option.enabled === false; + if (locked) { + input.disabled = true; + input.classList.add("is-locked"); + input.title = "지금은 고를 수 없는 항목입니다."; + } optionRow.append(field(label, input)); input.addEventListener("change", () => liveCommit()); optionInputs.push({ key: option.key, - required: !!option.required, + required: !locked && !!option.required, input, read: () => (option.input === "number" ? Number(input.value) || 0 : input.value), isEmpty: () => input.value.trim() === "", From 679726124bc91e4c0f63d69fc249bae88e138919 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 08:20:59 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(B06):=20=EC=A2=85=EB=8B=A8=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20B05=20?= =?UTF-8?q?=EC=99=80=20=ED=86=B5=EC=9D=BC=20=E2=80=94=20=EA=B0=99=EC=9D=80?= =?UTF-8?q?=20=EC=95=8C=EC=95=BD=20=EB=A0=88=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 2-5 (2026-09-07 사용자 지시 4 「구조물 표시 통일」). B06 종단에는 구조물 마크가 아예 없었고 배수관·세월교만 다른 계통으로 그려졌음. - B05 가 쓰는 **같은 부품**(`buildStructureLane`)을 B06 종단 그래프 아래에 붙임. 이름·중복 번호·툴팁·약호가 전부 B05 규칙 그대로 따라옴. - 종단 차트가 `toX`(누가거리 → px)를 내보내 레인이 그래프와 같은 자리를 씀. - 좌측 「구조물 배치」가 목록을 받을 때마다 `setStructureMarks` 로 넘김(구조물 정본 + 관 정본). 관을 가상 구조물로 바꾸는 `pipesToStructureMarks` 는 B05 안에 있던 것을 공용 자리(`B05_Profile_Api_Structures`)로 올려 **한 벌만** 둠. - 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고름(좌측 목록 클릭과 같은 규칙). 끌기는 B05 몫이라 여기서는 자리만 보임(배수유역 재분할이 걸림). - 검증 훅 `window.__b06Marks` 추가(`__corridorBuild` 등과 같은 용도). 자체검증 — 실화면 B06 종단에 레인 1줄·알약 10개(「배수관 1~8 · 세월교 1~2」)가 B05 와 같은 표기로 뜸. 훅 값 `{structures:10, types:36}`. tsc 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Api_Structures.ts | 27 +++++++++ B06_Section/B06_Section_UI_Page.ts | 12 ++++ .../B06_Section_UI_Page_Structures_Panel.ts | 13 ++++ B06_Section/B06_Section_UI_Section_View.ts | 60 +++++++++++++++++++ .../B06_Section_UI_Section_View_Chart.ts | 6 +- 5 files changed, 117 insertions(+), 1 deletion(-) diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index b5ceb147..d892eea8 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -186,6 +186,33 @@ export async function migrateLegacyStations( /** 종단도 마크 위치 = 기준점. 구간형도 chainage_m이 기준점이다(2026-08-17 사용자 * 확정: 기준점에 마킹 + 시작·종료 측점). 기준점이 없는 기존 저장분은 시점으로 본다. */ +/** 관 지점을 **알약 레인용 가상 구조물**로 만든다 — 정본은 `pipe_points.json` 이라 + * 저장하지 않는다. B05 종단과 B06 종단이 같은 표기를 쓰도록 한 벌만 둔다 + * (2026-09-07 사용자 지시 4 「구조물 표시 통일」). */ +export function pipesToStructureMarks( + pipes: ReadonlyArray<{ + chainage_m: number; + facility: string; + options?: Record | null; + }>, +): StructureInstance[] { + return pipes.map((pipe) => ({ + structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`, + type_id: pipe.facility, + placement: "point", + chainage_m: pipe.chainage_m, + start_m: null, + end_m: null, + options: (pipe.options ?? {}) as Record, + memo: "", + placement_source: "automatic", + status: "draft", + revision: 0, + geometry: null, + })) as StructureInstance[]; +} + + export function structureAnchorM(structure: StructureInstance): number { return structure.chainage_m ?? structure.start_m ?? 0; } diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 286b4e32..3f2f28b7 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -4,6 +4,10 @@ import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { stateKey } from "../A00_Common/b_page_state"; import { navigateTo } from "../A00_Common/router"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import type { + StructureInstance, + StructureType, +} from "../B05_Profile/B05_Profile_Api_Structures"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -139,10 +143,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화). // 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행]. + let structureMarksSink: + | ((structures: StructureInstance[], types: StructureType[]) => void) + | null = null; const structuresPanel = createB06StructuresPanel({ projectId, // 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자). reveal: () => layout.setOptionsOpen(true), + // 종단 알약 레인에 같은 목록을 넘긴다 — B05 와 같은 표기(2026-09-07 사용자 지시 4). + // 뷰는 이 패널보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다. + onMarks: (structures, types) => structureMarksSink?.(structures, types), // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시 // 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정). onStructuresChanged: () => void refreshDetailForStructures(), @@ -439,6 +449,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationControls.ford, stationControls.box, ); + // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). + structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). const pipeOptionsContext: PipeOptionsContext = { detail: () => sectionDetail, diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 90d570f0..a0152ed3 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -18,9 +18,11 @@ import { fetchStructures, fetchStructureTypes, readPendingStructures, + pipesToStructureMarks, structureAnchorM, writePendingStructures, type StructureInstance, + type StructureType, } from "../B05_Profile/B05_Profile_Api_Structures"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { @@ -62,6 +64,8 @@ export interface B06StructuresPanelDeps { focusChainage: (chainageM: number) => void; /** 바깥(횡단도·3D 넘김값)에서 고른 것이 폼에 실릴 때 — 접힌 좌측 패널을 펼친다. */ reveal?: () => void; + /** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */ + onMarks?: (structures: StructureInstance[], types: StructureType[]) => void; /** 폼 [수정]으로 바뀐 관 옵션을 캐시에 예약한다 — [저장]·[확정]이 정본에 쓴다 * (2026-08-29: 조정창 구간값과 같은 경로). 없으면 안내만 한다. */ queuePipeOptions?: (chainageM: number, patch: Record) => void; @@ -226,10 +230,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc item.structure_id ? item : { ...item, structure_id: crypto.randomUUID().replace(/-/g, "") }, ); + let markTypes: StructureType[] = []; + /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ + const pushMarks = (): void => + deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes); const section = createStructuresSection({ onChange: (next) => { structures = withLocalIds(next); section.setStructures(structures); + pushMarks(); if (deps.projectId) writePendingStructures(deps.projectId, structures); deps.onStructuresChanged?.(); }, @@ -255,6 +264,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (hit) hit.chainage_m = toChainageM; currentChainageM = toChainageM; section.setPipeFacilities(pipeFacilities); + pushMarks(); showToast(PIPE_MOVE_NOTICE, "success"); } else { showToast(PIPE_MOVE_GUIDE, "error"); @@ -321,6 +331,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc fetchDetailPipePoints(projectId), ]); section.setTypes(types); + markTypes = types; // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙). structures = readPendingStructures(projectId) ?? stored.structures; section.setStructures(structures); @@ -348,6 +359,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc )?.design_flow_m3s ?? null, })); section.setPipeFacilities(pipeFacilities); + // 종단 알약 레인 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합쳐 B05 와 같은 표기로. + pushMarks(); // 목록이 늦게 도착하면 그 사이에 고른 시설은 강조될 자리가 없었다 — 다시 세운다 // (2026-09-04 사용자 보고: B06 좌측 목록만 하이라이트가 안 붙음). 폼에 아직 아무것도 // 없으면 세션에 남은 선택(카드형 — 부재키 없는 측점)도 같은 규칙으로 세운다. diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index cadbcb1b..78cd7557 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -44,6 +44,13 @@ import { effectiveCardHalfWidth, } from "./B06_Section_UI_Cross_View_Metrics"; import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; +import { LONG_PAD } from "./B06_Section_UI_Section_Common"; +import { buildStructureLane } from "../B05_Profile/B05_Profile_UI_Structures_Marks"; +import { + structureAnchorM, + type StructureInstance, + type StructureType, +} from "../B05_Profile/B05_Profile_Api_Structures"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import { longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; @@ -114,6 +121,12 @@ export interface SectionViewController { * 올린다(2026-08-29 일원화). null = 선택 해제. */ setStationSelectListener: (listener: (stationId: string | null) => void) => void; clear: () => void; + /** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자 + * 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */ + setStructureMarks: ( + structures: ReadonlyArray, + types: ReadonlyArray, + ) => void; dispose: () => void; } @@ -142,6 +155,8 @@ export function createSectionView( let currentStationInterval: number | undefined; let currentConversion: EarthworkConversion | undefined; let currentNaturalSpoilSlope: number | undefined; + let markStructures: ReadonlyArray = []; + let markTypes: ReadonlyArray = []; let renderWidth = 0; let resizeTimer = 0; let panelResizeTimer = 0; @@ -493,6 +508,39 @@ export function createSectionView( }); const longAxis = chart.axis; const nodes: Element[] = [chart.node]; + // 구조물 알약 레인 — B05 종단과 **같은 부품**(`buildStructureLane`)을 그대로 쓴다. + // 배수관·세월교도 여기 같은 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일). + if (markStructures.length && markTypes.length) { + nodes.push( + buildStructureLane({ + structures: markStructures, + types: markTypes, + x: chart.toX, + chainageAt: chart.toChainage, + maxChainageM: chart.maxChainageM, + widthPx: chartWidth, + axisWidthPx: LONG_PAD.left, + stationIntervalM: cachedStationInterval, + selectedId: null, + // B06 에서 알약을 누르면 그 측점 카드를 고른다 — 목록 클릭과 같은 규칙. + onSelect: (structureId) => { + if (!structureId) return; + const hit = markStructures.find((entry) => entry.structure_id === structureId); + if (!hit) return; + const at = structureAnchorM(hit); + // 그 자리에 가장 가까운 측점 카드를 고른다 — 좌측 목록 클릭과 같은 규칙. + let best: { id: string; gap: number } | null = null; + for (const section of currentDetail?.cross_sections ?? []) { + const gap = Math.abs(section.chainage_m - at); + if (!best || gap < best.gap) best = { id: section.station_id, gap }; + } + if (best) selectStation(best.id, true); + }, + // 알약 끌기는 B05 몫이다(배수유역 재분할이 걸린다) — 여기서는 자리만 보여 준다. + onMove: () => undefined, + }), + ); + } chartWrap.replaceChildren(...nodes); // 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이 @@ -694,6 +742,18 @@ export function createSectionView( setStationSelectListener(listener) { stationSelectListener = listener; }, + setStructureMarks(structures, types) { + markStructures = structures; + markTypes = types; + // 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다 + // (`__corridorBuild` 등과 같은 용도). + (window as unknown as { __b06Marks?: unknown }).__b06Marks = { + structures: structures.length, + types: types.length, + drawn: !!currentDetail, + }; + if (currentDetail) drawPanel(); + }, clear() { currentDetail = null; selectedStationId = null; diff --git a/B06_Section/B06_Section_UI_Section_View_Chart.ts b/B06_Section/B06_Section_UI_Section_View_Chart.ts index 468efe3c..7ab41541 100644 --- a/B06_Section/B06_Section_UI_Section_View_Chart.ts +++ b/B06_Section/B06_Section_UI_Section_View_Chart.ts @@ -37,6 +37,8 @@ export interface LongitudinalChartResult { axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null; /** 화면 x(px) → 누가거리(m). 스크롤 갱신이 보이는 구간을 다시 잴 때 쓴다. */ toChainage: (px: number) => number; + /** 누가거리(m) → 화면 x(px). 구조물 알약 레인이 그래프와 같은 자리를 쓰게 한다. */ + toX: (chainageM: number) => number; maxChainageM: number; viewFromM: number; viewToM: number; @@ -78,6 +80,8 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi const maxChainageM = longitudinalMaxChainage(detail.longitudinal); const plotWidth = Math.max(1, input.chartWidth - LONG_PAD.left - LONG_PAD.right); const toChainage = (px: number): number => ((px - LONG_PAD.left) / plotWidth) * maxChainageM; + const toX = (chainageM: number): number => + LONG_PAD.left + (maxChainageM > 0 ? (chainageM / maxChainageM) * plotWidth : 0); const { fromM, toM } = visibleChainageRange( toChainage, maxChainageM, @@ -112,5 +116,5 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi visibleElevationRange(detail, fromM, toM) ?? undefined, ), ); - return { node, axis, toChainage, maxChainageM, viewFromM: fromM, viewToM: toM }; + return { node, axis, toChainage, toX, maxChainageM, viewFromM: fromM, viewToM: toM }; }