From aa2c2a870f14026df23d93ec475fe764f0825df3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 18:55:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(design):=20=EA=B3=A0=EB=A6=84=20=ED=95=9C?= =?UTF-8?q?=20=EC=B9=B8=20=EA=B5=AC=EB=8F=85=20=C2=B7=20=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=20=EA=B0=95=EC=A1=B0=EB=A5=BC=20=EA=B7=B8=20=EA=B0=92=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=8C=8C=EC=83=9D=20(233-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_pick_profile — 고름 한 칸 구독 → 종단 측점선 · 알약 · 띠를 값으로 다시 세움 - 관 · 구조물 목록이 바뀐 뒤 · 폼이 닫힌 뒤(저장 뒤 관 폼 등)에도 같은 값으로 다시 얹음 - 관 알약 id 는 그릴 때마다 지금 관 목록에서 찾음(관이 옮겨져도 이어짐) - 숨은 보기는 안 그림 · 보기를 떠나면 구독 끊음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- A00_Common/design/design_pick_profile.ts | 88 +++++++++++++ A00_Common/design/design_view_profile.ts | 12 +- .../tester/helper_233_1_profile_pick.cjs | 117 ++++++++++++++++++ resources/tester/test_233_1_profile_pick.py | 93 ++++++++++++++ 4 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 A00_Common/design/design_pick_profile.ts create mode 100644 resources/tester/helper_233_1_profile_pick.cjs create mode 100644 resources/tester/test_233_1_profile_pick.py diff --git a/A00_Common/design/design_pick_profile.ts b/A00_Common/design/design_pick_profile.ts new file mode 100644 index 000000000..48c2bb1bb --- /dev/null +++ b/A00_Common/design/design_pick_profile.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * A00_Common/design/design_pick_profile.ts + * 종단도 고름 강조(측점 세로선 · 알약 · 구간 띠) = 고름 한 칸에서 파생 (233-1 · 조사 AF (나) ②). + * + * 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고, + * 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이 + * 하나 빠지거나 다른 길이 강조를 끄더라도 종단도는 값을 따라간다. 관 알약 id(`pipe-`+측점)는 + * 그릴 때마다 지금 관 목록에서 다시 찾으므로 관이 옮겨져도 끊기지 않는다. + * ========================================================================== */ + +import { + nearestWithin, + readSelection, + subscribeSelection, + type DesignSelection, +} from "./design_selection"; +import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection"; +import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; + +/** 종단도가 켤 것 — 측점선(알약은 그 측점 짝을 종단도가 맞춤) 또는 알약(구조물 · 관 `pipe-*`). */ +export type ProfilePick = { stationId: string | null } | { structureId: string }; + +export interface ProfilePickSource { + /** 구조물 측점(수동 + 관 투영) — id 는 `irregularStationId` 를 씌우기 전 값. */ + irregular: () => ReadonlyArray<{ id: string; chainage_m: number; origin?: string }>; + /** 관 지점 정본(배수유역도). */ + pipes: () => ReadonlyArray<{ chainage_m: number }>; + /** 일반 측점 카드. */ + sections: () => ReadonlyArray<{ station_id: string; chainage_m: number }>; +} + +export interface ProfilePickTarget { + setSelectedStation: (stationId: string | null) => void; + setSelectedStructure: (structureId: string | null) => void; +} + +/** 같은 값으로 적힌 측점인가 — 고름 한 칸은 고른 측점의 누가거리를 그대로 적음(찾는 폭 아님). */ +const sameAt = (a: number, b: number): boolean => Math.abs(a - b) < 1e-6; + +/** + * 고름 값 → 종단도가 켤 것(B05 규칙). ① 구조물 id = 그 알약(측점선은 제 측점이 있을 때만 · 189-1) + * ② 고른 측점 그대로(일반 카드 → 구조물 측점) ③ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1). + */ +export function profilePickOf( + selection: DesignSelection | null, + source: ProfilePickSource, +): ProfilePick { + if (!selection || selection.chainageM === null) return { stationId: null }; + if (selection.structureId) return { structureId: selection.structureId }; + const at = selection.chainageM; + const card = source.sections().find((entry) => sameAt(entry.chainage_m, at)); + if (card) return { stationId: card.station_id }; + const irregular = source.irregular(); + const own = + irregular.find((entry) => sameAt(entry.chainage_m, at)) ?? + nearestWithin( + irregular.filter((entry) => entry.origin === "pipe"), + at, + (entry) => entry.chainage_m, + ); + if (own) return { stationId: irregularStationId(own.id) }; + const target = graphTargetAt(null, at, source.pipes(), source.sections()); + return "pipeChainageM" in target + ? { structureId: `pipe-${target.pipeChainageM.toFixed(2)}` } + : target; +} + +export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): void { + if ("structureId" in pick) target.setSelectedStructure(pick.structureId); + else target.setSelectedStation(pick.stationId); +} + +/** + * 종단도를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 바뀐 뒤 같은 값으로 다시 얹음. + * 보기가 숨어 있으면(다른 보기가 종단도를 쥠) 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. + */ +export function bindProfilePick( + projectId: string | null, + target: ProfilePickTarget, + host: { visible: () => boolean; onDispose: (fn: () => void) => void }, + source: ProfilePickSource, +): { paint: () => void } { + const paint = (): void => { + if (host.visible()) paintProfilePick(target, profilePickOf(readSelection(projectId), source)); + }; + host.onDispose(subscribeSelection(projectId, paint)); + return { paint }; +} diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 013ccdf01..05821f65d 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -39,6 +39,7 @@ import { selectDesign, } from "./design_selection"; import type { DesignPageHost } from "./design_page"; +import { bindProfilePick } from "./design_pick_profile"; import { bindDesignView, designReady, @@ -247,6 +248,7 @@ export async function mountProfileView( /** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다 * (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */ function restorePick(): void { + profilePick.paint(); // 관 · 구조물 목록이 바뀐 뒤에도 종단은 고름 한 칸대로(233-1) if (panel.structures.hasSelection()) return; const pick = viewer.structurePick; restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe); @@ -346,7 +348,7 @@ export async function mountProfileView( if (selectionSyncing) return; selectionSyncing = true; try { - profilePanel.setSelectedStructure(structure?.structure_id ?? null); + profilePick.paint(); // 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단은 고름 한 칸대로(233-1) viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null); } finally { selectionSyncing = false; @@ -409,6 +411,12 @@ export async function mountProfileView( }, isRestoring: () => restoring, }); + // 종단도 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1) + const profilePick = bindProfilePick(activeProjectId, profilePanel, host, { + irregular: () => bridge.irregularStations(), + pipes: () => lastPipes ?? [], + sections: () => currentSectionDetail?.cross_sections ?? [], + }); // 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19). bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []); @@ -566,7 +574,7 @@ export async function mountProfileView( return; } viewer.markers.selectStation(null); - profilePanel.setSelectedStation(null); + profilePick.paint(); }, // 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. onHide: () => { diff --git a/resources/tester/helper_233_1_profile_pick.cjs b/resources/tester/helper_233_1_profile_pick.cjs new file mode 100644 index 000000000..a328856e2 --- /dev/null +++ b/resources/tester/helper_233_1_profile_pick.cjs @@ -0,0 +1,117 @@ +/* 233-1 종단도 강조 = 고름 한 칸에서 파생 — 진짜 `design_pick_profile` · `design_selection` · + * `graphTargetAt`(B05_Profile_UI_Selection) 를 Node 로 돌림. 종단도는 고른 측점 · 알약 두 칸만 흉내. + * 표준출력 JSON 한 벌 · 짝 = test_233_1_profile_pick.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 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 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, + "../../B05_Profile/B05_Profile_UI_Selection": SY, + "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, +}); + +const PID = "p233"; +const sections = [80, 100, 120, 140, 300].map((at) => ({ station_id: `s${at}`, chainage_m: at })); +let pipes = [{ chainage_m: 80.17 }, { chainage_m: 300.4 }]; +const irregular = [ + { id: "p80", chainage_m: 80.17, origin: "pipe" }, + { id: "sign", chainage_m: 127.86, origin: "user" }, +]; + +/* 종단도 흉내 — 고른 측점 · 알약 */ +const profile = { station: null, structure: null, paints: 0 }; +const target = { + setSelectedStation: (id) => { + profile.station = id; + profile.structure = null; + profile.paints += 1; + }, + setSelectedStructure: (id) => { + profile.structure = id; + profile.station = null; + profile.paints += 1; + }, +}; +let visible = true; +const disposers = []; +const pick = PP.bindProfilePick( + PID, + target, + { visible: () => visible, onDispose: (fn) => disposers.push(fn) }, + { irregular: () => irregular, pipes: () => pipes, sections: () => sections }, +); + +const snap = () => ({ station: profile.station, structure: profile.structure }); +const sel = (chainageM, structureId = null) => + DSel.selectDesign(PID, chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId }); + +const out = {}; +sel(120); +out.card = snap(); +sel(127.86); +out.irregularSign = snap(); +sel(80); +out.card80 = snap(); // 관 80.17 옆 카드 80 = 카드 선(관 아님) +sel(80.17); +out.pipe80 = snap(); +sel(300.2); +out.pipePill = snap(); +pipes = [{ chainage_m: 80.17 }, { chainage_m: 300.45 }]; // 관이 옮겨짐 +pick.paint(); +out.pipeMoved = snap(); +sel(100, "wall"); +out.structure = snap(); +// 다른 길이 강조를 끔(저장 뒤 관 폼 닫힘 · 목록 다시 세움) → 값은 그대로 → 다시 얹음 +target.setSelectedStructure(null); +out.clearedByOther = snap(); +pick.paint(); +out.repainted = snap(); +// 숨은 보기는 안 그림 · 보이면 다시 얹음 +visible = false; +sel(140); +out.hidden = snap(); +visible = true; +pick.paint(); +out.shownAgain = snap(); +// 풂(값 비움) = 선 · 알약 모두 끔 +sel(null); +out.released = snap(); +// 보기를 떠나면 구독 끊음 +disposers.forEach((fn) => fn()); +const before = profile.paints; +sel(120); +out.paintsAfterDispose = profile.paints - before; +out.disposers = disposers.length; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_233_1_profile_pick.py b/resources/tester/test_233_1_profile_pick.py new file mode 100644 index 000000000..3801a57e6 --- /dev/null +++ b/resources/tester/test_233_1_profile_pick.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +"""233-1 고름 한 칸 = 유일 원천 + 바뀜 알림 · 종단도(측점선 · 알약 · 띠)는 그 값으로 그림(조사 AF (나) ① ②). + +까닭: 알림 장치(`subscribeSelection`)는 있었으나 듣는 곳 0 — 화면끼리 손으로 부르는 사슬이라 한 길이 +강조를 끄면(저장 뒤 관 폼 닫힘 → 종단 선 · 알약 끔) 값은 그대로인데 종단만 꺼짐 · 관 알약 id(`pipe-`+측점)가 +옛 관 자리에 묶여 관이 옮겨지면 끊김. +고침: `design_pick_profile` — 종단 보기가 고름 한 칸을 구독해 값 → 종단 강조(`profilePickOf`) · +목록이 바뀐 뒤 · 폼이 닫힌 뒤에도 같은 값으로 다시 얹음(`paint`) · 숨은 보기는 안 그림 · 떠나면 구독 끊음. +진짜 TS 를 `helper_233_1_profile_pick.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_1_profile_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def line(station): + return {"station": station, "structure": None} + + +def pill(structure): + return {"station": None, "structure": structure} + + +def test_측점_값은_그_측점선(out) -> None: + assert out["card"] == line("s120") + assert out["irregularSign"] == line("irregular:sign") # 구조물 측점(관 아님)도 그대로 + assert out["card80"] == line("s80") # 관 80.17 옆 카드 80 은 카드 선 + assert out["pipe80"] == line("irregular:p80") + + +def test_측점선_없는_관은_지금_관_목록으로_알약(out) -> None: + assert out["pipePill"] == pill("pipe-300.40") + assert out["pipeMoved"] == pill("pipe-300.45") # 관이 옮겨져도 끊기지 않음 + + +def test_구조물_값은_그_알약(out) -> None: + assert out["structure"] == pill("wall") + + +def test_다른_길이_꺼도_값대로_다시_얹음(out) -> None: + assert out["clearedByOther"] == pill(None) + assert out["repainted"] == pill("wall") + + +def test_숨은_보기는_안_그리고_보이면_다시_얹음(out) -> None: + assert out["hidden"] == pill("wall") + assert out["shownAgain"] == line("s140") + + +def test_풂과_구독_끊음(out) -> None: + assert out["released"] == line(None) + assert out["disposers"] == 1 + assert out["paintsAfterDispose"] == 0 + + +def test_종단_보기_배선() -> None: + view = _read("A00_Common", "design", "design_view_profile.ts") + assert "bindProfilePick(activeProjectId, profilePanel, host," in view + # 폼이 닫히는 길(저장 뒤 · 관 폼으로)은 종단을 끄지 않고 값대로 다시 얹음 + assert "profilePanel.setSelectedStructure(structure?.structure_id ?? null)" not in view + assert view.count("profilePick.paint()") >= 3 + assert len(view.splitlines()) <= 700