refactor(B05·B06): 측점 · 구조물 고름을 selection 한 곳으로 · 관 찾는 거리 PIPE_MATCH_M 한 값 · 횡단 카드 누름이 옹벽 · 세월교 · BOX · 기슭막이 폼을 엶 (24-2d)
- 목록 · 종단 알약 · 3D · 횡단 카드 · 벽 어디서 골라도 selectDesign 한 길로 세션에 적음(구조물 id 포함) — B05 목록 구조물 고름도 B06 으로 이어짐 - 폼이 열 항목 풀이 resolveSelection 한 곳(관 먼저 · 구조물 다음 · 가장 가까운 것) — 공용 폼의 0.05 없앰 - B06 벽 · 구체 누름은 showAtChainage 하나를 탐(옛 관 전용 showPipeAt 없앰) · 벽 누름 중 폼이 카드를 먼저 고르는 이중 토글 막음 - B06 진입 때 구조물 id 로 카드를 찾고, 못 찾으면 다시 부르게 함 · B05 진입 때 구조물 id 가 있을 때만 구조물로 연다 - 시험 test_b05_b06_selection_single 21 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
This commit is contained in:
@@ -28,9 +28,28 @@ export function isNear(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) < PIPE_MATCH_M;
|
||||
}
|
||||
|
||||
/** 그 누가거리에서 찾는 거리 안에 있는 항목 중 **가장 가까운 것** — 이웃한 항목이 둘 걸려도
|
||||
* 자리가 맞는 쪽을 고른다. */
|
||||
export function nearestWithin<T>(
|
||||
items: ReadonlyArray<T>,
|
||||
chainageM: number,
|
||||
chainageOf: (item: T) => number,
|
||||
): T | undefined {
|
||||
let best: T | undefined;
|
||||
let bestGap = PIPE_MATCH_M;
|
||||
for (const item of items) {
|
||||
const gap = Math.abs(chainageOf(item) - chainageM);
|
||||
if (gap < bestGap) {
|
||||
best = item;
|
||||
bestGap = gap;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 선택을 풀 때 필요한 구조물의 최소 모양 — B05 `StructureInstance` 가 그대로 맞는다. */
|
||||
export interface SelectableStructure {
|
||||
structure_id: string;
|
||||
structure_id?: string | null;
|
||||
chainage_m?: number | null;
|
||||
start_m?: number | null;
|
||||
end_m?: number | null;
|
||||
@@ -66,11 +85,11 @@ export function resolveSelection(
|
||||
): ResolvedSelection {
|
||||
if (selection.structureId) {
|
||||
const byId = structures.find((entry) => entry.structure_id === selection.structureId);
|
||||
if (byId) return { pipeChainageM: null, structureId: byId.structure_id };
|
||||
if (byId) return { pipeChainageM: null, structureId: byId.structure_id ?? null };
|
||||
}
|
||||
const chainageM = selection.chainageM;
|
||||
if (chainageM === null) return { pipeChainageM: null, structureId: null };
|
||||
const pipe = pipes.find((entry) => isNear(entry.chainage_m, chainageM));
|
||||
const pipe = nearestWithin(pipes, chainageM, (entry) => entry.chainage_m);
|
||||
if (pipe) return { pipeChainageM: pipe.chainage_m, structureId: null };
|
||||
const hit = structures.find((entry) => coversChainage(entry, chainageM));
|
||||
return { pipeChainageM: null, structureId: hit?.structure_id ?? null };
|
||||
|
||||
@@ -28,11 +28,9 @@ import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { openRouteEditModal } from "./B05_Profile_UI_RouteEdit";
|
||||
import { createSelectionSync } from "./B05_Profile_UI_Selection";
|
||||
import {
|
||||
restoreStructurePick,
|
||||
wireStructurePick,
|
||||
writeStructurePick,
|
||||
} from "./B05_Profile_UI_Structure_Pick_Session";
|
||||
import { restoreStructurePick, wireStructurePick } from "./B05_Profile_UI_Structure_Pick_Session";
|
||||
import { NO_SELECTION, nearestWithin, selectDesign } from "../A00_Common/design/design_selection";
|
||||
import { structureAnchorM } from "./B05_Profile_Api_Structures";
|
||||
import { createStructuresBridge } from "./B05_Profile_UI_Page_Structures";
|
||||
import { createRouteViewer } from "./B05_Profile_UI_Viewer";
|
||||
import { irregularStationId, isPipeStation } from "./B05_Profile_UI_IrregularStations";
|
||||
@@ -98,6 +96,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
대피로: "refuge",
|
||||
기타: "etc",
|
||||
};
|
||||
/** 고른 것을 selection 한 곳에 적는다 — B06 으로 이어지는 값이다(chainageM null = 해제). */
|
||||
const rememberPick = (chainageM: number | null, structureId: string | null): void =>
|
||||
selectDesign(activeProjectId, chainageM === null ? NO_SELECTION : { chainageM, structureId }, {
|
||||
from: "b05",
|
||||
});
|
||||
const profilePanel = createRouteProfilePanel(
|
||||
activeProjectId,
|
||||
(stationId) => {
|
||||
@@ -144,8 +147,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
onStructureSelect: (structureId) => {
|
||||
// 고른 자리를 B06 으로 넘긴다 — 사이드 목록·3D 픽에는 있던 기록이 **알약에만
|
||||
// 빠져 있어** 종단에서 고른 구조물이 B06 횡단도에서 안 잡혔다(2026-09-06).
|
||||
writeStructurePick(activeProjectId, bridge.markChainage(structureId));
|
||||
const chainage = bridge.pipeMarkChainage(structureId);
|
||||
rememberPick(bridge.markChainage(structureId), chainage === null ? structureId : null);
|
||||
if (chainage !== null) {
|
||||
panel.structures.selectPipeByChainage(chainage);
|
||||
selectStationOfPipe(chainage);
|
||||
@@ -342,7 +345,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
profilePanel.drainage.updatePipeFacility(from, to, attributes),
|
||||
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
|
||||
onPipeFacilitySelect: (chainage) => {
|
||||
writeStructurePick(activeProjectId, chainage); // 목록 선택도 B06으로 이어진다.
|
||||
rememberPick(chainage, null); // 목록 선택도 B06으로 이어진다.
|
||||
if (selectionSyncing) return;
|
||||
selectionSyncing = true;
|
||||
try {
|
||||
@@ -350,9 +353,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
const station =
|
||||
chainage === null
|
||||
? undefined
|
||||
: bridge
|
||||
.irregularStations()
|
||||
.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.05);
|
||||
: nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m);
|
||||
const id = station ? irregularStationId(station.id) : null;
|
||||
viewer.markers.selectStation(id);
|
||||
profilePanel.setSelectedStation(id);
|
||||
@@ -367,6 +368,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
// 3D·종단·유역도에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자).
|
||||
onStructureReveal: () => layout.setOptionsOpen(true),
|
||||
onStructureSelect: (structure) => {
|
||||
// 목록에서 고른 구조물도 B06 으로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을
|
||||
// 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문).
|
||||
if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null);
|
||||
if (selectionSyncing) return;
|
||||
selectionSyncing = true;
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* 구조물(`origin: "pipe"`)일 때만 붙는다 — 다른 구조물에는 대응하는 유역이 없다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import {
|
||||
irregularStationId,
|
||||
isPipeStation,
|
||||
@@ -48,9 +49,6 @@ export interface SelectionSync {
|
||||
selectStationOfPipe: (chainageM: number | null) => void;
|
||||
}
|
||||
|
||||
/** 같은 누가거리로 볼 여유(m). 관 지점과 구조물 측점은 소수점 둘째 자리까지 같은 값을 쓴다. */
|
||||
const SAME_CHAINAGE_M = 0.51;
|
||||
|
||||
export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync {
|
||||
const prefix = irregularStationId("");
|
||||
|
||||
@@ -94,7 +92,7 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync {
|
||||
.stations()
|
||||
.find(
|
||||
(entry) =>
|
||||
isPipeStation(entry) && Math.abs(entry.chainage_m - chainageM) < SAME_CHAINAGE_M,
|
||||
isPipeStation(entry) && Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
const id = matched ? irregularStationId(matched.id) : null;
|
||||
ports.setSyncing(true);
|
||||
|
||||
@@ -9,62 +9,35 @@
|
||||
* 적혀, 어느 쪽으로 오가든 마지막 선택이 그대로 살아 있다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
NO_SELECTION,
|
||||
readSelection,
|
||||
selectDesign,
|
||||
type DesignSelection,
|
||||
} from "../A00_Common/design/design_selection";
|
||||
import type { StructurePick, StructurePickControls } from "./B05_Profile_UI_Viewer_Structure_Pick";
|
||||
import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types";
|
||||
import type { StructureInstance } from "./B05_Profile_Api_Structures";
|
||||
|
||||
/** B06으로 넘기는 선택 — B06이 쓰는 `{ 측점, 부재키 }` 한 쌍과 같은 모양이다. */
|
||||
/** B06으로 넘기는 선택 — B06이 쓰는 `{ 측점, 부재키 }` 한 쌍에 구조물 id 가 붙는다. */
|
||||
export interface StructurePickHandoff {
|
||||
at: number;
|
||||
key?: string;
|
||||
structureId?: string;
|
||||
}
|
||||
|
||||
/* 넘김값도 ② 설계 초안이다 — 별도 임시 키를 두지 않고 등록표(`b_page_state`)를 쓴다
|
||||
(2026-09-06 캐시·세션 일원화). */
|
||||
|
||||
/** 세션에 남긴다(선택 해제면 지운다). */
|
||||
export function rememberStructurePick(projectId: string | null, pick: StructurePick | null): void {
|
||||
if (!projectId) return;
|
||||
if (!pick) return writeState("structure-pick", null, projectId);
|
||||
const handoff: StructurePickHandoff = { at: pick.chainageM, key: pick.key };
|
||||
writeState("structure-pick", handoff, projectId);
|
||||
}
|
||||
|
||||
/** 화면에서 고른 것을 세션에 적는다 — B06 쪽 창구(측점만 고르면 부재키는 비운다). */
|
||||
export function writeStructurePick(
|
||||
projectId: string | null,
|
||||
at: number | null,
|
||||
key?: string,
|
||||
): void {
|
||||
if (!projectId) return;
|
||||
if (at === null) return writeState("structure-pick", null, projectId);
|
||||
// 부재키 없이 **측점만** 오는 길(사이드 목록·선택 동기화)이 3D 로 고른 부재키를 지웠다
|
||||
// (2026-09-05 진단). 같은 측점이면 이미 적힌 부재키를 지킨다 — 3D 로 고른 직후 선택
|
||||
// 동기화가 이 길을 타도 조정창이 그대로 열린다.
|
||||
const kept = key ?? readStructurePick(projectId)?.key;
|
||||
writeState("structure-pick", { at, key: kept }, projectId);
|
||||
}
|
||||
/* 넘김값은 `design_selection` 의 selection 한 곳이다 — 3D · 알약 · 목록 · 횡단 카드 어디서
|
||||
골라도 그 함수 하나로 적고(`selectDesign`), 여기서는 읽기만 한다. 값은 ② 설계 초안이라
|
||||
등록표(`b_page_state`)를 쓴다(2026-09-06 캐시·세션 일원화). */
|
||||
|
||||
/** 세션에 남은 선택을 읽는다(지우지 않는다). 없으면 null. */
|
||||
export function readStructurePick(projectId: string | null): StructurePickHandoff | null {
|
||||
if (!projectId) return null;
|
||||
try {
|
||||
const value = readState<Partial<StructurePickHandoff>>("structure-pick", projectId);
|
||||
if (!value) return null;
|
||||
if (typeof value.at !== "number" || !Number.isFinite(value.at)) return null;
|
||||
return { at: value.at, key: typeof value.key === "string" ? value.key : undefined };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 구간형 구조물이 그 누가거리를 덮는가(기준점형은 ±0.51m 안). */
|
||||
function covers(entry: StructureInstance, chainageM: number): boolean {
|
||||
const start = entry.start_m ?? entry.chainage_m;
|
||||
const end = entry.end_m ?? entry.chainage_m;
|
||||
if (start == null || end == null) return false;
|
||||
return chainageM >= Math.min(start, end) - 0.51 && chainageM <= Math.max(start, end) + 0.51;
|
||||
const value = readSelection(projectId);
|
||||
if (!value || value.chainageM === null) return null;
|
||||
return {
|
||||
at: value.chainageM,
|
||||
key: value.partKey,
|
||||
structureId: value.structureId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +45,7 @@ function covers(entry: StructureInstance, chainageM: number): boolean {
|
||||
*
|
||||
* 배수관 세트(기슭막이·집수정·배관)는 `selectStation`(=기존 선택 동기화)이 좌측 폼·종단
|
||||
* 그래프·3D 마커·유역도를 한 줄로 맞춘다. 그 줄에서 아무것도 안 잡히면 옛 정본 구조물로
|
||||
* 보고 구조물 id로 연다.
|
||||
* 보고 폼이 구조물 id 로 연다 — 관·구조물 찾는 거리와 순서는 `resolveSelection` 한 곳이다.
|
||||
*/
|
||||
export function wireStructurePick(
|
||||
controls: StructurePickControls,
|
||||
@@ -82,15 +55,16 @@ export function wireStructurePick(
|
||||
selectStation: (chainageM: number | null) => void,
|
||||
): void {
|
||||
controls.onPick = (pick: StructurePick | null): void => {
|
||||
rememberStructurePick(projectId, pick);
|
||||
if (!pick) {
|
||||
selectDesign(projectId, NO_SELECTION, { from: "b05" });
|
||||
selectStation(null);
|
||||
return;
|
||||
}
|
||||
const selection: DesignSelection = { chainageM: pick.chainageM, structureId: null };
|
||||
const { structureId } = structures.resolveAt(selection);
|
||||
selectDesign(projectId, { ...selection, structureId }, { partKey: pick.key, from: "b05" });
|
||||
selectStation(pick.chainageM);
|
||||
if (structures.hasSelection()) return;
|
||||
const hit = structures.getStructures().find((entry) => covers(entry, pick.chainageM));
|
||||
structures.selectById(hit?.structure_id ?? null);
|
||||
if (!structures.hasSelection()) structures.selectAt({ ...selection, structureId });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,5 +86,10 @@ export function restoreStructurePick(
|
||||
if (!handoff) return true; // 되살릴 것이 없으면 끝난 것으로 본다.
|
||||
controls.select({ chainageM: handoff.at, kind: "", key: handoff.key });
|
||||
selectStation(handoff.at);
|
||||
// 구조물 id 가 적혀 있을 때만 구조물로 연다 — 관 목록이 아직 안 실렸을 때 측점만으로 구조물을
|
||||
// 짐작하면 그 측점을 덮는 다른 구조물이 열린다(관은 목록이 실릴 때 위 `selectStation` 이 연다).
|
||||
if (!structures.hasSelection() && handoff.structureId) {
|
||||
structures.selectAt({ chainageM: handoff.at, structureId: handoff.structureId });
|
||||
}
|
||||
return structures.hasSelection();
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import { renderStructureList } from "./B05_Profile_UI_Structures_List";
|
||||
import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection";
|
||||
|
||||
/** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 —
|
||||
* 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */
|
||||
@@ -408,7 +409,8 @@ export function createStructuresSection(
|
||||
},
|
||||
onSelectPipe: (pipe) => {
|
||||
const isSame =
|
||||
selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05;
|
||||
selectedPipeChainage !== null &&
|
||||
Math.abs(selectedPipeChainage - pipe.chainage_m) < PIPE_MATCH_M;
|
||||
if (isSame) {
|
||||
resetForm();
|
||||
} else {
|
||||
@@ -540,9 +542,9 @@ export function createStructuresSection(
|
||||
setPipeFacilities(pipes) {
|
||||
pipeFacilities = pipes.map((pipe) => ({ ...pipe }));
|
||||
if (tempPipeChainage !== null) {
|
||||
// 스냅·재계산으로 좌표가 조금 옮겨진 임시 관을 계속 따라간다(0.51m 기준).
|
||||
// 스냅·재계산으로 좌표가 조금 옮겨진 임시 관을 계속 따라간다.
|
||||
const hit = pipeFacilities.find(
|
||||
(pipe) => Math.abs(pipe.chainage_m - tempPipeChainage!) < 0.51,
|
||||
(pipe) => Math.abs(pipe.chainage_m - tempPipeChainage!) < PIPE_MATCH_M,
|
||||
);
|
||||
if (hit) {
|
||||
tempPipeChainage = hit.chainage_m;
|
||||
@@ -559,7 +561,9 @@ export function createStructuresSection(
|
||||
}
|
||||
if (
|
||||
selectedPipeChainage !== null &&
|
||||
!pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < 0.05)
|
||||
!pipeFacilities.some(
|
||||
(pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < PIPE_MATCH_M,
|
||||
)
|
||||
) {
|
||||
// 고르고 있던 관이 사라졌다(삭제·재계산 이동) — 폼도 함께 닫는다.
|
||||
selectedPipeChainage = null;
|
||||
@@ -578,10 +582,19 @@ export function createStructuresSection(
|
||||
}
|
||||
// 그래프·3D·배수유역도에서 고른 관도 목록 강조 + 폼 로드까지 — 어느 경로든
|
||||
// 같은 편집 화면이 열린다(2026-08-17 전역 선택 동기화).
|
||||
const pipe = pipeFacilities.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.05);
|
||||
const pipe = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (!pipe) return;
|
||||
loadPipeForm(pipe);
|
||||
},
|
||||
resolveAt: (selection) => resolveSelection(selection, pipeFacilities, structures),
|
||||
selectAt(selection) {
|
||||
const hit = resolveSelection(selection, pipeFacilities, structures);
|
||||
if (hit.pipeChainageM !== null) this.selectPipeByChainage(hit.pipeChainageM);
|
||||
else this.selectById(hit.structureId);
|
||||
return hit;
|
||||
},
|
||||
selectById(structureId) {
|
||||
if (structureId !== null) callbacks.onReveal?.();
|
||||
if (structureId === null) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection";
|
||||
import type { SummaryItem } from "../common_util/common_util_summary_item";
|
||||
|
||||
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
|
||||
@@ -93,6 +94,11 @@ export interface StructuresSection {
|
||||
hasSelection: () => boolean;
|
||||
/** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */
|
||||
selectById: (structureId: string | null) => void;
|
||||
/** 측점 · 구조물 고름 한 값을 폼이 열 항목으로 푼다 — 열지는 않는다(관 계열이 먼저). */
|
||||
resolveAt: (selection: DesignSelection) => ResolvedSelection;
|
||||
/** 고름 한 값으로 폼을 연다(관 → 구조물 순) — 어느 화면에서 골라도 같은 거리 · 같은 순서다.
|
||||
* 고를 항목이 없으면 폼을 해제한다. 푼 결과를 돌려준다. */
|
||||
selectAt: (selection: DesignSelection) => ResolvedSelection;
|
||||
/** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */
|
||||
addAt: (chainageM: number, typeId: string) => void;
|
||||
/** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */
|
||||
|
||||
@@ -32,10 +32,8 @@ import {
|
||||
type SectionPersistContext,
|
||||
} from "./B06_Section_UI_Page_Persist";
|
||||
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
|
||||
import {
|
||||
readStructurePick,
|
||||
writeStructurePick,
|
||||
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import { NO_SELECTION, selectDesign } from "../A00_Common/design/design_selection";
|
||||
import { applyStructurePick } from "./B06_Section_UI_Page_Structure_Pick";
|
||||
import { createSectionView } from "./B06_Section_UI_Section_View";
|
||||
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
@@ -196,7 +194,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
return inletLeft ? { inlet: "좌", outlet: "우" } : { inlet: "우", outlet: "좌" };
|
||||
},
|
||||
stationInterval: () => stationInterval ?? 20,
|
||||
focusChainage: focusStationAt,
|
||||
// 벽·구체를 누르는 중이면 그 누름이 카드를 고른다 — 폼이 구조물을 열며 카드를 먼저
|
||||
// 고르면 뒤이은 카드 선택이 「같은 측점 재클릭 = 해제」로 먹힌다.
|
||||
focusChainage: (chainageM) => {
|
||||
if (!isWallSelecting()) focusStationAt(chainageM);
|
||||
},
|
||||
queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch),
|
||||
applyPipeOptions: (chainageM, patch) =>
|
||||
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
|
||||
@@ -377,7 +379,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
() => sectionDetail,
|
||||
structuresPanel,
|
||||
// 횡단도에서 고른 부재도 같은 세션 칸에 남긴다(2026-09-04 — 두 화면이 한 페이지처럼).
|
||||
(chainageM, key) => writeStructurePick(projectId, chainageM, key),
|
||||
(chainageM, key) =>
|
||||
selectDesign(projectId, structuresPanel.selectionAt(chainageM), {
|
||||
partKey: key,
|
||||
from: "b06",
|
||||
}),
|
||||
);
|
||||
// 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자).
|
||||
// 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다.
|
||||
@@ -387,13 +393,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
: null;
|
||||
if (!target) {
|
||||
structuresPanel.showAtChainage(null);
|
||||
writeStructurePick(projectId, null); // 카드 해제 — 세션 선택도 비운다.
|
||||
selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 세션 선택도 비운다.
|
||||
return;
|
||||
}
|
||||
const owner = stationControls.structureSpan.ownerOf(target);
|
||||
structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
|
||||
const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
|
||||
// 카드만 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
|
||||
if (!isWallSelecting()) writeStructurePick(projectId, owner?.chainage_m ?? target.chainage_m);
|
||||
if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" });
|
||||
structureSelection.syncInletStructure();
|
||||
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
|
||||
// (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다.
|
||||
@@ -462,16 +468,25 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
function restoreStructurePick(): void {
|
||||
if (!sectionDetail) return;
|
||||
const handoff = readStructurePick(projectId);
|
||||
const signature = handoff ? `${handoff.at}|${handoff.key ?? ""}` : null;
|
||||
const signature = handoff
|
||||
? `${handoff.at}|${handoff.key ?? ""}|${handoff.structureId ?? ""}`
|
||||
: null;
|
||||
if (signature === null || signature === appliedPick) return;
|
||||
appliedPick = signature;
|
||||
applyStructurePick(handoff, sectionDetail, {
|
||||
focusStation: (stationId) => sectionView.focusStation(stationId),
|
||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
|
||||
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
|
||||
boxSelect: (chainageM, role) => stationControls.box.select(chainageM, role),
|
||||
});
|
||||
// 카드를 못 찾았으면(구조물 목록이 아직 안 실림) 적용한 것으로 치지 않는다 — 목록이 실린 뒤
|
||||
// `load().then(restoreStructurePick)` 가 다시 부른다.
|
||||
const applied = applyStructurePick(
|
||||
handoff,
|
||||
sectionDetail,
|
||||
{
|
||||
focusStation: (stationId) => sectionView.focusStation(stationId),
|
||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
|
||||
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
|
||||
boxSelect: (chainageM, role) => stationControls.box.select(chainageM, role),
|
||||
},
|
||||
structuresPanel.getStructures(),
|
||||
);
|
||||
if (applied) appliedPick = signature;
|
||||
}
|
||||
|
||||
function renderSectionDetail(): void {
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type { StructurePickHandoff } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import {
|
||||
coversChainage,
|
||||
isNear,
|
||||
type SelectableStructure,
|
||||
} from "../A00_Common/design/design_selection";
|
||||
|
||||
/** 기슭막이 조정창이 받는 부재키 — 집수정·배관·본체는 여기서 빠진다. */
|
||||
const REVET_KEYS = /^(inlet|outlet|own|extra\d+|bextra\d+)$/;
|
||||
@@ -30,20 +35,30 @@ interface PickTargets {
|
||||
|
||||
/**
|
||||
* 넘김값을 화면에 적용한다. 값이 없거나 그 측점이 이 노선에 없으면 아무것도 안 한다.
|
||||
* 카드가 다 선 뒤에 고른다 — 스크롤이 자리를 잡아야 한다.
|
||||
* 카드가 다 선 뒤에 고른다 — 스크롤이 자리를 잡아야 한다. 카드를 찾아 적용했으면 true —
|
||||
* 못 찾았으면(구조물 목록이 아직 안 실린 경우 등) false 라 부르는 쪽이 나중에 다시 부른다.
|
||||
*/
|
||||
export function applyStructurePick(
|
||||
handoff: StructurePickHandoff | null,
|
||||
detail: SectionDetailResponse | null,
|
||||
targets: PickTargets,
|
||||
): void {
|
||||
if (!handoff) return;
|
||||
// 허용오차는 같은 화면의 다른 대조와 같은 0.05m 다 — 0.01m 로는 3D 가 준 누가거리가
|
||||
/** 구조물 정본 — 카드 위치가 아닌 곳(구간형 옹벽 한가운데 등)을 골랐을 때 그 카드를 찾는 데 쓴다. */
|
||||
structures: ReadonlyArray<SelectableStructure> = [],
|
||||
): boolean {
|
||||
if (!handoff) return false;
|
||||
// 허용오차는 관·구조물 찾는 거리 한 값이다 — 0.01m 로는 3D 가 준 누가거리가
|
||||
// 소수점 아래에서 조금만 달라도 그 측점을 못 찾고 조용히 끝났다(2026-09-05 진단).
|
||||
const target = detail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - handoff.at) < 0.05,
|
||||
);
|
||||
if (!target) return;
|
||||
const sections = detail?.cross_sections ?? [];
|
||||
// 구조물 id 가 있으면 그 구조물의 카드가 먼저다 — 같은 측점 근처에 다른 카드(관)가 있어도.
|
||||
const byId = handoff.structureId
|
||||
? structures.find((entry) => entry.structure_id === handoff.structureId)
|
||||
: undefined;
|
||||
const covering = structures.find((entry) => coversChainage(entry, handoff.at));
|
||||
const target =
|
||||
(byId && cardOfStructure(sections, byId, handoff.at)) ||
|
||||
sections.find((section) => isNear(section.chainage_m, handoff.at)) ||
|
||||
(covering && cardOfStructure(sections, covering, handoff.at));
|
||||
if (!target) return false;
|
||||
// 조정창은 앞칸(측 이름)만 본다 — 뒤칸은 3D 강조를 부재 하나로 좁히는 몫이다.
|
||||
const key = handoff.key?.split(":")[0];
|
||||
requestAnimationFrame(() => {
|
||||
@@ -61,4 +76,28 @@ export function applyStructurePick(
|
||||
}
|
||||
targets.refreshCard(target.chainage_m);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
type Sections = SectionDetailResponse["cross_sections"];
|
||||
|
||||
/** 구조물이 자리한 카드 — 구간형이면 그 안의 첫 카드, 점형이면 가장 가까운 카드. 옹벽 · 기슭막이는
|
||||
* 횡단 제원이라 시작 측점 카드가 주인이다(관은 `isNear` 로 카드를 바로 찾는다). */
|
||||
function cardOfStructure(
|
||||
sections: Sections,
|
||||
structure: SelectableStructure,
|
||||
at: number,
|
||||
): Sections[number] | undefined {
|
||||
if (!sections.length) return undefined;
|
||||
const start = structure.start_m ?? structure.chainage_m;
|
||||
const end = structure.end_m ?? structure.chainage_m;
|
||||
if (start != null && end != null) {
|
||||
const inside = sections.find(
|
||||
(section) => section.chainage_m >= start - 0.01 && section.chainage_m <= end + 0.01,
|
||||
);
|
||||
if (inside) return inside;
|
||||
}
|
||||
return sections.reduce((best, entry) =>
|
||||
Math.abs(entry.chainage_m - at) < Math.abs(best.chainage_m - at) ? entry : best,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,10 +27,14 @@ import {
|
||||
import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { readSummaryItem } from "../common_util/common_util_summary_item";
|
||||
import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import {
|
||||
readStructurePick,
|
||||
writeStructurePick,
|
||||
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
NO_SELECTION,
|
||||
PIPE_MATCH_M,
|
||||
resolveSelection,
|
||||
selectDesign,
|
||||
type DesignSelection,
|
||||
} from "../A00_Common/design/design_selection";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend";
|
||||
import {
|
||||
@@ -92,14 +96,20 @@ export interface B06StructuresPanel {
|
||||
listRoot: HTMLElement;
|
||||
/** 타입 레지스트리·구조물 정본(미저장분 우선)·관 지점을 받아 채운다. */
|
||||
load: () => Promise<void>;
|
||||
/** 횡단도에서 고른 벽·구체의 시설을 폼에 올린다(null = 해제). */
|
||||
showPipeAt: (chainageM: number | null) => void;
|
||||
/** 기하가 한계로 자른 실제 값을 폼에 되돌린다 — 입력 숫자만 커지고 그림은 그대로인
|
||||
* 상태를 막는다(2026-08-29 사용자: 재질 높이 한계에 걸린 값). */
|
||||
overrideOptions: (chainageM: number, patch: Record<string, string | number>) => void;
|
||||
/** 고른 횡단도(측점)에 있는 구조물을 폼에 올린다 — 계곡 통과 시설이 먼저고,
|
||||
* 없으면 그 측점을 덮는 구조물 정본 항목. 둘 다 없으면 해제(2026-08-29). */
|
||||
showAtChainage: (chainageM: number | null) => void;
|
||||
* 없으면 그 측점을 덮는 구조물 정본 항목. 둘 다 없으면 해제(2026-08-29). 횡단도의 카드 ·
|
||||
* 벽 · 구체 어디를 눌러도 이 길 하나다. 폼이 연 것을 고름 값으로 돌려준다. */
|
||||
showAtChainage: (chainageM: number | null) => DesignSelection;
|
||||
/** 횡단도에서 벽·구체 선택을 풀 때 — 관 계열 폼만 비운다. 구조물 폼은 그대로 둔다(카드를
|
||||
* 갈아 선택하며 옛 카드의 벽 선택이 풀리는 순간 새 카드의 폼을 지우면 안 된다). */
|
||||
releasePipe: () => void;
|
||||
/** 그 측점에서 폼이 열 항목을 **열지 않고** 고름 값으로 풀어 준다(세션에 적는 데 쓴다). */
|
||||
selectionAt: (chainageM: number | null) => DesignSelection;
|
||||
/** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */
|
||||
getStructures: () => StructureInstance[];
|
||||
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */
|
||||
currentChainage: () => number | null;
|
||||
/** 종단 그래프 우클릭이 쓰는 길 — 좌측 폼·목록과 **같은 함수**를 탄다(2026-09-12). */
|
||||
@@ -116,17 +126,6 @@ export interface B06StructuresPanel {
|
||||
};
|
||||
}
|
||||
|
||||
/** 관 매칭 허용 오차(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다(B05와 같은 기준). */
|
||||
const PIPE_MATCH_M = 0.51;
|
||||
|
||||
/** 이 구조물이 그 측점을 덮는가 — 점형은 근사 일치, 구간형은 시작~종료 안. */
|
||||
function coversChainage(structure: StructureInstance, chainageM: number): boolean {
|
||||
if (structure.placement === "interval" && structure.start_m != null && structure.end_m != null) {
|
||||
return chainageM >= structure.start_m - 0.01 && chainageM <= structure.end_m + 0.01;
|
||||
}
|
||||
return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M;
|
||||
}
|
||||
|
||||
type Merged = Record<string, string | number>;
|
||||
|
||||
/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */
|
||||
@@ -376,7 +375,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
onPipeRemove: (chainageM) => removePipeAt(chainageM),
|
||||
onPipeSelect: (chainageM) => {
|
||||
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
|
||||
writeStructurePick(deps.projectId, chainageM);
|
||||
selectDesign(
|
||||
deps.projectId,
|
||||
chainageM === null ? NO_SELECTION : { chainageM, structureId: null },
|
||||
{ from: "b06" },
|
||||
);
|
||||
if (chainageM !== null) deps.focusChainage(chainageM);
|
||||
},
|
||||
},
|
||||
@@ -456,8 +459,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
// 목록이 늦게 도착하면 그 사이에 고른 시설은 강조될 자리가 없었다 — 다시 세운다
|
||||
// (2026-09-04 사용자 보고: B06 좌측 목록만 하이라이트가 안 붙음). 폼에 아직 아무것도
|
||||
// 없으면 세션에 남은 선택(카드형 — 부재키 없는 측점)도 같은 규칙으로 세운다.
|
||||
const picked = currentChainageM ?? readStructurePick(deps.projectId)?.at ?? null;
|
||||
if (picked !== null) section.selectPipeByChainage(picked);
|
||||
const pick = readStructurePick(deps.projectId);
|
||||
const picked = currentChainageM ?? pick?.at ?? null;
|
||||
// 관이면 그 관, 옹벽 · 기슭막이 같은 구조물이면 저장된 구조물 id 로 폼을 세운다.
|
||||
if (picked !== null) {
|
||||
section.selectAt({
|
||||
chainageM: picked,
|
||||
structureId: currentChainageM === null ? (pick?.structureId ?? null) : null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.",
|
||||
@@ -576,30 +586,27 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
// 폼을 그 값으로 다시 세운다 — 조정창이 한계를 알려 주던 것과 같은 자리다.
|
||||
section.selectPipeByChainage(hit.chainage_m);
|
||||
},
|
||||
showPipeAt: (chainageM) => {
|
||||
currentChainageM = chainageM;
|
||||
if (chainageM !== null) refreshSpecDefaults(chainageM);
|
||||
section.selectPipeByChainage(chainageM);
|
||||
getStructures: () => structures,
|
||||
releasePipe: () => {
|
||||
currentChainageM = null;
|
||||
section.selectPipeByChainage(null);
|
||||
},
|
||||
selectionAt: (chainageM) => {
|
||||
const selection = { chainageM, structureId: null };
|
||||
return { chainageM, structureId: section.resolveAt(selection).structureId };
|
||||
},
|
||||
showAtChainage: (chainageM) => {
|
||||
currentChainageM = null;
|
||||
if (chainageM === null) {
|
||||
section.selectById(null);
|
||||
return;
|
||||
}
|
||||
const selection: DesignSelection = { chainageM, structureId: null };
|
||||
// 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에
|
||||
// 그려지는 것은 이쪽이다.
|
||||
const pipe = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (pipe) {
|
||||
currentChainageM = pipe.chainage_m;
|
||||
refreshSpecDefaults(pipe.chainage_m);
|
||||
section.selectPipeByChainage(pipe.chainage_m);
|
||||
return;
|
||||
// 그려지는 것은 이쪽이다(순서 · 거리는 `resolveSelection` 한 곳).
|
||||
const hit = resolveSelection(selection, pipeFacilities, structures);
|
||||
if (hit.pipeChainageM !== null) {
|
||||
currentChainageM = hit.pipeChainageM;
|
||||
refreshSpecDefaults(hit.pipeChainageM);
|
||||
}
|
||||
const hit = structures.find((entry) => coversChainage(entry, chainageM));
|
||||
section.selectById(hit?.structure_id ?? null);
|
||||
section.selectAt(selection);
|
||||
return { chainageM, structureId: hit.structureId };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -661,7 +668,8 @@ export function wireStructureSelection(
|
||||
markWallSelecting();
|
||||
origRevet(chainageM, key);
|
||||
if (key) remember?.(ownerChainageOf(chainageM), key);
|
||||
panel.showPipeAt(key ? ownerChainageOf(chainageM) : null);
|
||||
if (key) panel.showAtChainage(ownerChainageOf(chainageM));
|
||||
else panel.releasePipe();
|
||||
if (key) syncInletStructure();
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다.
|
||||
refreshAdjustSlots();
|
||||
@@ -672,7 +680,8 @@ export function wireStructureSelection(
|
||||
markWallSelecting();
|
||||
origFord(chainageM, role);
|
||||
if (role) remember?.(chainageM, role);
|
||||
panel.showPipeAt(role ? chainageM : null);
|
||||
if (role) panel.showAtChainage(chainageM);
|
||||
else panel.releasePipe();
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다
|
||||
// (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1).
|
||||
refreshAdjustSlots();
|
||||
@@ -682,7 +691,8 @@ export function wireStructureSelection(
|
||||
markWallSelecting();
|
||||
origBox(chainageM, role);
|
||||
if (role) remember?.(chainageM, role);
|
||||
panel.showPipeAt(role ? chainageM : null);
|
||||
if (role) panel.showAtChainage(chainageM);
|
||||
else panel.releasePipe();
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다.
|
||||
refreshAdjustSlots();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/* 측점 · 구조물 고름 한 곳 단위검증 헬퍼(PLAN 24-2d) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다.
|
||||
* 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다(세션 저장소와 즉시 실행 프레임만 흉내).
|
||||
*
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
const session = new Map();
|
||||
const fakeStorage = {
|
||||
getItem: (k) => session.get(k) ?? null,
|
||||
setItem: (k, v) => session.set(k, String(v)),
|
||||
removeItem: (k) => session.delete(k),
|
||||
};
|
||||
globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage };
|
||||
globalThis.sessionStorage = fakeStorage;
|
||||
globalThis.localStorage = fakeStorage;
|
||||
globalThis.requestAnimationFrame = (fn) => fn();
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) =>
|
||||
request.startsWith(".")
|
||||
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
||||
: require(request);
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const sel = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"));
|
||||
const pickPage = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Structure_Pick.ts"));
|
||||
const pickSession = loadTs(
|
||||
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts"),
|
||||
);
|
||||
|
||||
const out = { matchM: sel.PIPE_MATCH_M };
|
||||
const P = "proj-1";
|
||||
|
||||
// ── 풀기: 관 → 구조물 순 · 한 거리 ───────────────────────────────────────
|
||||
// 옹벽(구간 200~210) · 독립 기슭막이(구간 80~90) · BOX/세월교/배수관은 관 정본
|
||||
const structures = [
|
||||
{ structure_id: "wall", placement: "interval", start_m: 200, end_m: 210, chainage_m: 205 },
|
||||
{ structure_id: "revet", placement: "interval", start_m: 80, end_m: 90, chainage_m: 85 },
|
||||
];
|
||||
const pipes = [{ chainage_m: 173.09 }, { chainage_m: 205.2 }, { chainage_m: 300 }];
|
||||
const at = (chainageM, structureId = null) =>
|
||||
sel.resolveSelection({ chainageM, structureId }, pipes, structures);
|
||||
out.card = {
|
||||
// 횡단 카드 측점이 관 측점에서 0.05 ~ 0.51 m 어긋나도 그 관을 찾는다(옛 B05 폼은 0.05 라 놓쳤다)
|
||||
pipeNear: at(173.4),
|
||||
pipeFar: at(173.09 + 0.6),
|
||||
wallStart: at(200),
|
||||
wallMiddle: at(207),
|
||||
wallEdge: at(210.4),
|
||||
revet: at(85),
|
||||
none: at(250),
|
||||
cleared: at(null),
|
||||
// 같은 측점에 관과 구조물이 겹치면 관이 먼저(횡단도에 그려지는 것)
|
||||
pipeBeatsStructure: at(205.0),
|
||||
// 구조물 id 가 주어지고 목록에 있으면 측점보다 id 가 이긴다 · 없는 id 는 측점으로 되돌아간다
|
||||
byId: at(300, "revet"),
|
||||
staleId: at(200, "gone"),
|
||||
};
|
||||
|
||||
// ── 세션 값 + 알림 ─────────────────────────────────────────────────────
|
||||
const heard = [];
|
||||
const off = sel.subscribeSelection(P, (s, from) => heard.push({ ...s, from }));
|
||||
sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { partKey: "own", from: "b06" });
|
||||
out.store1 = sel.readSelection(P);
|
||||
sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { from: "b06" });
|
||||
out.sameQuiet = heard.length; // 같은 값 · 부재키 생략 → 알리지 않음
|
||||
sel.selectDesign(P, { chainageM: 173.09, structureId: null }, { from: "b05" });
|
||||
out.keyKept = sel.readSelection(P)?.partKey; // 부재키를 안 주면 지킨다(옛 규칙)
|
||||
sel.selectDesign(P, sel.NO_SELECTION, { from: "b05" });
|
||||
out.cleared = sel.readSelection(P);
|
||||
out.heardFrom = heard.map((h) => [h.chainageM, h.structureId, h.from]);
|
||||
off();
|
||||
sel.selectDesign(P, { chainageM: 1, structureId: null });
|
||||
out.afterOff = heard.length;
|
||||
out.noProject = sel.readSelection(null);
|
||||
// 알림 도중 다시 낸 값은 적기만 한다(되울림 방지)
|
||||
let echoes = 0;
|
||||
const off2 = sel.subscribeSelection(P, () => {
|
||||
echoes += 1;
|
||||
sel.selectDesign(P, { chainageM: 2, structureId: null });
|
||||
});
|
||||
sel.selectDesign(P, { chainageM: 3, structureId: null });
|
||||
out.echoes = echoes;
|
||||
out.lastWritten = sel.readSelection(P)?.chainageM;
|
||||
off2();
|
||||
sel.selectDesign(P, sel.NO_SELECTION);
|
||||
|
||||
// ── 넘김값 읽기 ────────────────────────────────────────────────────────
|
||||
sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { partKey: "right" });
|
||||
out.handoff = pickSession.readStructurePick(P);
|
||||
sel.selectDesign(P, sel.NO_SELECTION);
|
||||
out.handoffNone = pickSession.readStructurePick(P);
|
||||
|
||||
// ── B06 진입 때 카드 찾기 ──────────────────────────────────────────────
|
||||
const cards = [80, 173.09, 200, 205.2, 220].map((c) => ({
|
||||
chainage_m: c,
|
||||
station_id: `s${c}`,
|
||||
ford: null,
|
||||
box: null,
|
||||
}));
|
||||
const detail = { cross_sections: cards };
|
||||
const run = (handoff, list) => {
|
||||
const calls = [];
|
||||
calls.applied = pickPage.applyStructurePick(
|
||||
handoff,
|
||||
detail,
|
||||
{
|
||||
focusStation: (id) => calls.push(["focus", id]),
|
||||
refreshCard: (c) => calls.push(["refresh", c]),
|
||||
revetSelect: (c, k) => calls.push(["revet", c, k]),
|
||||
fordSelect: (c, k) => calls.push(["ford", c, k]),
|
||||
boxSelect: (c, k) => calls.push(["box", c, k]),
|
||||
},
|
||||
list,
|
||||
);
|
||||
return calls;
|
||||
};
|
||||
const midWall = { at: 207, structureId: "wall" }; // 구간 한가운데 — 카드 위치가 아님
|
||||
out.retry = {
|
||||
// 구조물 목록이 아직 안 실렸으면 카드를 못 찾고 false → 부르는 쪽이 나중에 다시 부른다
|
||||
before: run({ at: 260 }, []).applied,
|
||||
// 목록이 실린 뒤 같은 넘김값은 카드를 찾는다
|
||||
after: run(midWall, structures).applied,
|
||||
notFound: run({ at: 260 }, structures).applied,
|
||||
emptyHandoff: run(null, structures).applied,
|
||||
};
|
||||
out.apply = {
|
||||
pipeOffsetCard: run({ at: 173.4 }, structures),
|
||||
wallByAnchor: run({ at: 205, structureId: "wall" }, structures),
|
||||
wallNoId: run({ at: 207 }, structures),
|
||||
revetCardFirst: run({ at: 85, structureId: "revet", key: "own" }, structures),
|
||||
unknown: run({ at: 260 }, structures),
|
||||
none: run(null, structures),
|
||||
};
|
||||
|
||||
// ── B05 진입 때 되살리기 — 구조물 id 가 있을 때만 구조물로 연다 ─────────────
|
||||
const restoreRun = (pickValue) => {
|
||||
sel.selectDesign(P, sel.NO_SELECTION);
|
||||
if (pickValue) sel.selectDesign(P, pickValue.selection, { partKey: pickValue.key });
|
||||
const calls = [];
|
||||
let opened = false;
|
||||
const section = {
|
||||
hasSelection: () => opened,
|
||||
selectAt: (s) => {
|
||||
calls.push(["selectAt", s.chainageM, s.structureId]);
|
||||
opened = true;
|
||||
},
|
||||
};
|
||||
const controls = { select: (p) => calls.push(["3d", p.chainageM, p.key ?? null]) };
|
||||
const done = pickSession.restoreStructurePick(controls, P, section, (c) =>
|
||||
calls.push(["station", c]),
|
||||
);
|
||||
return { calls, done };
|
||||
};
|
||||
out.restore = {
|
||||
pipeOnly: restoreRun({ selection: { chainageM: 85.05, structureId: null } }),
|
||||
structure: restoreRun({ selection: { chainageM: 205, structureId: "wall" }, key: "own" }),
|
||||
nothing: restoreRun(null),
|
||||
};
|
||||
sel.selectDesign(P, sel.NO_SELECTION);
|
||||
|
||||
console.log(JSON.stringify(out));
|
||||
@@ -0,0 +1,195 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""측점 · 구조물 고름 한 곳 회귀검증 — PLAN 24-2d (2026-09-29).
|
||||
|
||||
• 관 찾는 거리 한 값(`PIPE_MATCH_M = 0.51`) — 정의는 한 곳, 옛 0.05 는 없음
|
||||
• 횡단 카드 · 종단 알약 · 목록 · 3D 표지 어디서 골라도 같은 순서(관 → 구조물) · 같은 거리로 폼이 열 항목을 풂
|
||||
• 세션 값(`structure-pick`) 읽기 · 쓰기 · 알림 — 부재키 지킴 · 같은 값 조용 · 되울림 막음
|
||||
• B06 진입 때 옹벽 · 기슭막이 카드를 구조물 id 로 찾음
|
||||
화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_b05_b06_selection.cjs`).
|
||||
실제 화면 조작은 ORCA(자체 확인)로 따로 본다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_b06_selection.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
# ── 거리 한 값 ─────────────────────────────────────────────────────────────
|
||||
def test_거리_값은_0_51_하나(out):
|
||||
assert out["matchM"] == 0.51
|
||||
|
||||
|
||||
def test_거리_정의는_한_곳뿐이고_옛_0_05는_없다():
|
||||
definitions = []
|
||||
for folder in ("A00_Common", "B05_Profile", "B06_Section"):
|
||||
for current, _dirs, names in os.walk(os.path.join(ROOT, folder)):
|
||||
if "node_modules" in current:
|
||||
continue
|
||||
for name in names:
|
||||
if not name.endswith(".ts"):
|
||||
continue
|
||||
text = _read(os.path.relpath(os.path.join(current, name), ROOT))
|
||||
if re.search(r"\b(const|let)\s+PIPE_MATCH_M\b", text):
|
||||
definitions.append(name)
|
||||
assert definitions == ["design_selection.ts"]
|
||||
for name, folder in (
|
||||
("B05_Profile_UI_Structures_Panel.ts", "B05_Profile"),
|
||||
("B05_Profile_UI_Page.ts", "B05_Profile"),
|
||||
("B05_Profile_UI_Selection.ts", "B05_Profile"),
|
||||
("B06_Section_UI_Page_Structures_Panel.ts", "B06_Section"),
|
||||
("B06_Section_UI_Page_Structure_Pick.ts", "B06_Section"),
|
||||
("B05_Profile_UI_Structure_Pick_Session.ts", "B05_Profile"),
|
||||
):
|
||||
text = _read(folder, name)
|
||||
assert not re.search(r"<\s*0\.05\b", text), name
|
||||
assert not re.search(r"[-+]\s*0\.51\b", text), name
|
||||
|
||||
|
||||
# ── 카드 누름 → 폼이 열 항목 ────────────────────────────────────────────
|
||||
def test_카드_측점이_관에서_0_05보다_어긋나도_관을_찾는다(out):
|
||||
assert out["card"]["pipeNear"] == {"pipeChainageM": 173.09, "structureId": None}
|
||||
assert out["card"]["pipeFar"]["pipeChainageM"] is None # 0.51 m 밖은 다른 자리
|
||||
|
||||
|
||||
def test_옹벽_기슭막이_카드는_구조물_폼을_연다(out):
|
||||
for key in ("wallStart", "wallMiddle", "wallEdge"):
|
||||
assert out["card"][key] == {"pipeChainageM": None, "structureId": "wall"}, key
|
||||
assert out["card"]["revet"]["structureId"] == "revet"
|
||||
|
||||
|
||||
def test_같은_측점에_관과_구조물이_겹치면_관이_먼저(out):
|
||||
assert out["card"]["pipeBeatsStructure"]["pipeChainageM"] == 205.2
|
||||
|
||||
|
||||
def test_구조물_id가_있으면_id가_이기고_없는_id는_측점으로_되돌아간다(out):
|
||||
assert out["card"]["byId"]["structureId"] == "revet"
|
||||
assert out["card"]["staleId"]["structureId"] == "wall"
|
||||
|
||||
|
||||
def test_고를_것이_없으면_해제(out):
|
||||
assert out["card"]["none"] == {"pipeChainageM": None, "structureId": None}
|
||||
assert out["card"]["cleared"] == {"pipeChainageM": None, "structureId": None}
|
||||
|
||||
|
||||
# ── 세션 값 + 알림 ─────────────────────────────────────────────────────
|
||||
def test_세션에_적고_읽는다(out):
|
||||
assert out["store1"] == {"chainageM": 205, "structureId": "wall", "partKey": "own"}
|
||||
assert out["handoff"] == {"at": 205, "key": "right", "structureId": "wall"}
|
||||
assert out["handoffNone"] is None
|
||||
assert out["noProject"] is None
|
||||
|
||||
|
||||
def test_부재키를_안_주면_지키고_해제하면_비운다(out):
|
||||
assert out["keyKept"] == "own"
|
||||
assert out["cleared"] is None
|
||||
|
||||
|
||||
def test_같은_값은_알리지_않고_출처를_함께_알린다(out):
|
||||
assert out["sameQuiet"] == 1
|
||||
assert out["heardFrom"] == [[205, "wall", "b06"], [173.09, None, "b05"], [None, None, "b05"]]
|
||||
assert out["afterOff"] == 3 # 구독을 끊으면 더는 안 들림
|
||||
|
||||
|
||||
def test_알림_도중_다시_낸_값은_적기만_한다(out):
|
||||
assert out["echoes"] == 1
|
||||
assert out["lastWritten"] == 2
|
||||
|
||||
|
||||
# ── B06 진입 때 카드 찾기 ──────────────────────────────────────────────
|
||||
def test_관_카드는_거리_안에서_바로_찾는다(out):
|
||||
assert out["apply"]["pipeOffsetCard"] == [["focus", "s173.09"]]
|
||||
|
||||
|
||||
def test_옹벽은_구조물_id로_시작_카드를_찾는다(out):
|
||||
assert out["apply"]["wallByAnchor"] == [["focus", "s200"]]
|
||||
assert out["apply"]["wallNoId"] == [["focus", "s200"]]
|
||||
|
||||
|
||||
def test_기슭막이_부재키는_그_카드의_조정창을_연다(out):
|
||||
assert out["apply"]["revetCardFirst"] == [
|
||||
["focus", "s80"],
|
||||
["revet", 80, "own"],
|
||||
["refresh", 80],
|
||||
]
|
||||
|
||||
|
||||
def test_카드를_못_찾으면_false로_알려_나중에_다시_부르게_한다(out):
|
||||
assert out["retry"] == {
|
||||
"before": False,
|
||||
"after": True,
|
||||
"notFound": False,
|
||||
"emptyHandoff": False,
|
||||
}
|
||||
|
||||
|
||||
def test_모르는_자리_또는_값_없음은_아무것도_안_한다(out):
|
||||
assert out["apply"]["unknown"] == []
|
||||
assert out["apply"]["none"] == []
|
||||
|
||||
|
||||
# ── B05 진입 때 되살리기 ───────────────────────────────────────────────
|
||||
def test_B05_되살리기_관은_측점만으로_구조물을_짐작하지_않는다(out):
|
||||
pipe = out["restore"]["pipeOnly"]
|
||||
assert pipe["calls"] == [["3d", 85.05, None], ["station", 85.05]]
|
||||
assert pipe["done"] is False # 관 목록이 실리면 다시 불린다
|
||||
|
||||
|
||||
def test_B05_되살리기_구조물_id가_있으면_그_구조물을_연다(out):
|
||||
structure = out["restore"]["structure"]
|
||||
assert structure["calls"] == [
|
||||
["3d", 205, "own"],
|
||||
["station", 205],
|
||||
["selectAt", 205, "wall"],
|
||||
]
|
||||
assert structure["done"] is True
|
||||
|
||||
|
||||
def test_B05_되살리기_적힌_것이_없으면_끝난_것(out):
|
||||
assert out["restore"]["nothing"] == {"calls": [], "done": True}
|
||||
|
||||
|
||||
# ── 배선 ───────────────────────────────────────────────────────────────
|
||||
def test_횡단도_벽_구체_눌러도_폼은_같은_길_하나():
|
||||
panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
|
||||
assert "showPipeAt" not in panel # 관 전용 길을 없앰
|
||||
assert panel.count("panel.showAtChainage(") == 3 # 기슭막이 · 세월교 · BOX
|
||||
assert panel.count("panel.releasePipe()") == 3 # 벽 선택을 풀 때는 관 폼만 비움
|
||||
assert "section.selectAt(" in panel
|
||||
page = _read("B06_Section", "B06_Section_UI_Page.ts")
|
||||
assert "writeStructurePick" not in page
|
||||
assert "selectDesign(" in page
|
||||
|
||||
|
||||
def test_고름은_selectDesign_한_길로_적는다():
|
||||
for folder, name in (
|
||||
("B05_Profile", "B05_Profile_UI_Page.ts"),
|
||||
("B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts"),
|
||||
("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts"),
|
||||
):
|
||||
text = _read(folder, name)
|
||||
assert "writeStructurePick" not in text, name
|
||||
assert "rememberStructurePick" not in text, name
|
||||
assert "selectDesign(" in _read("B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts")
|
||||
Reference in New Issue
Block a user