Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-10 19:14:12 +09:00
18 changed files with 966 additions and 90 deletions
+31
View File
@@ -0,0 +1,31 @@
/* =============================================================================
* A00_Common/design/design_pick_escape.ts
* 풀기 한 길 — Esc = 고름 한 칸 비움 (233-3 · 조사 AF (나)).
*
* 값을 비우면 구독한 화면이 모두 따라 풂 — 종단(측점선 · 알약) · 3D(띠 · 핀 · 부재) · 배수유역도 ·
* 좌측 폼 · 횡단 카드 · 범위 테두리 · 포장층 · 형상. 입력 칸 · 창(모달) · 메뉴 · 종단 도구가 먼저
* 받은 Esc(입력 취소 · 창 닫기 · 도구 끝)는 그쪽 몫이라 건드리지 않음.
* ========================================================================== */
import { NO_SELECTION, selectDesign } from "./design_selection";
/** Esc 로 푼 고름의 출처 이름 — 구독자가 폼까지 닫을지 가르는 데 씀. */
export const ESCAPE_FROM = "esc";
const OWN_ESCAPE =
"input, select, textarea, [contenteditable='true'], [role='dialog'], .ui-modal__backdrop";
/** 보기마다 건다 — 보이는 보기 하나만 받음 · 보기를 떠나면 뗌. */
export function bindEscapeRelease(
projectId: string | null,
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
): void {
const onKey = (event: KeyboardEvent): void => {
if (event.key !== "Escape" || event.defaultPrevented || !host.visible()) return;
const target = event.target as HTMLElement | null;
if (target?.closest?.(OWN_ESCAPE) || document.querySelector(".ui-modal__backdrop")) return;
selectDesign(projectId, NO_SELECTION, { from: ESCAPE_FROM });
};
window.addEventListener("keydown", onKey);
host.onDispose(() => window.removeEventListener("keydown", onKey));
}
+70 -6
View File
@@ -1,6 +1,7 @@
/* =============================================================================
* A00_Common/design/design_pick_profile.ts
* 종단도 고름 강조(측점 세로선 · 알약 · 구간 띠) = 고름 한 칸에서 파생 (233-1 · 조사 AF (나) ②).
* 종단 보기 고름 강조 = 고름 한 칸에서 파생 — 종단도(측점 세로선 · 알약 · 구간 띠 · 233-1) ·
* 3D(측점 띠 · 핀 · 부재 · 233-2) · 배수유역도(관 마커 · 유역 · 측점 마름모 · 233-3) (조사 AF (나) ② ③ ⑤).
*
* 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고,
* 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이
@@ -14,6 +15,7 @@ import {
subscribeSelection,
type DesignSelection,
} from "./design_selection";
import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape";
import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
@@ -32,6 +34,11 @@ export interface ProfilePickSource {
export interface ProfilePickTarget {
setSelectedStation: (stationId: string | null) => void;
setSelectedStructure: (structureId: string | null) => void;
/** 배수유역도 — 관 마커 · 유역(알림 없이) · 측점 마름모. */
drainage?: {
selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void;
markStation: (chainageM: number | null) => void;
} | null;
}
/** 같은 값으로 적힌 측점인가 — 고름 한 칸은 고른 측점의 누가거리를 그대로 적음(찾는 폭 아님). */
@@ -70,19 +77,76 @@ export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick):
else target.setSelectedStation(pick.stationId);
}
/** 배수유역도 = 그 자리 관 마커 · 유역(없으면 풂) + 측점 마름모 — 재계산이 지운 뒤에도 다시 얹음(a3 · a6). */
export function paintMapPick(
map: NonNullable<ProfilePickTarget["drainage"]>,
selection: DesignSelection | null,
): void {
const at = selection?.chainageM ?? null;
map.selectPipeAtChainage(at, false);
map.markStation(at);
}
export interface ViewerPickTarget {
markers: { selectStation: (stationId: string | null, notify?: boolean) => void };
structurePick: {
select: (pick: { chainageM: number; kind: string; key?: string } | null) => void;
};
}
/** 고름 값 → 3D 가 켤 것 — 띠(+핀) = 종단 측점선과 같은 측점(구조물 고름은 그 자리 구조물 측점만) ·
* 부재 = 고른 측점의 부재 전부(부재키가 있으면 그 부재 하나). */
export function viewerPickOf(
selection: (DesignSelection & { partKey?: string }) | null,
source: ProfilePickSource,
): { stationId: string | null; member: { chainageM: number; kind: string; key?: string } | null } {
if (!selection || selection.chainageM === null) return { stationId: null, member: null };
const at = selection.chainageM;
const pick = profilePickOf(selection, source);
const own = selection.structureId
? nearestWithin(source.irregular(), at, (entry) => entry.chainage_m)
: undefined;
const stationId = "stationId" in pick ? pick.stationId : own ? irregularStationId(own.id) : null;
return { stationId, member: { chainageM: at, kind: "", key: selection.partKey } };
}
export function paintViewerPick(
target: ViewerPickTarget,
pick: ReturnType<typeof viewerPickOf>,
): void {
target.markers.selectStation(pick.stationId, false); // 칠하기만 — 알림이 사슬을 다시 돌리지 않게
target.structurePick.select(pick.member);
}
/**
* 종단도를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 바뀐 뒤 같은 값으로 다시 얹음.
* 보기가 숨어 있으면(다른 보기가 종단도를 쥠) 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음.
* 종단 보기(종단도 · 3D · 배수유역도)를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이
* 바뀐 뒤 · 재계산 뒤 · 폼이 닫힌 뒤 같은 값으로 다시 얹음. 보기가 숨어 있으면(횡단 보기가 종단도를 쥠)
* 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. Esc(풀기 한 길)는 좌측 폼까지 닫음.
*/
export function bindProfilePick(
projectId: string | null,
target: ProfilePickTarget,
target: {
profile: ProfilePickTarget;
viewer?: ViewerPickTarget;
form?: { hasSelection: () => boolean; selectById: (id: null) => void };
},
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
source: ProfilePickSource,
): { paint: () => void } {
const paint = (): void => {
if (host.visible()) paintProfilePick(target, profilePickOf(readSelection(projectId), source));
if (!host.visible()) return;
const selection = readSelection(projectId);
paintProfilePick(target.profile, profilePickOf(selection, source));
if (target.viewer) paintViewerPick(target.viewer, viewerPickOf(selection, source));
if (target.profile.drainage) paintMapPick(target.profile.drainage, selection);
};
host.onDispose(subscribeSelection(projectId, paint));
host.onDispose(
subscribeSelection(projectId, (_selection, from) => {
paint();
if (from === ESCAPE_FROM && host.visible() && target.form?.hasSelection())
target.form.selectById(null);
}),
);
bindEscapeRelease(projectId, host);
return { paint };
}
+15 -17
View File
@@ -39,6 +39,7 @@ import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pi
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync";
import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View";
import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
import {
@@ -387,7 +388,7 @@ export async function mountCrossView(
: null;
if (!target) {
structuresPanel.showAtChainage(null);
selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다.
releaseCross(); // 카드 다시 누름 = 풀기 한 길(범위 테두리 · 형상까지 · 233-2)
return;
}
const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null;
@@ -494,23 +495,20 @@ export async function mountCrossView(
if (applied) appliedPick = signature;
}
/** 고름을 다 풂(54-38) — 카드 · 벽 · 세월교 · BOX 강조 · 넘김 되얹기 표시 · 선택 칸. */
// 횡단 보기 강조 = 고름 한 칸 구독 · 다른 곳(Esc 등)이 바꾼 값으로 다시 세움(233-2)
const crossPick = bindCrossPick(projectId, host, {
sections: () => sectionDetail?.cross_sections ?? [],
controls: stationControls,
releaseForm: () => structuresPanel.showAtChainage(null),
view: sectionView,
reapply: () => {
appliedPick = null;
restoreStructurePick();
},
});
/** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */
function releaseCross(): void {
const { revetOffset, ford, box } = stationControls;
const lit = (sectionDetail?.cross_sections ?? []).filter(
(s) =>
revetOffset.highlightFor(s) ||
ford.selectedFor(s.chainage_m) ||
box.selectedFor(s.chainage_m),
);
for (const s of lit) {
if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null);
if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null);
if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null);
}
structuresPanel.releasePipe();
sectionView.focusStation(null);
sectionView.refreshCards(lit.map((s) => s.chainage_m));
crossPick.release();
appliedPick = null;
selectDesign(projectId, NO_SELECTION, { from: "b06" });
}
+27 -44
View File
@@ -31,13 +31,7 @@ import {
restoreStructurePick,
wireStructurePick,
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import {
coversAtStation,
NO_SELECTION,
nearestWithin,
readSelection,
selectDesign,
} from "./design_selection";
import { coversAtStation, NO_SELECTION, readSelection, selectDesign } from "./design_selection";
import type { DesignPageHost } from "./design_page";
import { bindProfilePick } from "./design_pick_profile";
import {
@@ -174,8 +168,8 @@ export async function mountProfileView(
panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
// 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다.
onBasinSelected: (chainageM) => selectStationOfPipe(chainageM),
onPipeSelected: (chainageM) => selectStationOfPipe(chainageM),
onBasinSelected: (chainageM) => pickFromMap(chainageM),
onPipeSelected: (chainageM) => pickFromMap(chainageM),
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
onStructureSelect: (structureId) => {
@@ -221,7 +215,7 @@ export async function mountProfileView(
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */
const uphillOverrides = loadUphillOverrides(activeProjectId);
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({
const { syncIrregularSelection, selectStationOfPipe } = createSelectionSync({
stations: () => bridge.irregularStations(),
isSyncing: () => selectionSyncing,
setSyncing: (value) => {
@@ -245,6 +239,12 @@ export async function mountProfileView(
remember: (chainageM) => rememberPick(chainageM, null),
});
/** 배수유역도 유역 · 관 고름도 고름 한 칸에(사슬 도중 되울림은 안 적음 · 233-3). */
const pickFromMap = (chainageM: number | null): void => {
if (!selectionSyncing) rememberPick(chainageM, null);
selectStationOfPipe(chainageM);
};
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
function restorePick(): void {
@@ -320,39 +320,18 @@ export async function mountProfileView(
forwardPipeOptions(shared, from, attributes.options as Record<string, number | string>);
},
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
// 목록 고름(null = 다시 누름 해제)도 고름 한 칸에 — 종단 · 3D · 배수유역도는 구독이 그 값으로 그림(233-3)
onPipeFacilitySelect: (chainage) => {
rememberPick(chainage, null); // 목록 선택도 횡단 보기로 이어진다.
if (selectionSyncing) return;
selectionSyncing = true;
try {
// null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18).
const station =
chainage === null
? undefined
: nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m);
const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id);
selectGraphAt(id, chainage);
syncBasinHighlight(id);
// 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화).
profilePanel.drainage.selectPipeAtChainage(chainage);
viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2)
} finally {
selectionSyncing = false;
}
rememberPick(chainage, null);
if (!selectionSyncing) profilePick.paint();
},
onListRelease: () => rememberPick(null, null), // 목록 다시 누름 = 풀기 한 길(233-3)
onStructureSelect: (structure) => {
// 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을
// 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문).
if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null);
if (selectionSyncing) return;
selectionSyncing = true;
try {
profilePick.paint(); // 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단은 고름 한 칸대로(233-1)
viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null);
} finally {
selectionSyncing = false;
}
// 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단 · 3D 는 고름 한 칸대로(233-1 · 233-2)
if (!selectionSyncing) profilePick.paint();
},
},
shared,
@@ -411,12 +390,17 @@ export async function mountProfileView(
},
isRestoring: () => restoring,
});
// 종단도 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1)
const profilePick = bindProfilePick(activeProjectId, profilePanel, host, {
irregular: () => bridge.irregularStations(),
pipes: () => lastPipes ?? [],
sections: () => currentSectionDetail?.cross_sections ?? [],
});
// 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2)
const profilePick = bindProfilePick(
activeProjectId,
{ profile: profilePanel, viewer, form: panel.structures },
host,
{
irregular: () => bridge.irregularStations(),
pipes: () => lastPipes ?? [],
sections: () => currentSectionDetail?.cross_sections ?? [],
},
);
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []);
@@ -573,7 +557,6 @@ export async function mountProfileView(
);
return;
}
viewer.markers.selectStation(null);
profilePick.paint();
},
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
+5 -5
View File
@@ -289,18 +289,18 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage);
/** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */
function syncPipeSelection(): void {
function syncPipeSelection(notify = true): void {
const index = pipeEditor.selected();
deleteButton.disabled = index === null;
const picked = index === null ? null : pipeEditor.pipes()[index];
selectBasin(basinIndexOfPipe(basins, picked ?? null));
selectBasin(basinIndexOfPipe(basins, picked ?? null), notify);
// 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은
// Page의 selectionSyncing 가드가 끊는다). 값이 안 바뀌면 조용히 — 재계산(apply)
// 뒤의 재호출이 다른 화면 선택을 풀어 버리지 않게.
const pickedChainage = picked ? picked.chainage_m : null;
if (pickedChainage !== lastNotifiedPipeChainage) {
lastNotifiedPipeChainage = pickedChainage;
callbacks.onPipeSelected?.(pickedChainage);
if (notify) callbacks.onPipeSelected?.(pickedChainage);
}
}
@@ -647,7 +647,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
pipeEditor.select(index);
pipeEditor.deleteSelected();
},
selectPipeAtChainage(chainageM) {
selectPipeAtChainage(chainageM, notify = true) {
const index =
chainageM === null
? null
@@ -658,7 +658,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
if (pipeEditor.selected() === next) return;
// select()는 onChange를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다.
pipeEditor.select(next);
syncPipeSelection();
syncPipeSelection(notify);
scheduleDraw();
},
async savePipes() {
@@ -45,7 +45,8 @@ export interface DrainagePanel {
selectBasinByChainage: (chainageM: number | null) => void;
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
* 이미 같으면 무시(2026-08-17 전역 선택 동기화). */
selectPipeAtChainage: (chainageM: number | null) => void;
/** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(밖으로 안 알림 · 재계산 뒤 다시 얹음 · 233-3). */
selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void;
/** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */
markStation: (chainageM: number | null) => void;
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6).
+3 -2
View File
@@ -494,7 +494,8 @@ export function createRouteMarkers(
if (center) selectionPin.position.copy(center);
}
function selectStation(stationId: string | null): void {
/** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(알림 없음 · 233-2). */
function selectStation(stationId: string | null, notify = true): void {
selectedStationId = stationId;
stationGroup.children.forEach((object) => {
// 측점 띠(stationId 보유 Mesh)만 색을 바꾼다.
@@ -503,7 +504,7 @@ export function createRouteMarkers(
(object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15);
});
syncSelectionPin();
stationSelectionListener?.(selectedStationId);
if (notify) stationSelectionListener?.(selectedStationId);
}
return {
+3
View File
@@ -69,6 +69,8 @@ interface PanelCallbacks {
onPipeFacilityRemove: (chainageM: number) => void;
/** null = 사이드 목록 재클릭 해제 — 전 화면 선택도 함께 푼다(2026-08-18). */
onPipeFacilitySelect: (chainageM: number | null) => void;
/** 목록 다시 누름 · 새로 놓기 = 풀기 한 길(233-3). */
onListRelease?: () => void;
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
onStationDisplayChange: (offset: { station: number; cumulative: number }) => void;
}
@@ -104,6 +106,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
onPipeUpdate: callbacks.onPipeFacilityUpdate,
onPipeRemove: callbacks.onPipeFacilityRemove,
onPipeSelect: callbacks.onPipeFacilitySelect,
onListRelease: callbacks.onListRelease,
},
});
@@ -23,6 +23,7 @@ import {
type SummaryItem,
} from "../common_util/common_util_summary_item";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc";
import {
containerTitle,
@@ -229,7 +230,8 @@ export function renderStructureList(params: StructureListParams): void {
} else {
const { pipe } = row;
const picked =
selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05;
selectedPipeChainage !== null &&
Math.abs(selectedPipeChainage - pipe.chainage_m) < PIPE_MATCH_M; // 고름 찾는 거리 한 값(233-3)
item.classList.toggle("is-selected", picked);
station.textContent = formatStation(pipe.chainage_m, intervalM);
item.addEventListener("click", () => params.onSelectPipe(pipe));
@@ -566,10 +566,10 @@ export function createStructuresSection(
// 이 배열을 참조로 받아 두므로, 새 배열로 바꾸면 그쪽이 옛 배열을 고쳐
// 목록에 반영되지 않는다(2026-09-04 실측: 저장 전 항목 [삭제]가 안 먹던 원인).
structures.splice(0, structures.length, ...next.map((entry) => ({ ...entry })));
// 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다.
// [저장] 뒤 같은 id 가 남으면 그대로 고름 — 다시 열기와 같게(53-2).
// 실시간 반영 목록이면 폼 안 닫음(고치던 칸) · [저장] 뒤 같은 id · 관 폼은 그대로(53-2 · 233-3)
const kept = structures.find((entry) => entry.structure_id === editingId) ?? null;
if (!applyingLive) loadForm(editingId === null ? null : kept);
if (!applyingLive && selectedPipeChainage === null)
loadForm(editingId === null ? null : kept);
renderList();
},
getStructures: () => [...structures],
@@ -593,12 +593,12 @@ export function createStructuresSection(
syncButtons();
}
}
if (
selectedPipeChainage !== null &&
!pipeFacilities.some(
(pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < PIPE_MATCH_M,
)
) {
const kept = pipeFacilities.find(
(pipe) => Math.abs(pipe.chainage_m - (selectedPipeChainage ?? NaN)) < PIPE_MATCH_M,
);
if (kept)
selectedPipeChainage = kept.chainage_m; // 조금 옮겨진 관도 고름 따라감(233-3)
else if (selectedPipeChainage !== null) {
// 고르고 있던 관이 사라졌다(삭제·재계산 이동) — 폼도 함께 닫는다.
selectedPipeChainage = null;
facilityOptions.setFacility(null);
@@ -143,7 +143,7 @@ export function selectionSource(
/* ── 모달 배선 ─────────────────────────────────────────────────────────── */
export interface DetailSelect {
/** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(고름 지움). */
/** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(같은 자리 조각 고름은 유지 · 233-2). */
redraw: () => void;
/** 고르기 갈래(표 우클릭) — 있으면 [칸에 넣기] 단추 · 면적이면 누름 = 늘 더하기. */
setMode: (kind: "area" | "length" | null) => void;
@@ -152,6 +152,10 @@ export interface DetailSelect {
detach: () => void;
}
/** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */
const pieceKey = (piece: SplitPiece | undefined): string =>
piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : "";
export function attachDetailSelect(input: {
cad: HTMLElement;
design: () => CrossDesign | undefined;
@@ -209,10 +213,12 @@ export function attachDetailSelect(input: {
return {
redraw() {
selected = [];
// 다시 그려도(창 크기 · 카드 다시 세움) 같은 자리 조각은 고름 유지 — 지우면 바로 꺼졌음(233-2)
const keep = new Set(selected.map((i) => pieceKey(pieces[i])));
const axes = input.axes();
const layer = cad.querySelector<SVGGElement>("g.b06-chart__plot-layer");
pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : [];
selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : []));
const toPx = axes ? mToPx(axes) : null;
hits = toPx
? pieces.map((piece) => {
@@ -0,0 +1,83 @@
/* =============================================================================
* B06_Section_UI_Page_Pick_Sync.ts
* 횡단 보기 강조를 고름 한 칸에 묶음 (233-2 · 조사 AF (나) ④).
*
* 고름 한 칸(`structure-pick`)이 유일 원천이다. 횡단 보기가 보일 때 다른 곳(풀기 Esc 등)이 값을
* 바꾸면 구독으로 받아 카드 · 범위 테두리(`--span`) · 포장층 · 벽 · 세월교 · BOX 형상 강조를 그 값으로
* 다시 세운다 — 값이 비면 모두 풂, 값이 있으면 넘김 적용(`applyStructurePick`)과 같은 길로 다시 얹음.
* 횡단 보기 안 누름(카드 · 목록 · 알약 · 형상)은 그 자리에서 그리고 값을 적으므로(from "b06") 되받지 않음.
* 풀기(Esc · 카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 · 범위 테두리 ·
* 형상까지 모두 끔(카드만 풀고 `--span` 이 남던 구멍 · a4).
* ========================================================================== */
import { subscribeSelection } from "../A00_Common/design/design_selection";
import { bindEscapeRelease } from "../A00_Common/design/design_pick_escape";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
export interface CrossPickDeps {
sections: () => ReadonlyArray<CrossSection>;
controls: Pick<StationControls, "revetOffset" | "ford" | "box">;
/** 좌측 폼(구조물 · 관) 닫기. */
releaseForm: () => void;
view: {
focusStation: (stationId: string | null) => void;
refreshCards: (chainages: ReadonlyArray<number>) => void;
selectMark: (structureId: string | null, span?: boolean) => void;
};
/** 지금 값으로 카드 · 형상 · 폼을 다시 세움(넘김 적용 길). */
reapply: () => void;
}
let releasing = false;
/** 횡단 보기 강조를 모두 끔 — 카드 · 벽 · 세월교 · BOX · 폼 관 · 범위 테두리 · 포장층(값은 안 건드림).
* 카드를 풀면 카드 리스너가 다시 풀기를 부름 — 되돌아온 부름은 건너뜀. */
export function releaseCrossHighlights(deps: CrossPickDeps): void {
if (releasing) return;
releasing = true;
try {
releaseAll(deps);
} finally {
releasing = false;
}
}
function releaseAll(deps: CrossPickDeps): void {
const { revetOffset, ford, box } = deps.controls;
const lit = deps
.sections()
.filter(
(s) =>
revetOffset.highlightFor(s) ||
ford.selectedFor(s.chainage_m) ||
box.selectedFor(s.chainage_m),
);
for (const s of lit) {
if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null);
if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null);
if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null);
}
deps.releaseForm();
deps.view.focusStation(null);
deps.view.selectMark(null);
deps.view.refreshCards(lit.map((s) => s.chainage_m));
}
/** 횡단 보기를 고름 한 칸에 묶음 — 보일 때 · 다른 곳이 바꾼 값만 받음 · 보기를 떠나면 끊음.
* `release` = 강조 모두 끔(값은 부르는 쪽이 비움). */
export function bindCrossPick(
projectId: string | null,
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
deps: CrossPickDeps,
): { release: () => void } {
host.onDispose(
subscribeSelection(projectId, (selection, from) => {
if (!host.visible() || from === "b06") return;
if (selection.chainageM === null) releaseCrossHighlights(deps);
else deps.reapply();
}),
);
bindEscapeRelease(projectId, host); // 풀기 한 길(233-3)
return { release: () => releaseCrossHighlights(deps) };
}
@@ -27,6 +27,8 @@ const DSel = loadTs("A00_Common/design/design_selection.ts", {
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
},
});
globalThis.window = { addEventListener() {}, removeEventListener() {} };
const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel });
const IRR = {
irregularStationId: (id) => `irregular:${id}`,
isPipeStation: (s) => s.origin === "pipe",
@@ -37,6 +39,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", {
});
const PP = loadTs("A00_Common/design/design_pick_profile.ts", {
"./design_selection": DSel,
"./design_pick_escape": ESC,
"../../B05_Profile/B05_Profile_UI_Selection": SY,
"../../B05_Profile/B05_Profile_UI_IrregularStations": IRR,
});
@@ -67,7 +70,7 @@ let visible = true;
const disposers = [];
const pick = PP.bindProfilePick(
PID,
target,
{ profile: target },
{ visible: () => visible, onDispose: (fn) => disposers.push(fn) },
{ irregular: () => irregular, pipes: () => pipes, sections: () => sections },
);
+319
View File
@@ -0,0 +1,319 @@
/* 233-2 3D · 횡단 카드 · [상세] 강조 = 고름 한 칸에서 파생 — 진짜 TS 를 Node 로 돌림.
* ① 3D: `bindProfilePick`(종단 보기 구독) + 진짜 마커(MK3) · 3D 부재 고름(SPK) · three.
* ② 횡단: `bindCrossPick` · `releaseCrossHighlights`(B06_Section_UI_Page_Pick_Sync) + 카드 · 형상 흉내.
* ③ [상세]: `attachDetailSelect` 다시 그림(창 크기 바뀜) 뒤 고른 선 조각 유지.
* 표준출력 JSON 한 벌 · 짝 = test_233_2_view_pick.py.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const NM = path.join(ROOT, "config", "node_modules");
const ts = require(path.join(NM, "typescript"));
const THREE = require(path.join(NM, "three", "build", "three.cjs"));
function loadTs(file, stubs = {}) {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (request === "three") return THREE;
if (stubs[request]) return stubs[request];
throw new Error(`stub 없음: ${request}`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
/* ── 가짜 DOM(라벨 캔버스 · [상세] 값 칸 · 조각) ── */
const node = (tag) => {
const self = {
tag,
hidden: false,
textContent: "",
attrs: {},
classes: new Set(),
parentElement: null,
setAttribute: (k, v) => {
self.attrs[k] = String(v);
if (k === "class")
String(v)
.split(" ")
.forEach((c) => self.classes.add(c));
},
getAttribute: (k) => self.attrs[k] ?? null,
addEventListener() {},
classList: {
toggle: (c, on) => (on ? self.classes.add(c) : self.classes.delete(c)),
contains: (c) => self.classes.has(c),
},
closest: (sel) => (self.classes.has(sel.slice(1)) ? self : null),
getContext: () => null,
};
return self;
};
globalThis.document = {
createElement: node,
createElementNS: (_ns, tag) => node(tag),
addEventListener() {},
removeEventListener() {},
};
globalThis.window = { addEventListener() {}, removeEventListener() {} };
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
},
});
const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel });
const IRR = {
irregularStationId: (id) => `irregular:${id}`,
isPipeStation: (s) => s.origin === "pipe",
};
const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", {
"../A00_Common/design/design_selection": DSel,
"./B05_Profile_UI_IrregularStations": IRR,
});
const PP = loadTs("A00_Common/design/design_pick_profile.ts", {
"./design_selection": DSel,
"./design_pick_escape": ESC,
"../../B05_Profile/B05_Profile_UI_Selection": SY,
"../../B05_Profile/B05_Profile_UI_IrregularStations": IRR,
});
const MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", {
"@util/common_util_svg": { inferStationInterval: () => 20, stationLabel: (c) => `0+${c}` },
});
const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts");
const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", {
"../A00_Common/design/design_selection": DSel,
"../A00_Common/design/design_pick_escape": ESC,
});
const CROSS_PAD = Function(
`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(fs.readFileSync(path.join(ROOT, "B06_Section", "B06_Section_UI_Section_Common.ts"), "utf8"))[1]}`,
)();
// [상세] 조각 모듈은 helper_209_1 과 같은 꼴로 딸린 모듈까지 진짜로 읽음(카드 공용만 흉내)
const DEEP_STUBS = {
"@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) },
"./B06_Section_UI_Section_Common": { CROSS_PAD },
};
const resolveTs = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
function loadDeep(full) {
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (DEEP_STUBS[request]) return DEEP_STUBS[request];
if (request.endsWith(".css")) return {};
if (request.startsWith("@util/"))
return loadDeep(resolveTs(path.join(ROOT, "common_util", request.slice(6))));
return loadDeep(resolveTs(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const DTS = loadDeep(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Select.ts"));
const out = {};
const PID = "p233b";
const sel = (chainageM, structureId = null, options = {}) =>
DSel.selectDesign(
PID,
chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId },
options,
);
/* ── ① 3D ── */
const bounds = { x: [0, 200], y: [0, 200], z: [0, 10] };
const scene = new THREE.Scene();
const markers = MK3.createRouteMarkers(scene, () => bounds);
const STATIONS = [
...[100, 120].map((at) => ({ station_id: `s${at}`, chainage_m: at, center_x: at })),
{ station_id: "irregular:sign", chainage_m: 127.86, center_x: 127.86 },
].map((s) => ({ ...s, center_y: 100, center_z: 5, frame: { left_xy: [0, 1] }, kind: "regular" }));
markers.renderStationLines(STATIONS, 3);
let notified = 0;
markers.onStationSelectionChange(() => (notified += 1));
const corridor = new THREE.Group();
const member = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 4), new THREE.MeshLambertMaterial());
member.userData = { chainageM: 160, kind: "pipe", key: "inlet" };
const wall = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 4), new THREE.MeshLambertMaterial());
wall.userData = { chainageM: 120, kind: "wall", key: "own" };
corridor.add(member, wall);
const structurePick = SPK.bindStructurePick({
canvas: { addEventListener() {}, removeEventListener() {} },
camera: () => null,
group: () => corridor,
blocked: () => false,
});
const profile = { station: null, structure: null };
let b05Visible = true;
const disposers = [];
PP.bindProfilePick(
PID,
{
profile: {
setSelectedStation: (id) => Object.assign(profile, { station: id, structure: null }),
setSelectedStructure: (id) => Object.assign(profile, { station: null, structure: id }),
},
viewer: { markers, structurePick },
},
{ visible: () => b05Visible, onDispose: (fn) => disposers.push(fn) },
{
irregular: () => [{ id: "sign", chainage_m: 127.86, origin: "user" }],
pipes: () => [],
sections: () => STATIONS.filter((s) => s.station_id.startsWith("s")),
},
);
const bar = (id) => markers.group.children[1].children.find((o) => o.userData.stationId === id);
const pin = () => markers.group.children.find((g) => g.renderOrder === 9);
const lit = (mesh) => mesh.material.emissive.getHex() !== 0;
const view3d = () => ({
red: STATIONS.map((s) => s.station_id).filter(
(id) => bar(id).material.color.getHexString() === "ef4444",
),
pin: pin().visible,
member: lit(member),
wall: lit(wall),
profile: { ...profile },
});
sel(120, null, { from: "b06" }); // 횡단 카드 120 고름 → 종단 보기도 값을 받음
out.card120 = view3d();
sel(160, null, { partKey: "inlet", from: "b06" });
out.part160 = view3d();
sel(127.86, "sign", { from: "b05" }); // 목록 표지 — 그 자리 구조물 측점 띠
out.sign = view3d();
sel(null);
out.released3d = view3d();
out.notified = notified; // 칠하기만 — 마커 알림으로 사슬이 다시 돌지 않음
disposers.forEach((fn) => fn());
/* ── ② 횡단 카드 · 범위 · 형상 ── */
const calls = [];
const sections = [100, 120].map((at) => ({ station_id: `s${at}`, chainage_m: at }));
const revetLit = new Set([120]);
let cardSelected = "s120";
let crossVisible = true;
let releaseCross;
const deps = {
sections: () => sections,
controls: {
revetOffset: {
highlightFor: (s) => revetLit.has(s.chainage_m),
selectedFor: (s) => revetLit.has(s.chainage_m),
select: (at) => revetLit.delete(at),
},
ford: { selectedFor: () => false, select() {} },
box: { selectedFor: () => false, select() {} },
},
releaseForm: () => calls.push("releaseForm"),
view: {
focusStation: (id) => {
calls.push(`focus:${id}`);
if (id === null && cardSelected !== null) {
cardSelected = null;
releaseCross(); // 카드 리스너(null) → 풀기 한 길 다시 부름 — 되돌아온 부름은 건너뜀
}
},
refreshCards: (list) => calls.push(`refresh:${list.join(",")}`),
selectMark: (id) => calls.push(`mark:${id}`),
},
reapply: () => calls.push("reapply"),
};
const crossDisposers = [];
const crossPick = SYNC.bindCrossPick(
PID,
{ visible: () => crossVisible, onDispose: (fn) => crossDisposers.push(fn) },
deps,
);
releaseCross = () => {
crossPick.release();
sel(null, null, { from: "b06" });
};
sel(120, null, { from: "b06" });
out.ownPick = calls.splice(0);
sel(140, null, { from: "esc" });
out.otherPick = calls.splice(0);
releaseCross(); // 카드 다시 누름 · 목록 다시 누름 · 보기 오감
out.release = calls.splice(0);
out.revetAfter = [...revetLit];
revetLit.add(100);
cardSelected = "s100";
sel(null, null, { from: "esc" }); // 다른 곳 풀기(Esc)
out.otherRelease = calls.splice(0);
crossVisible = false;
sel(100, null, { from: "esc" });
out.hidden = calls.splice(0);
crossDisposers.forEach((fn) => fn());
/* ── ③ [상세] 선 조각 — 다시 그린 뒤 고름 유지 ── */
let axes = {
maxOffset: 10,
pixelsPerMeter: 10,
displayMax: 110,
elevationMid: 100,
exaggeration: 1,
};
const groundM = [
[
[-5, 100],
[0, 101],
],
[
[0, 101],
[5, 99],
],
];
const toPx = ([o, e]) =>
[
CROSS_PAD.left + (axes.maxOffset - o) * axes.pixelsPerMeter,
CROSS_PAD.top + (axes.displayMax - e) * axes.pixelsPerMeter,
].join(",");
const layerChildren = [];
const layer = {
querySelectorAll: () => [...layerChildren],
append: (hit) => layerChildren.push(hit),
};
const drawGround = () => {
layerChildren.length = 0;
for (const pts of groundM) {
const el = node("polyline");
el.tagName = "polyline";
el.setAttribute("class", "b06-chart__cross-profile");
el.setAttribute("points", pts.map(toPx).join(" "));
el.hasAttribute = () => false;
layerChildren.push(el);
}
};
let click = null;
const cad = {
addEventListener: (_type, fn) => (click = fn),
querySelector: (s) => (s === "g.b06-chart__plot-layer" ? layer : null),
};
const select = DTS.attachDetailSelect({
cad,
design: () => undefined,
axes: () => axes,
picked() {},
commit() {},
});
drawGround();
select.redraw();
const hit = layerChildren.find((el) => el.classes.has(DTS.PIECE_HIT_CLASS));
click({ target: hit, shiftKey: false, ctrlKey: false, metaKey: false, stopPropagation() {} });
out.detailPicked = select.source("length")?.pieces.length ?? 0;
axes = { ...axes, pixelsPerMeter: 23 }; // 창 크기 바뀜 → 다시 그림
drawGround();
select.redraw();
out.detailAfterRedraw = select.source("length")?.pieces.length ?? 0;
groundM.splice(0, 1, [
[-6, 100],
[0, 102],
]); // 그 조각 자리가 바뀌면 고름도 빠짐
drawGround();
select.redraw();
out.detailAfterChange = select.source("length")?.pieces.length ?? 0;
process.stdout.write(JSON.stringify(out));
+171
View File
@@ -0,0 +1,171 @@
/* 233-3 풀기 한 길(Esc) · 배수유역도 다시 얹음 — 진짜 `design_pick_escape` · `design_pick_profile` ·
* `B06_Section_UI_Page_Pick_Sync` · `design_selection` 을 Node 로 돌림. 창 키 · 폼 · 지도 · 카드는 흉내.
* 표준출력 JSON 한 벌 · 짝 = test_233_3_release.py.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
function loadTs(file, stubs = {}) {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (stubs[request]) return stubs[request];
throw new Error(`stub 없음: ${request}`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
/* 창 키 듣기 · 창(모달) 흉내 */
const keyListeners = new Set();
globalThis.window = {
addEventListener: (type, fn) => type === "keydown" && keyListeners.add(fn),
removeEventListener: (type, fn) => keyListeners.delete(fn),
};
let modalOpen = false;
globalThis.document = { querySelector: () => (modalOpen ? {} : null) };
const press = (key, { inInput = false, prevented = false } = {}) => {
const event = {
key,
defaultPrevented: prevented,
target: { closest: () => (inInput ? {} : null) },
};
for (const fn of [...keyListeners]) fn(event);
};
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
},
});
const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel });
const IRR = {
irregularStationId: (id) => `irregular:${id}`,
isPipeStation: (s) => s.origin === "pipe",
};
const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", {
"../A00_Common/design/design_selection": DSel,
"./B05_Profile_UI_IrregularStations": IRR,
});
const PP = loadTs("A00_Common/design/design_pick_profile.ts", {
"./design_selection": DSel,
"./design_pick_escape": ESC,
"../../B05_Profile/B05_Profile_UI_Selection": SY,
"../../B05_Profile/B05_Profile_UI_IrregularStations": IRR,
});
const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", {
"../A00_Common/design/design_selection": DSel,
"../A00_Common/design/design_pick_escape": ESC,
});
const PID = "p233c";
const sel = (chainageM, structureId = null, from = "b05") =>
DSel.selectDesign(PID, chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId }, {
from,
});
/* ── 종단 보기(B05) — 종단도 · 배수유역도 · 좌측 폼 흉내 ── */
const map = { pipe: null, mark: null, notifies: 0 };
const profile = { station: null, structure: null };
const form = { open: "wall", resets: 0 };
let b05Visible = true;
const b05 = PP.bindProfilePick(
PID,
{
profile: {
setSelectedStation: (id) => Object.assign(profile, { station: id, structure: null }),
setSelectedStructure: (id) => Object.assign(profile, { station: null, structure: id }),
drainage: {
// 진짜 판 꼴 — 0.51 m 안 관을 고름 · notify 거짓이면 밖으로 안 알림
selectPipeAtChainage: (at, notify = true) => {
map.pipe = at === null ? null : Math.abs(at - 80.17) < 0.51 ? 80.17 : null;
if (notify) map.notifies += 1;
},
markStation: (at) => (map.mark = at),
},
},
form: {
hasSelection: () => form.open !== null,
selectById: () => {
form.open = null;
form.resets += 1;
},
},
},
{ visible: () => b05Visible, onDispose() {} },
{
irregular: () => [{ id: "p80", chainage_m: 80.17, origin: "pipe" }],
pipes: () => [{ chainage_m: 80.17 }],
sections: () => [80, 100, 120].map((at) => ({ station_id: `s${at}`, chainage_m: at })),
},
);
/* ── 횡단 보기(B06) — 카드 · 형상 · 폼 흉내 ── */
const cross = [];
let b06Visible = false;
SYNC.bindCrossPick(
PID,
{ visible: () => b06Visible, onDispose() {} },
{
sections: () => [],
controls: {
revetOffset: { highlightFor: () => false, selectedFor: () => false, select() {} },
ford: { selectedFor: () => false, select() {} },
box: { selectedFor: () => false, select() {} },
},
releaseForm: () => cross.push("releaseForm"),
view: {
focusStation: (id) => cross.push(`focus:${id}`),
refreshCards: () => undefined,
selectMark: (id) => cross.push(`mark:${id}`),
},
reapply: () => cross.push("reapply"),
},
);
const snap = () => ({
session: DSel.readSelection(PID),
profile: { ...profile },
map: { ...map },
form: { ...form },
});
const out = {};
sel(80.17);
out.pipePick = snap();
// 배수유역도 재계산이 관 · 유역 고름을 지움 → 같은 값으로 다시 얹음(알림 없이)
map.pipe = null;
map.mark = null;
b05.paint();
out.afterRecompute = snap();
press("Escape", { inInput: true });
out.escInInput = snap();
modalOpen = true;
press("Escape");
modalOpen = false;
out.escModal = snap();
press("Escape", { prevented: true });
out.escPrevented = snap();
press("Escape");
out.esc = snap();
out.crossWhileHidden = cross.splice(0);
// 목록 다시 누름 같은 B05 풀기(값 비움)는 폼을 그 길이 이미 닫음 — 구독은 폼을 또 닫지 않음
form.open = "sign";
sel(100, "sign");
sel(null);
out.listRelease = snap();
// 횡단 보기가 보일 때 Esc = 카드 · 폼 · 범위 · 형상 모두 풂
b05Visible = false;
b06Visible = true;
sel(120, null, "b06");
press("Escape");
out.crossEsc = cross.splice(0);
out.crossSession = DSel.readSelection(PID);
out.listeners = keyListeners.size;
process.stdout.write(JSON.stringify(out));
+2 -2
View File
@@ -80,13 +80,13 @@ def test_숨은_보기는_안_그리고_보이면_다시_얹음(out) -> None:
def test_풂과_구독_끊음(out) -> None:
assert out["released"] == line(None)
assert out["disposers"] == 1
assert out["disposers"] == 2 # 구독 + Esc 듣기(233-3)
assert out["paintsAfterDispose"] == 0
def test_종단_보기_배선() -> None:
view = _read("A00_Common", "design", "design_view_profile.ts")
assert "bindProfilePick(activeProjectId, profilePanel, host," in view
assert "bindProfilePick(" in view and "{ profile: profilePanel, viewer," in view
# 폼이 닫히는 길(저장 뒤 · 관 폼으로)은 종단을 끄지 않고 값대로 다시 얹음
assert "profilePanel.setSelectedStructure(structure?.structure_id ?? null)" not in view
assert view.count("profilePick.paint()") >= 3
+103
View File
@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
"""233-2 3D(띠 · 핀 · 부재) · 횡단 카드(고름 · --span · 형상 · 포장층) · [상세] 선 조각 = 고름 한 칸에서 파생.
까닭: 3D 는 화면끼리 손으로 부르는 사슬로만 칠해져 길마다 빠짐(구조물 고름 뒤 옛 띠 남음 · 횡단 카드
고름은 부재를 안 켬) · 횡단 카드를 풀어도 `--span` · 포장층이 남음(a4) · 풀기 길마다 끄는 범위가 다름 ·
[상세] 선 조각은 다시 그릴 때(창 크기 · 카드 다시 세움) 고름을 지워 바로 꺼짐.
고침: 종단 보기 구독이 3D 띠 · 핀 · 부재까지 값으로 칠함(마커 알림 없이) · 횡단 보기 구독(`bindCrossPick`)은
다른 곳이 바꾼 값을 넘김 적용 길로 다시 세우고 · 풀기는 한 길(`releaseCrossHighlights` — 카드 · 폼 관 ·
범위 테두리 · 포장층 · 형상) · [상세] 다시 그림은 같은 자리 조각 고름 유지.
진짜 TS 를 `helper_233_2_view_pick.cjs` 가 Node(three 포함)로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
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() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_233_2_view_pick.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_3D_횡단_카드_고름은_띠_핀_그_측점_부재(out) -> None:
card = out["card120"]
assert (card["red"], card["pin"], card["wall"], card["member"]) == (["s120"], True, True, False)
assert card["profile"]["station"] == "s120"
def test_3D_부재키_고름은_그_부재만_띠는_없음(out) -> None:
part = out["part160"]
assert (part["red"], part["pin"], part["member"], part["wall"]) == ([], False, True, False)
def test_3D_구조물_고름은_그_자리_구조물_측점_띠(out) -> None:
assert out["sign"]["red"] == ["irregular:sign"]
assert out["sign"]["profile"]["structure"] == "sign"
def test_3D_풂과_알림_없음(out) -> None:
released = out["released3d"]
assert (released["red"], released["pin"], released["member"], released["wall"]) == (
[],
False,
False,
False,
)
assert out["notified"] == 0 # 칠하기만 — 마커 알림이 사슬을 다시 돌리지 않음
def test_횡단_제_누름은_되받지_않고_다른_곳_값은_다시_세움(out) -> None:
assert out["ownPick"] == []
assert out["otherPick"] == ["reapply"]
assert out["hidden"] == [] # 숨은 보기는 안 받음
def test_횡단_풀기_한_길은_범위_테두리_형상까지(out) -> None:
for key in ("release", "otherRelease"):
assert out[key] == ["releaseForm", "focus:null", "mark:null", out[key][-1]]
assert out["release"][-1] == "refresh:120"
assert out["revetAfter"] == []
def test_상세_다시_그려도_같은_자리_조각_고름_유지(out) -> None:
assert out["detailPicked"] == 1
assert out["detailAfterRedraw"] == 1
assert out["detailAfterChange"] == 0
def test_배선() -> None:
cross = _read("A00_Common", "design", "design_view_cross.ts")
assert "bindCrossPick(projectId, host," in cross
assert "crossPick.release()" in cross
# 카드 다시 누름 = 풀기 한 길
assert "releaseCross(); // 카드 다시 누름" in cross
assert len(cross.splitlines()) <= 685
profile = _read("A00_Common", "design", "design_view_profile.ts")
assert "{ profile: profilePanel, viewer," in profile
assert len(profile.splitlines()) <= 700
+108
View File
@@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
"""233-3 좌측 목록 · 폼 · 배수유역도 · 남은 바로 꺼짐 자리 · 풀기 = Esc 한 길(조사 AF (나) ⑤ · a3 · a5 · a6).
까닭: 배수유역도 재계산이 관 마커 · 유역 고름을 지움(지도만 꺼짐) · 지도 · 유역 고름은 고름 한 칸에 안 적혀
마름모가 안 옮겨짐 · 목록 관 줄은 폭 0.05 · 관이 조금 옮겨지면 줄 강조 빠짐 · 목록 관 고름은 화면마다 손으로
불러 길마다 빠짐 · 풀기가 화면마다 다름(어디까지 푸는지 제각각).
고침: 종단 보기 구독이 배수유역도(관 마커 · 유역 · 마름모)까지 값으로 칠함(알림 없이 · 재계산 뒤 다시 얹음) ·
지도 고름 · 목록 관 고름 · 목록 다시 누름도 고름 한 칸에 적고 칠하기는 구독 몫 · 목록 줄 폭 = `PIPE_MATCH_M` ·
옮겨진 관도 고름 따라감 · Esc = 고름 한 칸 비움 → 종단 · 3D · 지도 · 폼 · 횡단 카드 · 범위 · 형상 모두 풂
(입력 칸 · 창 · 먼저 받은 Esc 는 그쪽 몫).
진짜 TS 를 `helper_233_3_release.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
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() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_233_3_release.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_관_고름은_지도_관_마커_유역_마름모까지_알림_없이(out) -> None:
assert out["pipePick"]["map"] == {"pipe": 80.17, "mark": 80.17, "notifies": 0}
assert out["pipePick"]["profile"]["station"] == "irregular:p80"
def test_재계산이_지운_지도_고름을_같은_값으로_다시_얹음(out) -> None:
assert out["afterRecompute"]["map"] == {"pipe": 80.17, "mark": 80.17, "notifies": 0}
def test_입력칸_창_먼저_받은_Esc는_안_풂(out) -> None:
for key in ("escInInput", "escModal", "escPrevented"):
assert out[key]["session"] == {"chainageM": 80.17, "structureId": None}, key
assert out[key]["form"]["resets"] == 0, key
def test_Esc는_값_종단_지도_폼_모두_풂(out) -> None:
esc = out["esc"]
assert esc["session"] is None
assert esc["profile"] == {"station": None, "structure": None}
assert (esc["map"]["pipe"], esc["map"]["mark"]) == (None, None)
assert esc["form"] == {"open": None, "resets": 1}
assert out["crossWhileHidden"] == [] # 숨은 횡단 보기는 안 받음
def test_B05_목록_풀기는_그_길이_폼을_닫고_구독은_폼을_또_안_닫음(out) -> None:
release = out["listRelease"]
assert release["session"] is None
assert release["profile"] == {"station": None, "structure": None}
assert release["form"]["resets"] == 1 # Esc 때 한 번뿐
def test_횡단_보기_Esc는_카드_폼_범위_형상_모두(out) -> None:
assert out["crossEsc"] == ["releaseForm", "focus:null", "mark:null"]
assert out["crossSession"] is None
assert out["listeners"] == 2 # 보기마다 하나 — 보이는 보기만 받음
def test_배선() -> None:
view = _read("A00_Common", "design", "design_view_profile.ts")
assert "onBasinSelected: (chainageM) => pickFromMap(chainageM)" in view
assert "onPipeSelected: (chainageM) => pickFromMap(chainageM)" in view
assert "if (!selectionSyncing) rememberPick(chainageM, null);" in view
assert "onListRelease: () => rememberPick(null, null)" in view
assert "form: panel.structures" in view
assert len(view.splitlines()) <= 700
panel = _read("B05_Profile", "B05_Profile_UI_Panel.ts")
assert "onListRelease: callbacks.onListRelease" in panel
drainage = _read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts")
assert "if (notify) callbacks.onPipeSelected?.(pickedChainage);" in drainage
assert "selectBasin(basinIndexOfPipe(basins, picked ?? null), notify);" in drainage
listing = _read("B05_Profile", "B05_Profile_UI_Structures_List.ts")
assert "< PIPE_MATCH_M" in listing and "< 0.05" not in listing
structures = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts")
assert "selectedPipeChainage = kept.chainage_m;" in structures
# 저장 · 새로 읽기 뒤 목록 주입이 관 폼을 닫지 않음(닫히며 종단 선까지 꺼졌음)
assert "if (!applyingLive && selectedPipeChainage === null)" in structures
assert len(structures.splitlines()) <= 700
cross = _read("A00_Common", "design", "design_view_cross.ts")
assert "releaseForm: () => structuresPanel.showAtChainage(null)" in cross
assert len(cross.splitlines()) <= 685