Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_7' into sub_laptop_1

This commit is contained in:
2026-10-18 18:27:28 +09:00
11 changed files with 273 additions and 15 deletions
+2 -1
View File
@@ -166,7 +166,8 @@ export function pipelineCrossSource(
stored?.find((row) => Math.abs(row.chainage_m - chainageM) < SAME_M)?.design;
const put = (key: string, section: CrossSection, at = section.chainage_m): void => {
byKey[key] = carried(section, storedAt(at));
const own: Record<string, unknown> = {};
// 카드 번호 = 저장 카드 번호(같은 측점 열쇠 · 옮긴 관도 · 찾기 · 고름이 같은 id · 440)
const own: Record<string, unknown> = { station_id: section.station_id };
for (const name of SPEC_KEYS) if (section[name] !== undefined) own[name] = section[name];
specs.set(key, own as SectionSpecs);
};
+17 -7
View File
@@ -103,6 +103,8 @@ export async function mountCrossView(
const projectId = shared.projectId || null;
let currentRouteId: number | null = null;
let sectionDetail: SectionDetailResponse | null = null;
/** 지금 그린 상세(노선 초안 동안 = 묶음 새 노선 상세) — 카드 찾기 · 고름 · 넘김 · 풀기가 이것으로(440). */
let drawnDetail: SectionDetailResponse | null = null;
let stationInterval: number | undefined;
// 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다.
@@ -115,7 +117,7 @@ export async function mountCrossView(
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */
const focusStationAt = (chainageM: number, revealOnly = false): void => {
const sections = sectionDetail?.cross_sections;
const sections = drawnDetail?.cross_sections;
if (!sections?.length) return;
const nearest = sections.reduce((best, entry) =>
Math.abs(entry.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM) ? entry : best,
@@ -379,7 +381,7 @@ export async function mountCrossView(
sectionView.setPavementSelectListener((id) => structuresPanel.selectStructure(id));
sectionView.setStationSelectListener((stationId) => {
const target = stationId
? sectionDetail?.cross_sections.find((section) => section.station_id === stationId)
? drawnDetail?.cross_sections.find((section) => section.station_id === stationId)
: null;
if (!target) {
structuresPanel.showAtChainage(null);
@@ -458,7 +460,7 @@ export async function mountCrossView(
* 적용한다 — 재렌더마다 다시 돌면 사용자가 옮긴 스크롤이 튄다(2026-09-06). */
let appliedPick: string | null = null;
function restoreStructurePick(): void {
if (!sectionDetail) return;
if (!drawnDetail) return;
const handoff = readStructurePick(projectId);
const signature = handoff
? `${handoff.at}|${handoff.key ?? ""}|${handoff.structureId ?? ""}`
@@ -468,7 +470,7 @@ export async function mountCrossView(
// `load().then(restoreStructurePick)` 가 다시 부른다.
const applied = applyStructurePick(
handoff,
sectionDetail,
drawnDetail,
{
// 넘겨받은 구조물 id 가 먼저(52-15) · 폼은 id 로 직접(53-1) · 측점 없는 구조물은 카드 · 스크롤 없음(307)
focusStation: (stationId) =>
@@ -487,7 +489,7 @@ export async function mountCrossView(
// 횡단 보기 강조 = 고름 한 칸 구독 · 다른 곳(Esc 등)이 바꾼 값으로 다시 세움(233-2)
const crossPick = bindCrossPick(projectId, host, {
sections: () => sectionDetail?.cross_sections ?? [],
sections: () => drawnDetail?.cross_sections ?? [],
controls: stationControls,
releaseForm: () => structuresPanel.showAtChainage(null),
view: sectionView,
@@ -508,8 +510,9 @@ export async function mountCrossView(
if (!sectionDetail) return;
showSectionView();
// 높이 배율은 항상 1(2026-08-06 사용자 지시 — 옵션 폐지).
drawnDetail = shownDetail(projectId, sectionDetail); // 노선 초안 동안 = 새 노선 횡단(346-2 덧 3)
sectionView.render(
shownDetail(projectId, sectionDetail), // 노선 초안 동안 = 새 노선 횡단(346-2 덧 3)
drawnDetail,
1,
crossHalfWidth(),
stationInterval,
@@ -564,7 +567,14 @@ export async function mountCrossView(
const reloadPanel = (): void => void structuresPanel.load().then(restoreStructurePick);
host.onDispose(onStaleStructureTypes(() => host.visible() && reloadPanel()));
// 바뀜 신호 하나(323-6 · `watchDrafts`) — 초안이 어느 길로 바뀌든 보일 때 카드 · 그림 다시(옛 되부름 갈음)
host.onDispose(watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures()));
// 목록도 — 관 · 구조물 자리가 바뀌었으면(노선 초안 따라감 · 평면 마커 옮김) 다시 실음(440)
host.onDispose(
watchDrafts(projectId, () => {
if (!host.visible()) return;
if (structuresPanel.listStale()) reloadPanel();
void refreshDetailForStructures();
}),
);
host.onDispose(followSwaps(projectId, () => currentRouteId, host, refreshDetailForStructures));
host.onDispose(() => {
window.clearTimeout(pipeReloadTimer);
@@ -266,7 +266,7 @@ export interface StructureMarkInput {
release: () => boolean;
/** 마커 위인가 — 커서 몫. */
over: (px: number, py: number) => boolean;
/** 고른 마커가 있으면 풂 · true(409 — 마커 아닌 곳 누름 · Esc). */
/** 이 평면 마커가 고른 것이면 풂 · true(409 — 마커 아닌 곳 누름 · 다른 화면이 고른 것은 그대로 · 440). */
deselect: () => boolean;
}
@@ -277,6 +277,8 @@ export function bindStructureMarks(
): StructureMarkInput {
const { canvas, status } = params;
const projectId = draftProjectId();
/** 이 평면 마커 누름이 고른 것(빈 곳 누름이 풀 것 · 440). */
let ownPick: string | null = null;
const list = redraws.get(projectId) ?? new Set<() => void>();
redraws.set(projectId, list);
const redraw = (): void => void (canvas.isConnected ? params.draw() : list.delete(redraw));
@@ -335,6 +337,7 @@ export function bindStructureMarks(
const deselect = (): boolean => {
if (!planMarks(canvas)?.picked) return false;
selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM });
ownPick = null;
params.draw();
return true;
};
@@ -402,6 +405,7 @@ export function bindStructureMarks(
if (!mark) return false;
release();
planMarks(canvas)?.source.select(mark.id); // 알약 고름과 같은 길(공용 고름)
ownPick = mark.id;
start(mark);
params.draw();
return true;
@@ -422,6 +426,7 @@ export function bindStructureMarks(
},
release: () => !holdKey && finish(),
over: (px, py) => markAt(px, py) !== null,
deselect,
// 빈 곳 누름 = 이 마커가 고른 것만 풂 — B06 카드 · 목록 · 알약이 고른 것은 그대로(440) · Esc 는 앞 그대로
deselect: () => planMarks(canvas)?.picked === ownPick && deselect(),
};
}
@@ -28,10 +28,12 @@ import { toDesignProfile } from "./B05_Profile_UI_Profile_Data";
import { stationLabel } from "../../common_util/common_util_station";
import { stationIdOf } from "./B05_Profile_Pipeline_Grade";
/** 측점에 얹힌 시설 제원(저장 상세에서 측점 열쇠로 옮김) — 낡음 판정 · 그림이 읽음. */
/** 측점에 얹힌 시설 제원(저장 상세에서 측점 열쇠로 옮김) — 낡음 판정 · 그림이 읽음 · 카드 번호(`station_id` ·
* 있으면 저장 카드 번호를 이어 씀 · 440). */
export type SectionSpecs = Partial<
Pick<
CrossSection,
| "station_id"
| "structure"
| "culvert"
| "ford"
@@ -117,6 +117,8 @@ export interface B06StructuresPanel {
listRoot: HTMLElement;
/** 타입 레지스트리·구조물 정본(미저장분 우선)·관 지점을 받아 채운다. */
load: () => Promise<void>;
/** 가게(초안 · 없으면 저장분) 관 · 구조물 자리가 목록과 다른가 — 다시 실을 때(노선 초안 따라감 · 평면 마커 옮김 · 440). */
listStale: () => boolean;
/** 기하가 한계로 자른 실제 값을 폼에 되돌린다 — 입력 숫자만 커지고 그림은 그대로인
* 상태를 막는다(2026-08-29 사용자: 재질 높이 한계에 걸린 값). */
overrideOptions: (chainageM: number, patch: Record<string, string | number>) => void;
@@ -162,9 +164,19 @@ export interface B06StructuresPanel {
};
}
type PlaceOf = { chainage_m?: number | null; start_m?: number | null; end_m?: number | null };
/** 목록 자리 지문 — 누가거리 · 구간만(옵션 고침은 안 셈 · 440). */
const placeKey = (list: ReadonlyArray<PlaceOf>): string =>
JSON.stringify(
list.map((entry) => [entry.chainage_m ?? null, entry.start_m ?? null, entry.end_m ?? null]),
);
export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06StructuresPanel {
let structures: StructureInstance[] = [];
let pipeFacilities: PipeFacilityItem[] = [];
/** 마지막으로 실은 저장분(초안이 비면 목록이 견줄 자리 · 440). */
let storedStructures: StructureInstance[] = [];
let storedPipes: ReadonlyArray<PlaceOf> = [];
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리 — 유입측 형식 반영 대상. */
let currentChainageM: number | null = null;
@@ -385,6 +397,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
section.setTypes(types, listPick("b06")); // 표 목록 both + b06(322-1)
markTypes = types;
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙).
storedStructures = stored.structures;
storedPipes = pipeResponse.pipe_points;
structures = readPendingStructures(projectId) ?? stored.structures;
const note = staleDefinitionNotice(projectId, stored.definition_version); // 322-2
if (note) showToast(note);
@@ -558,6 +572,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
root: section.root,
listRoot: section.listRoot,
load,
listStale: () => {
if (!deps.projectId) return false;
const shopStructures = readPendingStructures(deps.projectId) ?? storedStructures;
const shopPipes = readPendingPipes(deps.projectId) ?? storedPipes;
return (
placeKey(shopStructures) !== placeKey(structures) ||
placeKey(shopPipes) !== placeKey(pipeFacilities)
);
},
currentChainage: () => currentChainageM,
// 종단 우클릭 → 좌측 폼·목록과 같은 함수. 폼이 열리고 목록·알약도 함께 선다.
addStructureAt: (chainageM, typeId) => section.addAt(chainageM, typeId),
@@ -141,7 +141,11 @@ function loadSelection() {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const stubs = { "./B06_Section_UI_Adjust_Dock": { refreshAdjustSlots: () => {} } };
const stubs = {
"./B06_Section_UI_Adjust_Dock": { refreshAdjustSlots: () => {} },
"./B06_Section_UI_Cross_Design_Pick": { setBermPickHost: () => {} }, // 소단 고름 다리(440 덤)
"./B06_Section_UI_Page_Persist": { isBermType: () => false },
};
new Function("exports", "module", "require", js)(box.exports, box, (r) => stubs[r]);
return box.exports;
}
@@ -336,6 +336,8 @@ out.redrawnOnChange = redrawn - was;
// ⑥ 고름 풀기(409) — 고른 것 없으면 아무 일 없음 · 있으면 공용 고름을 b05-plan 으로 비움 · Esc 도
selection = null;
out.deselectNone = [input.deselect(), written.length];
input.press(80, 0); // 이 마커로 고름(440 — 빈 곳 누름이 풀 것)
input.release();
selection = { chainageM: 40, structureId: "A" };
out.deselectPicked = [input.deselect(), ...written.slice(-1), selection];
selection = { chainageM: 80.17, structureId: null }; // 관 고름(누가거리만)
@@ -345,10 +347,15 @@ out.escapePicked = [...written.slice(-1), selection, prevented];
const writes = written.length;
key("keydown", "Escape"); // 고른 것 없음 = 공용 Esc 몫(안 막음)
out.escapeNone = [written.length - writes, prevented];
// 440 — 다른 화면(B06 카드 · 목록 · 알약)이 고른 것 = 빈 곳 누름이 안 풂 · 이 마커로 고른 것만 풂
selection = { chainageM: 40, structureId: "A" };
const before440 = written.length;
out.deselectOther = [input.deselect(), written.length - before440, selection !== null];
// 마커 누름 = 고름(공용 고름 길) · 법선 · 창 — 그린 차례는 `_Render` 맨 끝(시험이 원본으로 봄)
input.press(80, 0);
input.release();
out.pressAfterRelease = selects[selects.length - 1];
out.deselectOwn = [input.deselect(), ...written.slice(-1), selection]; // 440 — 마커로 고른 것은 풂
// ⑦ 순수 셈
out.logic = {
@@ -0,0 +1,122 @@
// 440 — 노선 초안 동안 B06 카드(묶음 새 상세) 번호가 찾기 상세(갈아끼운 캐시)에 있음.
// 저장 상세 → cross 입력 모음(`pipelineCrossSource` · 진짜) → 새 상세(`draftSectionDetail` · 진짜) 대 갈아끼움(`swapCrossSections` · 진짜).
const fs = require("fs");
const path = require("path");
const ROOT = path.resolve(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const idOf = (m) => `station_${String(Math.round(m * 1000)).padStart(12, "0")}`;
const REAL = /(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross)$/;
const STUBS = {
B05_Profile_Pipeline_Grade: { stationIdOf: idOf },
common_util_station: { stationLabel: (m) => String(m) },
B05_Profile_UI_Profile_Data: { toDesignProfile: () => ({}) },
b_page_state: { readState: () => null },
B06_Section_Section_Canon: { savedDesigns: () => null },
B06_Section_UI_Standard_Panel: {
effectiveStandardCross: () => ({ road_width_m: 3 }),
readRockBoundaryDefault: () => null,
},
};
const cache = new Map();
function load(full) {
if (cache.has(full)) return cache.get(full).exports;
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);
const req = (r) => {
const name = r.split("/").pop();
if (STUBS[name]) return STUBS[name];
if (REAL.test(r)) return load(path.resolve(path.dirname(full), r + ".ts"));
throw new Error(`stub 없음: ${r}`);
};
new Function("exports", "module", "require", js)(box.exports, box, req);
return box.exports;
}
const detailMod = load(path.join(ROOT, "B05_Profile/logic/B05_Profile_Pipeline_Detail.ts"));
const swapMod = load(path.join(ROOT, "B06_Section/logic/B06_Section_Station_Swap.ts"));
const crossMod = load(path.join(ROOT, "A00_Common/design/design_pipeline_cross.ts"));
const sec = (m, kind, extra = {}) => ({
station_id: idOf(m),
chainage_m: m,
label: "",
kind,
center_x: m,
center_y: 0,
samples: [],
design: { design_elevation_m: 100 },
...extra,
});
// 저장 상세: 일반 340 · 관 351(배수관) · 일반 360
const saved = {
longitudinal: { stations: [], design_profiles: [], length_m: 400, samples: [] },
cross_sections: [
sec(340, "regular"),
sec(351, "irregular", { culvert: { chainage_m: 351, diameter_m: 0.8 } }),
sec(360, "regular"),
],
};
const st = (key, m, kind) => ({
key,
chainageM: m,
kind,
x: m + 0.5,
y: 3,
left: [0, 1],
azimuthDeg: 0,
ground: null,
});
const savedStations = [
st("grid:340", 340, "grid"),
st("pipe:p35100", 351, "pipe"),
st("grid:360", 360, "grid"),
];
// 노선 초안: 교점 끌기 → 관이 좌표 따라 354 로(410) · 일반 측점은 같은 m
const draftStations = [
st("grid:340", 340, "grid"),
st("pipe:p35100", 354, "pipe"),
st("grid:360", 360, "grid"),
];
const oldKeys = savedStations.map((s) => s.key);
const source = crossMod.pipelineCrossSource("p1", saved, savedStations, null);
const draft = detailMod.draftSectionDetail(
saved,
{ stations: draftStations, longitudinal: { chainageM: [0, 400], z: [0, 0] } },
{},
null,
source.specs,
20,
);
const swapped = swapMod.swapCrossSections(saved, draftStations, oldKeys, 20).detail;
const ids = (detail) => detail.cross_sections.map((s) => s.station_id);
const swappedIds = new Set(ids(swapped));
const pipeCard = draft.cross_sections[1];
// 새 측점(초안에서 처음 생긴 일반 측점) = 누가거리 번호
const added = detailMod.draftSectionDetail(
saved,
{
stations: [...draftStations, st("grid:380", 380, "grid")],
longitudinal: { chainageM: [0, 400], z: [0, 0] },
},
{},
null,
source.specs,
20,
);
console.log(
JSON.stringify({
draftIds: ids(draft),
allFoundInSwapped: ids(draft).every((id) => swappedIds.has(id)),
sameOrder: JSON.stringify(ids(draft)) === JSON.stringify(ids(swapped)),
pipeCard: [
pipeCard.station_id,
pipeCard.chainage_m,
pipeCard.culvert && pipeCard.culvert.chainage_m,
],
longIds: draft.longitudinal.stations.map((s) => s.station_id),
addedId: added.cross_sections[3].station_id,
}),
);
+2 -3
View File
@@ -151,9 +151,8 @@ def test_추가_뒤_새_측점_횡단_미리_뜸():
body = _body(B06, " onPipeAdd: (chainageM, attributes) => {", " onPipeUpdate:")
assert "persistPipes();" in body # 초안 씀 → 구독(`watchDrafts`)이 카드 다시(323-6)
# 카드 다시 = 바뀜 신호 하나(323-6 · 관 · 구조물 초안 구독 · 옛 되부름 갈음)
assert (
"watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures())" in CROSS
)
watch = CROSS[CROSS.index("watchDrafts(projectId, () => {") :]
assert "void refreshDetailForStructures();" in watch[: watch.index("}),")] # 목록도(440)
drafts = _read("B06_Section", "B06_Section_Section_Drafts.ts")
stations = _body(
drafts, "export async function withDraftStations(", "parked.set(detail, stale);"
@@ -52,6 +52,12 @@ def test_마커_누름은_그대로_고름(got) -> None:
assert got["pressAfterRelease"] == "A"
def test_빈_곳_누름은_마커가_고른_것만_풂(got) -> None:
# 440 — B06 카드 · 목록 · 알약이 고른 것은 평면 빈 곳 누름이 안 풂(B06 고름 그대로)
assert got["deselectOther"] == [False, 0, True]
assert got["deselectOwn"] == [True, [None, "b05-plan"], None]
def test_누름_마커_먼저_교각점_풂() -> None:
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
down = pointer[pointer.index("if (marks.press(px, py)) {") :]
@@ -0,0 +1,79 @@
"""440 구조물 횡단 · 내부 구조물 하이라이트(요구 267).
① 노선 초안 동안 B06 카드(묶음 새 상세) 번호 = 저장 카드 번호(같은 측점 열쇠 · 옮긴 관도) — 찾기 상세
(갈아끼운 캐시)에 모든 카드 번호가 있음 · 찾기 넷(카드 고름 · 목록 → 카드 · 넘김 · 풀기)은 지금 그린 상세.
② 평면 패널 빈 곳 누름 = 평면 마커가 고른 것만 풂(`test_409` 의 deselectOther · deselectOwn).
③ 바뀜 신호에서 B06 목록도 자리가 바뀌었으면 다시 실음.
TS 셈은 `helper_440_draft_card_ids.cjs`.
./venv/Scripts/python.exe -m pytest resources/tester/test_440_cross_draft_pick.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).with_name("helper_440_draft_card_ids.cjs")
SAVED_PIPE = "station_000000351000"
def _read(*parts: str) -> str:
return ROOT.joinpath(*parts).read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_초안_카드_번호가_찾기_상세에_모두_있음(got) -> None:
assert got["allFoundInSwapped"] is True
assert got["sameOrder"] is True
def test_옮긴_관_카드는_저장_번호_새_자리(got) -> None:
assert got["pipeCard"] == [SAVED_PIPE, 354, 354]
assert got["longIds"] == got["draftIds"] # 종단 측점 머리도 같은 번호
def test_초안_새_측점은_누가거리_번호(got) -> None:
assert got["addedId"] == "station_000000380000"
def test_찾기_넷은_지금_그린_상세() -> None:
cross = _read("A00_Common", "design", "design_view_cross.ts")
assert "drawnDetail = shownDetail(projectId, sectionDetail);" in cross
focus = cross[cross.index("const focusStationAt") : cross.index("const ownerAt")]
assert "drawnDetail?.cross_sections" in focus and "sectionDetail" not in focus
pick = cross[cross.index("sectionView.setStationSelectListener") :]
assert "drawnDetail?.cross_sections.find" in pick[: pick.index("if (!target)")]
restore = cross[cross.index("function restoreStructurePick") : cross.index("// 횡단 보기 강조")]
assert "sectionDetail" not in restore and "drawnDetail," in restore
assert "sections: () => drawnDetail?.cross_sections ?? []," in cross
def test_바뀜_신호에서_목록_다시_실음() -> None:
cross = _read("A00_Common", "design", "design_view_cross.ts")
watch = cross[cross.index("watchDrafts(projectId, () => {") :]
watch = watch[: watch.index("}),")]
assert "if (structuresPanel.listStale()) reloadPanel();" in watch
panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
assert "readPendingStructures(deps.projectId) ?? storedStructures" in panel
assert "readPendingPipes(deps.projectId) ?? storedPipes" in panel
def test_평면_빈_곳_누름은_마커_고름만() -> None:
marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts")
assert "deselect: () => planMarks(canvas)?.picked === ownPick && deselect()," in marks
assert "ownPick = mark.id;" in marks