Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -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)
|
||||
@@ -130,18 +154,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 +172,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 +184,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 +193,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 +285,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 +294,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 +306,7 @@ def build_contour_descent(
|
||||
# 그래야 그 자리가 도로·세류선이면 적색으로 잡히고, 아니면 물이 고이는 지점으로 보인다.
|
||||
marks.append(
|
||||
(
|
||||
f"밴드별 하강거리(EDT {len(levels) - 1}회 · 전체격자 폴백 {window_fallbacks}회)",
|
||||
f"밴드별 하강거리(EDT {edt_steps}단 · 창 넓힘 {window_widened}회)",
|
||||
time.perf_counter(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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";
|
||||
@@ -129,18 +131,28 @@ async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T>
|
||||
/** 타입 레지스트리는 서버 배포 중에 바뀌지 않으므로 탭 수명 동안 한 번만 받는다. */
|
||||
let typesCache: Promise<StructureType[]> | null = null;
|
||||
|
||||
export function fetchStructureTypes(): Promise<StructureType[]> {
|
||||
/**
|
||||
* 구조물 타입 목록.
|
||||
*
|
||||
* `includeDisabled` 를 주면 `enabled:false` 타입(B군 종단배수·F군 생태/녹화·G군 일부)도
|
||||
* 함께 준다. 레지스트리 주석(2026-08-17)이 「B05 선택지에서 빼고 **B06 개별 횡단도
|
||||
* 옵션으로 재사용**」이라 적어 둔 그 자리다 — 2026-09-07 사용자 지시 「A군뿐 아니라
|
||||
* 구조물 전체를 넣을 수 있어야 함」으로 B06 이 그 목록을 쓴다. B05 는 종전대로 켜진 것만.
|
||||
*/
|
||||
export function fetchStructureTypes(includeDisabled = false): Promise<StructureType[]> {
|
||||
if (!typesCache) {
|
||||
typesCache = requestJson<StructureTypesResponse>("/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<StructureListResponse> {
|
||||
@@ -174,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<string, unknown> | 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<string, string | number>,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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": "자동(성토 쪽)"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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() === "",
|
||||
|
||||
@@ -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,29 @@ 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);
|
||||
// ↓ 되돌릴 자리(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) {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
// 「구조물 배치」 — 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<void> {
|
||||
stationControls.ford,
|
||||
stationControls.box,
|
||||
);
|
||||
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
|
||||
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
|
||||
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
|
||||
const pipeOptionsContext: PipeOptionsContext = {
|
||||
detail: () => sectionDetail,
|
||||
|
||||
@@ -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<string, number | string>) => 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");
|
||||
@@ -313,11 +323,15 @@ 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),
|
||||
]);
|
||||
section.setTypes(types);
|
||||
markTypes = types;
|
||||
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙).
|
||||
structures = readPendingStructures(projectId) ?? stored.structures;
|
||||
section.setStructures(structures);
|
||||
@@ -345,6 +359,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
)?.design_flow_m3s ?? null,
|
||||
}));
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
// 종단 알약 레인 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합쳐 B05 와 같은 표기로.
|
||||
pushMarks();
|
||||
// 목록이 늦게 도착하면 그 사이에 고른 시설은 강조될 자리가 없었다 — 다시 세운다
|
||||
// (2026-09-04 사용자 보고: B06 좌측 목록만 하이라이트가 안 붙음). 폼에 아직 아무것도
|
||||
// 없으면 세션에 남은 선택(카드형 — 부재키 없는 측점)도 같은 규칙으로 세운다.
|
||||
|
||||
@@ -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<StructureInstance>,
|
||||
types: ReadonlyArray<StructureType>,
|
||||
) => 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<StructureInstance> = [];
|
||||
let markTypes: ReadonlyArray<StructureType> = [];
|
||||
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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user