Merge remote-tracking branch 'origin/sub_laptop_4' into sub_desktop_1
This commit is contained in:
@@ -203,11 +203,15 @@ export async function mountCrossView(
|
||||
view: () => sectionView,
|
||||
});
|
||||
|
||||
let structuresRefreshSeq = 0;
|
||||
/** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */
|
||||
async function refreshDetailForStructures(): Promise<void> {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4)
|
||||
try {
|
||||
sectionDetail = await loadSectionDetail(projectId, currentRouteId);
|
||||
const fresh = await loadSectionDetail(projectId, currentRouteId);
|
||||
if (seq !== structuresRefreshSeq) return;
|
||||
sectionDetail = fresh;
|
||||
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가
|
||||
// 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측).
|
||||
if (sectionDetail) {
|
||||
|
||||
@@ -8,6 +8,7 @@ longitudinal/, 각 횡단은 cross_sections/ 아래 파일로 저장한다. DB
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -33,6 +34,7 @@ from common_util.common_util_drainage_pipes import (
|
||||
PIPE_FACILITY_REVET,
|
||||
PipePoint,
|
||||
load_pipe_points_file,
|
||||
parse_pipe_points,
|
||||
pipe_anchor_clearances,
|
||||
route_signature,
|
||||
)
|
||||
@@ -205,6 +207,56 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P
|
||||
return points
|
||||
|
||||
|
||||
def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None:
|
||||
"""관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖 = 종류 이름 · 물넘이포장 = 없음."""
|
||||
if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT:
|
||||
return None
|
||||
if pipe.facility == PIPE_FACILITY_PIPE:
|
||||
values = pipe.options or {}
|
||||
kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE)
|
||||
diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM))
|
||||
return f"{kind} D{diameter}"
|
||||
definition = types.get(pipe.facility)
|
||||
return definition.name if definition is not None else None
|
||||
|
||||
|
||||
def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any]]) -> None:
|
||||
"""횡단 측점 라벨을 **지금 관 지점 종류**로 맞춘다(54-4).
|
||||
|
||||
라벨은 측점 생성 때 적혀 파일에 남는다 — 저장으로 종류만 바꾸면(배관 → BOX암거) 측점은
|
||||
다시 안 만들어져 카드 제목이 옛 종류로 남는다. 관 지점 라벨(종류 이름 · 「관종 D관경」)인
|
||||
측점만 고치고 다른 구조물 라벨은 건드리지 않는다.
|
||||
"""
|
||||
path = (
|
||||
project_root
|
||||
/ "B04_PreProcess"
|
||||
/ DRAINAGE_CACHE_DIRNAME
|
||||
/ DRAINAGE_EDITS_DIRNAME
|
||||
/ DRAINAGE_PIPE_POINTS_FILENAME
|
||||
)
|
||||
try:
|
||||
points = parse_pipe_points(json.loads(path.read_text(encoding="utf-8")).get("points"))
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return
|
||||
types = structure_type_map()
|
||||
pipe_names = {definition.name for definition in types.values() if definition.managed_by}
|
||||
for section in cross_sections:
|
||||
label = section.get("structure")
|
||||
if not isinstance(label, str) or not (
|
||||
label in pipe_names or re.fullmatch(r"\S+ D\d+", label)
|
||||
):
|
||||
continue
|
||||
chainage = float(section.get("chainage_m") or 0.0)
|
||||
pipe = next((p for p in points if abs(p.chainage_m - chainage) < 0.02), None)
|
||||
if pipe is None:
|
||||
continue
|
||||
fresh = pipe_station_label(pipe, types)
|
||||
if fresh is None:
|
||||
section.pop("structure", None)
|
||||
else:
|
||||
section["structure"] = fresh
|
||||
|
||||
|
||||
def resolve_extra_stations(
|
||||
project_root: Path, pipes: list[PipePoint]
|
||||
) -> tuple[tuple[float, str], ...]:
|
||||
@@ -224,18 +276,9 @@ def resolve_extra_stations(
|
||||
types = structure_type_map()
|
||||
derived: list[tuple[float, str]] = []
|
||||
for pipe in pipes:
|
||||
if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT:
|
||||
label = pipe_station_label(pipe, types)
|
||||
if label is None:
|
||||
continue
|
||||
if pipe.facility == PIPE_FACILITY_PIPE:
|
||||
values = pipe.options or {}
|
||||
kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE)
|
||||
diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM))
|
||||
label = f"{kind} D{diameter}"
|
||||
else:
|
||||
definition = types.get(pipe.facility)
|
||||
if definition is None:
|
||||
continue
|
||||
label = definition.name
|
||||
# 독립 기슭막이는 구간(시작~종료)이라 **시작·기준·종료** 세 측점을 심는다 —
|
||||
# 길수록 횡단도가 여러 장 나온다(구 D군 구간형과 같은 규칙). 나머지 관 시설은
|
||||
# 기준 한 곳(관이 노선을 가로지르는 점형).
|
||||
|
||||
@@ -50,6 +50,8 @@ export function bindStructuresEvents(ctx: StructuresEventContext): void {
|
||||
// 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시:
|
||||
// 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다).
|
||||
ctx.syncFacilityForm();
|
||||
// 고른 관 지점의 시설 종류를 바꿈(54-4) — 실시간 반영으로 목록 · 알약 · 횡단도가 바로 따라옴
|
||||
if (ctx.selectedPipeChainage() !== null && ctx.currentType()?.managed_by) ctx.liveCommit();
|
||||
if (ctx.editingId()) {
|
||||
ctx.setEditingId(null);
|
||||
ctx.syncButtons();
|
||||
|
||||
@@ -16,6 +16,7 @@ from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_gra
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
prune_stale_cross_files,
|
||||
relabel_pipe_stations,
|
||||
run_section_generation,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
@@ -269,6 +270,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
|
||||
# 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다.
|
||||
# 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points).
|
||||
attach_culvert_sets(root, cross_sections)
|
||||
relabel_pipe_stations(root, cross_sections) # 저장으로 종류만 바꾼 관 — 카드 제목(54-4)
|
||||
# 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 —
|
||||
# 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정).
|
||||
attach_wall_structures(root, cross_sections)
|
||||
|
||||
@@ -22,6 +22,13 @@ import {
|
||||
fetchStructureTypes,
|
||||
readPendingStructures,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import {
|
||||
attachCulvertSets,
|
||||
buildCulvertSets,
|
||||
registryDefaults,
|
||||
} from "@util/common_util_culvert_sets";
|
||||
import {
|
||||
attachWallSpecs,
|
||||
wallSpecsFrom,
|
||||
@@ -55,6 +62,7 @@ async function withDraftWalls(
|
||||
// ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이
|
||||
// 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도
|
||||
// 횡단도에 아무것도 안 보이던 결함의 원인이다(창 둘에서 같은 증상, 실측 확인).
|
||||
await withDraftPipeKinds(detail, projectId);
|
||||
if (!drafts || !drafts.length) return withStructureAreas(detail);
|
||||
const types = await fetchStructureTypes().catch(() => []);
|
||||
const names = new Map(
|
||||
@@ -77,6 +85,66 @@ async function withDraftWalls(
|
||||
return withStructureAreas(detail);
|
||||
}
|
||||
|
||||
/** 관 지점 시설 → 횡단 자리 키(파이썬 `_SECTION_KEYS` · 배관 · 독립 기슭막이 = culvert). */
|
||||
const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const;
|
||||
const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] =>
|
||||
facility === "ford_bridge"
|
||||
? "ford"
|
||||
: facility === "box_culvert"
|
||||
? "box"
|
||||
: facility === "ford_pavement"
|
||||
? "ford_pavement"
|
||||
: "culvert";
|
||||
|
||||
/**
|
||||
* 저장 전에 **종류를 바꾼 관**(배관 ↔ BOX암거 ↔ 세월교 ↔ 물넘이포장 · 54-4)을 상세에 얹는다.
|
||||
* 서버는 저장분 종류로만 얹어 준다 — 초안 종류와 다른 관만 옛 자리를 걷고 짝 산식
|
||||
* (`common_util_culvert_sets`)으로 새 종류 제원을 얹는다. 옛 종류 칸 값은 섞지 않는다.
|
||||
*/
|
||||
async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: string): Promise<void> {
|
||||
const pipes = readPendingPipes(projectId);
|
||||
if (!pipes?.length) return;
|
||||
const sections = detail.cross_sections as unknown as Array<Record<string, unknown>>;
|
||||
const near = (spec: Record<string, unknown>, section: Record<string, unknown>, at: number) =>
|
||||
Math.abs(Number(spec.chainage_m ?? section.chainage_m) - at) < PIPE_MATCH_M;
|
||||
/** 그 관 자리에 얹힌 시설 키가 이 종류와 맞는가 — 얹힌 것이 없으면(새 관 · 옮긴 관) 대상 밖. */
|
||||
const fits = (pipe: (typeof pipes)[number], key: string, spec: Record<string, unknown>) =>
|
||||
key === facilityKey(pipe.facility ?? "pipe") &&
|
||||
(key !== "culvert" || (spec.hidden_pipe === true) === (pipe.facility === "revetment"));
|
||||
const stale = pipes.filter((pipe) => {
|
||||
const here = sections.flatMap((section) =>
|
||||
FACILITY_KEYS.flatMap((key) => {
|
||||
const spec = section[key] as Record<string, unknown> | undefined;
|
||||
return spec && near(spec, section, pipe.chainage_m) ? [[key, spec] as const] : [];
|
||||
}),
|
||||
);
|
||||
return here.length > 0 && !here.some(([key, spec]) => fits(pipe, key, spec));
|
||||
});
|
||||
if (!stale.length) return;
|
||||
const types = await fetchStructureTypes().catch(() => []);
|
||||
const registry = registryDefaults(types);
|
||||
for (const section of sections) {
|
||||
for (const key of FACILITY_KEYS) {
|
||||
const spec = section[key] as Record<string, unknown> | undefined;
|
||||
if (spec && stale.some((pipe) => near(spec, section, pipe.chainage_m))) delete section[key];
|
||||
}
|
||||
// 카드 제목 구조물 이름 — 서버 `resolve_extra_stations` 라벨 규칙(배관 = 관종 D관경 · 물넘이 = 없음)
|
||||
// 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점(물넘이로 지웠다 되돌린 때)
|
||||
const pipe = stale.find((entry) => {
|
||||
const gap = Math.abs(entry.chainage_m - Number(section.chainage_m));
|
||||
return section.structure ? gap < PIPE_MATCH_M : gap < 0.01;
|
||||
});
|
||||
if (!pipe) continue;
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
const values = { ...registry.pipe, ...pipe.options };
|
||||
if (facility === "ford_pavement") delete section.structure;
|
||||
else if (facility === "pipe")
|
||||
section.structure = `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`;
|
||||
else section.structure = types.find((type) => type.type_id === facility)?.name ?? facility;
|
||||
}
|
||||
attachCulvertSets(sections, buildCulvertSets(stale, registry));
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다.
|
||||
*
|
||||
|
||||
@@ -262,18 +262,27 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
}
|
||||
// 값은 관 목록 초안(가게 pipes) 하나로 나간다 — 조정 예약(culvertopt)에 따로 담으면 비운 칸이
|
||||
// [저장] 때 옛 값으로 되살아난다. 옮기기 전 자리로 찾는다(저장 시점 목록 기준).
|
||||
deps.applyPipeOptions?.(fromChainageM, patch);
|
||||
const hit = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
const was = hit?.facility ?? "pipe";
|
||||
const facility = attributes.facility ?? was;
|
||||
// 종류를 바꿈(54-4) — 옛 종류 그림 캐시에 새 종류 칸을 얹지 않음 · 옛 칸은 안 이음
|
||||
if (facility === was) deps.applyPipeOptions?.(fromChainageM, patch);
|
||||
if (hit) {
|
||||
const facility = hit.facility ?? "pipe";
|
||||
hit.options =
|
||||
mergeFacilityOptions({ facility, options: hit.options }, { facility, options: patch })
|
||||
.options ?? {};
|
||||
mergeFacilityOptions(
|
||||
{ facility: was, options: hit.options },
|
||||
{ facility, options: patch },
|
||||
).options ?? {};
|
||||
hit.facility = facility;
|
||||
}
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
if (facility !== was) {
|
||||
pushMarks();
|
||||
deps.onStructuresChanged?.(); // 횡단 상세에 새 종류를 얹어 다시 그림(`withDraftPipeKinds`)
|
||||
}
|
||||
},
|
||||
onPipeRemove: (chainageM) => removePipeAt(chainageM),
|
||||
onPipeSelect: (chainageM) => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""54-4 — 저장으로 관 지점 종류만 바꾸면 횡단 측점 라벨(카드 제목)이 새 종류를 따라감.
|
||||
|
||||
측점은 다시 안 만들어지므로 라벨은 파일에 옛 종류로 남음 — 상세 조회가 지금 관 지점으로 고침.
|
||||
다른 구조물 라벨(옹벽 등)은 건드리지 않음.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections import relabel_pipe_stations, resolve_extra_stations
|
||||
from common_util.common_util_drainage_pipes import parse_pipe_points
|
||||
|
||||
|
||||
def _write_points(root, points):
|
||||
edits = root / "B04_PreProcess" / "drainage" / "edits"
|
||||
edits.mkdir(parents=True, exist_ok=True)
|
||||
(edits / "pipe_points.json").write_text(json.dumps({"points": points}), encoding="utf-8")
|
||||
|
||||
|
||||
def _sections():
|
||||
return [
|
||||
{"chainage_m": 80.0},
|
||||
{"chainage_m": 85.05, "structure": "파형강관 D900"},
|
||||
{"chainage_m": 120.0, "structure": "옹벽"},
|
||||
{"chainage_m": 173.09, "structure": "세월교"},
|
||||
{"chainage_m": 258.12, "structure": "파형강관 D800"},
|
||||
]
|
||||
|
||||
|
||||
def test_relabel_follows_saved_kind(tmp_path):
|
||||
_write_points(
|
||||
tmp_path,
|
||||
[
|
||||
{"chainage_m": 85.05, "source": "spacing", "facility": "box_culvert"},
|
||||
{"chainage_m": 120.0, "source": "user", "facility": "box_culvert"}, # 옹벽 라벨 자리
|
||||
{
|
||||
"chainage_m": 173.09,
|
||||
"source": "stream",
|
||||
"facility": "pipe",
|
||||
"options": {"pipe_kind": "흄관", "pipe_diameter_mm": 1200},
|
||||
},
|
||||
{"chainage_m": 258.12, "source": "spacing", "facility": "ford_pavement"},
|
||||
],
|
||||
)
|
||||
sections = _sections()
|
||||
relabel_pipe_stations(tmp_path, sections)
|
||||
labels = {s["chainage_m"]: s.get("structure") for s in sections}
|
||||
assert labels == {
|
||||
80.0: None,
|
||||
85.05: "BOX암거",
|
||||
120.0: "옹벽", # 관 지점 라벨이 아닌 측점은 그대로
|
||||
173.09: "흄관 D1200",
|
||||
258.12: None, # 물넘이포장 = 라벨 없음(측점 생성 규칙과 같음)
|
||||
}
|
||||
|
||||
|
||||
def test_relabel_matches_generation_rule(tmp_path):
|
||||
points = [
|
||||
{"chainage_m": 85.05, "source": "spacing", "options": {"pipe_diameter_mm": 1000}},
|
||||
{"chainage_m": 173.09, "source": "stream", "facility": "ford_bridge"},
|
||||
]
|
||||
_write_points(tmp_path, points)
|
||||
sections = [
|
||||
{"chainage_m": 85.05, "structure": "세월교"},
|
||||
{"chainage_m": 173.09, "structure": "BOX암거"},
|
||||
]
|
||||
relabel_pipe_stations(tmp_path, sections)
|
||||
generated = dict(resolve_extra_stations(tmp_path, parse_pipe_points(points)))
|
||||
assert [s["structure"] for s in sections] == [generated[85.05], generated[173.09]]
|
||||
|
||||
|
||||
def test_relabel_without_file_keeps_labels(tmp_path):
|
||||
sections = _sections()
|
||||
relabel_pipe_stations(tmp_path, sections)
|
||||
assert sections == _sections()
|
||||
@@ -0,0 +1,211 @@
|
||||
/* 54-4 — 좌측 패널에서 같은 자리 관 지점 시설 종류를 바꿀 때 B06 횡단도 · 종단 알약 · 목록이 바로 새 종류
|
||||
* 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub4_검증) · 로그인 · 목록에 「4+5 배관 1」(85.05 m)
|
||||
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m544 = "단계";`
|
||||
* setup → 처음 관 지점 정본(서버)을 세션 `__544_orig` 에 남김 · 4+5 관 고름 · 종단 도크 폄
|
||||
* cycle → 저장 전 네 종류 서로 바꾸기 12길(배관 → BOX → 세월교 → 물넘이 → 배관 → 세월교 → BOX →
|
||||
* 물넘이 → 세월교 → 배관 → 물넘이 → BOX → 배관) — 길마다 횡단 카드 모양 · 제목 · 목록 · 알약 · 초안 칸
|
||||
* save → BOX암거로 바꾸고 [저장] → 저장 뒤 카드 · 목록 · 알약 = BOX
|
||||
* reopen → (orca reload 뒤) 저장분이 BOX 로 다시 섬 · BOX → 배관 바꾸기도 바로 따라옴(저장 뒤 길)
|
||||
* restore → 처음 정본을 관 초안에 되얹고 [저장] → 서버 관 지점 = 처음과 같음
|
||||
* 긴 단계는 `void` 로 띄우고 `sessionStorage.getItem("__544_out_{단계}")` 로 읽음
|
||||
* 쓰는 것: 검증 프로젝트 관 지점 정본(save · restore) · sessionStorage
|
||||
* 기대 수치: 모든 단계 fails 0 · restore same true
|
||||
*/
|
||||
(async () => {
|
||||
const MODE = window.__m544 || "cycle";
|
||||
const raf = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16);
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const AT = 85.05;
|
||||
const pid = localStorage.getItem("frd_current_project_id");
|
||||
const api = async (u) => (await fetch(u, { credentials: "include" })).json();
|
||||
const txt = (el) => el.innerText.replace(/\s+/g, " ").trim();
|
||||
const NAME = {
|
||||
pipe: "배관",
|
||||
box_culvert: "BOX암거",
|
||||
ford_bridge: "세월교",
|
||||
ford_pavement: "물넘이포장",
|
||||
};
|
||||
const CHIP = {
|
||||
pipe: "배수관",
|
||||
box_culvert: "BOX암거",
|
||||
ford_bridge: "세월교",
|
||||
ford_pavement: null,
|
||||
};
|
||||
/** 그 종류에만 있는 칸 — 다른 종류로 바꾼 뒤 초안에 남으면 옛 칸이 섞인 것. */
|
||||
const OWN = {
|
||||
pipe: ["inlet_revet_form", "outlet_revet_form", "inlet_basin_length_m"], // 세월교도 inlet_type 을 씀(날개벽)
|
||||
box_culvert: ["body_width_m", "body_height_m"],
|
||||
ford_bridge: ["pipe_count"],
|
||||
ford_pavement: [],
|
||||
};
|
||||
const typeSel = () =>
|
||||
document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1];
|
||||
const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
|
||||
const row = () => rows().find((li) => /^4\+5 (배관|BOX암거|세월교|물넘이포장)/.test(txt(li)));
|
||||
const card = () =>
|
||||
[...document.querySelectorAll(".b06-cross-card")]
|
||||
.map((c) => ({ c, at: Number(txt(c).split(" ")[1]?.replace("m", "")) }))
|
||||
.sort((a, b) => Math.abs(a.at - AT) - Math.abs(b.at - AT))[0]?.c;
|
||||
const pill = () =>
|
||||
[...document.querySelectorAll(".b05-structure__mark")].find(
|
||||
(m) => m.dataset.structureId === `pipe-${AT.toFixed(2)}`,
|
||||
);
|
||||
const draft = () => {
|
||||
const raw = sessionStorage.getItem(`aislo:draft:pipes:${pid}`);
|
||||
const v = raw ? JSON.parse(raw) : null;
|
||||
return (v?.value ?? v ?? []).find((p) => Math.abs(p.chainage_m - AT) < 0.51) ?? null;
|
||||
};
|
||||
const openDock = async () => {
|
||||
if (pill()) return;
|
||||
const handle = document.querySelector(".ui-workflow-overlay__handle--bottom");
|
||||
if (handle?.getAttribute("aria-expanded") !== "true") handle?.click();
|
||||
for (let k = 0; k < 20 && !pill(); k++) await w(300);
|
||||
};
|
||||
const pick = async () => {
|
||||
for (let k = 0; k < 40 && !row(); k++) await w(500);
|
||||
if (!row()?.classList.contains("is-selected")) {
|
||||
row()?.click();
|
||||
await w(2500);
|
||||
}
|
||||
};
|
||||
const until = async (fn, ms = 8000) => {
|
||||
for (let t = 0; t < ms; t += 200) {
|
||||
if (fn()) return true;
|
||||
await w(200);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
/** 횡단 카드 그림이 그 종류 모양인가 — 클래스 · 툴팁(svg title) 첫 낱말로 가름. */
|
||||
const shapeOf = (c) => {
|
||||
const svg = c.querySelector("svg");
|
||||
const cls = [...svg.querySelectorAll("[class]")].map((x) => x.getAttribute("class"));
|
||||
const tips = [...svg.querySelectorAll("title")].map((x) => x.textContent);
|
||||
if (tips.some((t) => t.startsWith("BOX암거"))) return "box_culvert";
|
||||
if (cls.some((k) => k.includes("ford-slab"))) return "ford_bridge";
|
||||
if (cls.some((k) => k.includes("culvert-revet") || k.includes("culvert-pipe"))) return "pipe";
|
||||
return "none";
|
||||
};
|
||||
const check = (to, label) => {
|
||||
const c = card();
|
||||
const got = {
|
||||
shape: c ? shapeOf(c) : "카드 없음",
|
||||
chip: c ? [...c.querySelectorAll(".b06-cross-card__structure")].map(txt) : [],
|
||||
notes: c ? [...c.querySelectorAll(".b06-cross-card__warning")].map(txt) : [],
|
||||
row: row() ? txt(row()) : null,
|
||||
pill: pill() ? `${txt(pill())}|${pill().title}` : null,
|
||||
type: typeSel().value,
|
||||
facility: draft()?.facility,
|
||||
keys: Object.keys(draft()?.options ?? {}),
|
||||
};
|
||||
const want = to === "ford_pavement" ? "none" : to;
|
||||
if (got.shape !== want) fail(`${label}: 모양 ${got.shape} ≠ ${want}`);
|
||||
if (CHIP[to] ? got.chip.join() !== CHIP[to] : got.chip.length)
|
||||
fail(`${label}: 제목 ${got.chip}`);
|
||||
if (to === "ford_pavement" && !got.notes.some((n) => n.includes("물넘이포장")))
|
||||
fail(`${label}: 물넘이 알림 없음 ${got.notes}`);
|
||||
if (got.notes.some((n) => Object.values(NAME).some((v) => v !== NAME[to] && n.includes(v))))
|
||||
fail(`${label}: 옛 종류 알림 ${got.notes}`);
|
||||
if (!got.row?.startsWith(`4+5 ${NAME[to]}`)) fail(`${label}: 목록 ${got.row}`);
|
||||
if (!got.pill || !got.pill.includes(NAME[to])) fail(`${label}: 알약 ${got.pill}`);
|
||||
if (got.type !== to) fail(`${label}: 폼 종류 ${got.type}`);
|
||||
if (!["setup", "save", "reopen"].includes(MODE) && got.facility !== to)
|
||||
fail(`${label}: 초안 종류 ${got.facility}`);
|
||||
for (const [kind, keys] of Object.entries(OWN))
|
||||
if (kind !== to && keys.some((k) => got.keys.includes(k)))
|
||||
fail(`${label}: 옛 ${kind} 칸 섞임 ${got.keys.filter((k) => keys.includes(k))}`);
|
||||
return got;
|
||||
};
|
||||
const change = async (to) => {
|
||||
const t = typeSel();
|
||||
t.value = to;
|
||||
t.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await until(() => card() && shapeOf(card()) === (to === "ford_pavement" ? "none" : to));
|
||||
await w(600);
|
||||
};
|
||||
const save = async () => {
|
||||
[...document.querySelectorAll("button")].find((b) => b.innerText.trim() === "저장").click();
|
||||
await until(() => document.querySelector(".ui-toast, [class*='toast']"), 60000);
|
||||
await w(4000);
|
||||
return document.querySelector(".ui-toast, [class*='toast']")?.innerText ?? null;
|
||||
};
|
||||
const out = { mode: MODE, fails: [] };
|
||||
const fail = (what) => out.fails.push(what);
|
||||
|
||||
if (MODE === "setup") {
|
||||
const pipes = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points;
|
||||
sessionStorage.setItem("__544_orig", JSON.stringify(pipes));
|
||||
out.orig = pipes.find((p) => Math.abs(p.chainage_m - AT) < 0.51);
|
||||
await pick();
|
||||
await openDock();
|
||||
out.start = check(typeSel().value, "처음");
|
||||
} else if (MODE === "cycle") {
|
||||
await pick();
|
||||
await openDock();
|
||||
const path = [
|
||||
"pipe",
|
||||
"box_culvert",
|
||||
"ford_bridge",
|
||||
"ford_pavement",
|
||||
"pipe",
|
||||
"ford_bridge",
|
||||
"box_culvert",
|
||||
"ford_pavement",
|
||||
"ford_bridge",
|
||||
"pipe",
|
||||
"ford_pavement",
|
||||
"box_culvert",
|
||||
"pipe",
|
||||
];
|
||||
if (typeSel().value !== path[0]) fail(`처음 종류 ${typeSel().value}`);
|
||||
out.steps = [];
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const label = `${i} ${path[i - 1]}→${path[i]}`;
|
||||
await change(path[i]);
|
||||
const got = check(path[i], label);
|
||||
out.steps.push(
|
||||
`${label} ${got.shape} ${got.chip.join("/") || "-"} | ${got.row} | ${got.pill}`,
|
||||
);
|
||||
}
|
||||
out.ways = new Set(path.slice(1).map((to, i) => `${path[i]}→${to}`)).size;
|
||||
} else if (MODE === "save") {
|
||||
await pick();
|
||||
await openDock();
|
||||
await change("box_culvert");
|
||||
out.toast = await save();
|
||||
await openDock();
|
||||
out.after = check("box_culvert", "저장 뒤");
|
||||
const saved = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points;
|
||||
out.server = saved.find((p) => Math.abs(p.chainage_m - AT) < 0.51)?.facility;
|
||||
if (out.server !== "box_culvert") fail(`서버 종류 ${out.server}`);
|
||||
} else if (MODE === "reopen") {
|
||||
await pick();
|
||||
await openDock();
|
||||
out.reopened = check("box_culvert", "다시 연 BOX");
|
||||
await change("pipe");
|
||||
out.toPipe = check("pipe", "저장 뒤 BOX→배관");
|
||||
} else if (MODE === "restore") {
|
||||
const orig = JSON.parse(sessionStorage.getItem("__544_orig") ?? "null");
|
||||
if (!orig) fail("처음 정본 없음 — setup 먼저");
|
||||
else {
|
||||
const points = orig.map(({ lonlat, ...rest }) => rest); // 좌표는 서버가 다시 계산(DetailPipeInput)
|
||||
sessionStorage.setItem(`aislo:draft:pipes:${pid}`, JSON.stringify(points));
|
||||
location.reload();
|
||||
return "reload — 다시 부르면 저장"; // 새로 고친 뒤 `__m544 = "restore2"`
|
||||
}
|
||||
} else if (MODE === "restore2") {
|
||||
await pick();
|
||||
out.toast = await save();
|
||||
const orig = JSON.parse(sessionStorage.getItem("__544_orig"));
|
||||
const now = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points;
|
||||
const pick2 = (list) =>
|
||||
list.map((p) => [p.chainage_m, p.facility ?? "pipe", JSON.stringify(p.options ?? {})]);
|
||||
out.same = JSON.stringify(pick2(now)) === JSON.stringify(pick2(orig));
|
||||
if (!out.same) fail("되돌린 정본이 처음과 다름");
|
||||
out.at = now.find((p) => Math.abs(p.chainage_m - AT) < 0.51);
|
||||
}
|
||||
window.requestAnimationFrame = raf;
|
||||
out.failCount = out.fails.length;
|
||||
sessionStorage.setItem(`__544_out_${MODE}`, JSON.stringify(out));
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
Reference in New Issue
Block a user