diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index e0f8b98a1..375e616db 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -164,6 +164,18 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc markTypes.find((type) => type.type_id === entry.type_id)?.group !== "G"; /** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */ let preferredId: string | null = null; + /** 폼에 열린 구조물 — 범위 안 빈 카드를 눌러도 연 노면공을 지킴(165-1). */ + let openId: string | null = null; + /** 그 구조물로 갈 카드 측점 — 구간형은 범위 안 카드 중 기준점에 가까운 것(경계 기준점이 범위 밖 카드로 안 감 · 165-2). */ + const focusOf = (structure: StructureInstance): number => { + const anchor = structureAnchorM(structure); + const inside = (deps.detail?.()?.cross_sections ?? []) + .map((entry) => entry.chainage_m) + .filter((at) => coversChainage(structure, at)); + return inside.length + ? inside.reduce((best, at) => (Math.abs(at - anchor) < Math.abs(best - anchor) ? at : best)) + : anchor; + }; /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ const pushMarks = (): void => deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes); @@ -197,13 +209,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } }, onSelect: (structure) => { + openId = structure?.structure_id ?? null; if (!structure) { deps.markStructure?.(null); // 풂 = 알약도 끔(53-2) return; } + currentChainageM = null; // 폼 = 이 구조물 — 옛 관을 붙든 채 남지 않게(165-2) // 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다. preferredId = structure.structure_id ?? null; - deps.focusChainage(structureAnchorM(structure)); + deps.focusChainage(focusOf(structure)); preferredId = null; // 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2) deps.markStructure?.(structure.structure_id ?? null); @@ -534,14 +548,25 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc // 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에 // 그려지는 것은 이쪽이다(순서 · 거리는 `resolveSelection` 한 곳). const hit = resolveSelection(selection, pipeFacilities, structures, coverable); + // 범위 안 빈 카드(관 · 덮는 구조물 없음) = 연 노면공 그대로 · 카드만 옮김(165-1) + const open = structures.find((entry) => entry.structure_id === openId); + if ( + chainageM !== null && + hit.pipeChainageM === null && + hit.structureId === null && + open && + !coverable(open) && + coversChainage(open, chainageM) + ) + return { chainageM, structureId: openId }; + // 폼은 위에서 푼 값으로 엶 — 공용 `selectAt` 은 노면공을 덮어 엶(152-5) if (hit.pipeChainageM !== null) { currentChainageM = hit.pipeChainageM; refreshSpecDefaults(hit.pipeChainageM); - deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`); // 측점선 없는 물넘이포장도(53-2) - } - // 폼은 위에서 푼 값으로 엶 — 공용 `selectAt` 은 노면공을 덮어 엶(152-5) - if (hit.pipeChainageM !== null) section.selectPipeByChainage(hit.pipeChainageM); - else section.selectById(hit.structureId); + section.selectPipeByChainage(hit.pipeChainageM); + // 알약은 폼 뒤 — 폼이 연 구조물을 닫으며 알약을 끄므로(165-2 · 측점선 없는 물넘이포장도 53-2) + deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`); + } else section.selectById(hit.structureId); return { chainageM, structureId: hit.structureId }; }, }; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 386e79411..0d6a90505 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -245,12 +245,16 @@ export function createSectionView( const at = section.chainage_m; return at >= Math.min(start, end) - 0.01 && at <= Math.max(start, end) + 0.01; }; + const isPavement = (structureId: string | null): boolean => { + const mark = spanMarks.find((entry) => entry.structure_id === structureId); + return !!mark && spanTypes.find((type) => type.type_id === mark.type_id)?.group === "G"; + }; const applySpan = (card: HTMLElement, section: CrossSection): void => { const mark = spanMarkId ? spanMarks.find((entry) => entry.structure_id === spanMarkId) : undefined; const span = inSpan(section, mark); - const pave = span && spanTypes.find((type) => type.type_id === mark?.type_id)?.group === "G"; + const pave = span && isPavement(spanMarkId); card.classList.toggle("b06-cross-card--span", span && !pave); highlightPavement(card, pave ? mark : undefined); }; @@ -541,8 +545,8 @@ export function createSectionView( } }, selectMark: (structureId, span = true) => { - // span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너) - spanMarkId = span ? structureId : null; + // span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너) · 노면공 포장층은 테두리가 아니라 늘 켬(165-2) + spanMarkId = span || isPavement(structureId) ? structureId : null; syncSpanHighlights(); profile.selectMark(structureId); }, diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs new file mode 100644 index 000000000..b7d3a565e --- /dev/null +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -0,0 +1,336 @@ +/* 165 노면공 고름 일관성 헬퍼 — 진짜 `createB06StructuresPanel`(SPN)에 폼 · 횡단 카드 · 종단 알약을 흉내 내 붙이고 + * 조사 K 재현 차례 6단계를 돌려 단계마다 {폼 · 알약 · 포장층 · 고른 카드 · 세션 sid} 를 표준출력 JSON 한 벌로. + * 카드 고름 리스너는 design_view_cross.ts 의 `setStationSelectListener` 본문을, 카드 · 알약 흉내는 B06 Section_View · + * Section_View_Profile 의 `selectStation` · `focusStation` · `selectMark` · `showCard` 를 그대로 옮김(TS 는 그 자리 트랜스파일). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(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; +} + +// 세션(structure-pick) — 메모리 한 벌 +const store = new Map(); +const selection = loadTs(path.join(ROOT, "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 PID = "p165"; +const TYPES = [ + { type_id: "pavement_concrete", group: "G" }, + { type_id: "hazard_sign", group: "E" }, + { type_id: "pipe", group: "A", managed_by: "pipe_points" }, +]; +const PAVE = { + structure_id: "pave", + type_id: "pavement_concrete", + placement: "interval", + chainage_m: 30, + start_m: 30, + end_m: 250, +}; +const PAVE2 = { + structure_id: "pave2", + type_id: "pavement_concrete", + placement: "interval", + chainage_m: 100, + start_m: 100, + end_m: 200, +}; +const PIPES = [{ chainage_m: 80.17, facility: "pipe", source: "spacing", options: {} }]; +// 카드 = 20 m 측점 + 관 측점(비정규) +const CARDS = [...Array.from({ length: 16 }, (_, i) => i * 20), 80.17] + .sort((a, b) => a - b) + .map((at) => ({ station_id: `s${at}`, chainage_m: at })); +const cardAt = (at) => CARDS.find((card) => Math.abs(card.chainage_m - at) < 0.01); + +/* ── 폼 흉내(B05 패널 · loadForm / loadPipeForm / resetForm 의 알림 차례) ── */ +let binding = null; +let structures = []; +const form = { id: null, pipe: null }; +const loadForm = (target) => { + form.id = target?.structure_id ?? null; + form.pipe = null; + binding.onSelect(target); +}; +const loadPipeForm = (chainageM) => { + const hadStructure = form.id !== null; + form.id = null; + form.pipe = chainageM; + if (hadStructure) binding.onSelect(null); +}; +const resetForm = () => { + const hadPipe = form.pipe !== null; + form.pipe = null; + loadForm(null); + if (hadPipe) binding.onPipeSelect(null); +}; +const node = { addEventListener() {}, querySelector: () => ({ append() {} }) }; +const section = { + root: node, + listRoot: node, + facility: { root: node, setRevetSideLabels() {}, recount() {} }, + setTypes() {}, + setStructures(next) { + structures = next; + }, + setPipeFacilities() {}, + hasSelection: () => form.id !== null || form.pipe !== null, + selectById(id) { + if (id === null) { + if (form.id !== null || form.pipe !== null) resetForm(); + return; + } + loadForm(structures.find((entry) => entry.structure_id === id) ?? null); + }, + selectPipeByChainage(chainageM) { + if (chainageM === null) { + if (form.pipe !== null) resetForm(); + return; + } + loadPipeForm(chainageM); + }, +}; + +const panelModule = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Structures_Panel.ts"), + { + "../A00_Common/design/design_shared": { + bindDesignView: (_shared, _view, parts) => { + binding = parts.structures; + }, + sharedStructures: () => section, + }, + "../B05_Profile/B05_Profile_Api_Structures": { + fetchStructureTypes: async () => TYPES, + fetchStructures: async () => ({ structures: [] }), + readPendingStructures: () => current.structures, + pipesToStructureMarks: (pipes) => + pipes.map((pipe) => ({ + structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`, + type_id: pipe.facility, + chainage_m: pipe.chainage_m, + start_m: null, + end_m: null, + })), + structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, + writePendingStructures() {}, + }, + "../B05_Profile/B05_Profile_Api_Pipes_Draft": { + readPendingPipes: () => null, + writePendingPipes() {}, + }, + "../B05_Profile/B05_Profile_UI_Drainage_Facility_Merge": { mergeFacilityOptions: (x) => x }, + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => ({ pipe_points: PIPES, basins: [] }), + }, + "../common_util/common_util_summary_item": { + linkFields: () => ({}), + readGroupItems: (x) => x ?? {}, + readSummaryItem: (x) => x ?? null, + }, + "../B05_Profile/B05_Profile_UI_Structure_Pick_Session": { readStructurePick: () => null }, + "../A00_Common/design/design_selection": selection, + "@ui/ui_template_elements": { showToast() {} }, + "./B06_Section_UI_Adjust_Dock": { + adjustDockRoot: () => ({}), + refreshAdjustSlots() {}, + resetAdjustDock() {}, + setAdjustSideSlots() {}, + }, + "./B06_Section_UI_Page_Structures_Spec": { withSpecDefaults: (o) => o ?? {} }, + }, +); + +/* ── 종단 알약 흉내(Section_View_Profile) ── */ +let marks = []; +const profile = { + markedId: null, + pill: null, + selectMark(id) { + this.markedId = id; + this.pill = id; + }, + showCard(card) { + if (!card) return; + const pipe = PIPES.find((entry) => Math.abs(entry.chainage_m - card.chainage_m) < 0.51); + // 관 측점 = 그 측점선 짝 알약 · 일반 측점 = 꺼짐 — 고른 구조물이 덮으면 지킴(53-1) + this.pill = pipe ? `pipe-${pipe.chainage_m.toFixed(2)}` : null; + const marked = marks.find((entry) => entry.structure_id === this.markedId); + if (!pipe && marked && selection.coversChainage(marked, card.chainage_m)) + this.pill = this.markedId; + }, +}; + +/* ── 횡단 카드 흉내(Section_View) ── */ +let markTypes = TYPES; +const view = { + selected: null, + spanMarkId: null, + listener: null, + isPavement(id) { + const mark = marks.find((entry) => entry.structure_id === id); + return !!mark && markTypes.find((type) => type.type_id === mark.type_id)?.group === "G"; + }, + selectStation(id) { + const previous = this.selected; + this.selected = this.selected === id ? null : id; + profile.showCard(CARDS.find((card) => card.station_id === this.selected) ?? null); + if (this.selected !== previous) this.listener(this.selected); + }, + focusStation(id) { + if (id === null) this.selected = null; + else if (this.selected !== id) this.selectStation(id); + else profile.showCard(CARDS.find((card) => card.station_id === id)); + }, + selectMark(id, span = true) { + this.spanMarkId = span || this.isPavement(id) ? id : null; + profile.selectMark(id); + }, +}; + +let wallSelecting = false; +const focusStationAt = (at) => { + const nearest = CARDS.reduce((best, card) => + Math.abs(card.chainage_m - at) < Math.abs(best.chainage_m - at) ? card : best, + ); + view.focusStation(nearest.station_id); +}; + +const current = { structures: [PAVE] }; +let panel = null; + +/** design_view_cross.ts 카드 고름 리스너 본문(벽 → 소유 측점) — 그대로 옮김. */ +const ownerOf = (card) => (wallOwner && card.chainage_m === wallOwner.card ? wallOwner : null); +let wallOwner = null; +view.listener = (stationId) => { + const target = stationId ? CARDS.find((card) => card.station_id === stationId) : null; + if (!target) { + panel.showAtChainage(null); + selection.selectDesign(PID, selection.NO_SELECTION, { from: "b06" }); + return; + } + const owner = wallSelecting ? ownerOf(target) : null; + const opened = panel.showAtChainage(owner?.chainage_m ?? target.chainage_m); + if (!wallSelecting) selection.selectDesign(PID, opened, { from: "b06" }); + const pipeAt = panel.currentChainage(); + const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`); + view.selectMark(mark, false); +}; + +async function build(list) { + current.structures = list; + store.clear(); + form.id = null; + form.pipe = null; + view.selected = null; + view.spanMarkId = null; + profile.markedId = null; + profile.pill = null; + panel = panelModule.createB06StructuresPanel({ + projectId: PID, + shared: { view: () => "cross" }, + detail: () => ({ cross_sections: CARDS }), + stationInterval: () => 20, + focusChainage: (at) => { + if (!wallSelecting) focusStationAt(at); + }, + markStructure: (id) => view.selectMark(id), + onMarks: (next, types) => { + marks = next; + markTypes = types; + }, + }); + await panel.load(); +} + +/** 지금 상태 한 줄 — 포장층 = 고른 노면공 범위 안 카드 수(포장층 is-active 와 같은 판정). */ +function snap() { + const mark = marks.find((entry) => entry.structure_id === view.spanMarkId); + const pave = mark && view.isPavement(mark.structure_id); + const inSpan = (at) => + at >= Math.min(mark.start_m, mark.end_m) - 0.01 && + at <= Math.max(mark.start_m, mark.end_m) + 0.01; + const card = CARDS.find((entry) => entry.station_id === view.selected); + return { + form: form.id ?? (form.pipe === null ? null : `pipe-${form.pipe.toFixed(2)}`), + pill: profile.pill, + paveLayer: pave ? mark.structure_id : null, + paveCards: pave ? CARDS.filter((entry) => inSpan(entry.chainage_m)).length : 0, + card: card ? card.chainage_m : null, + sid: selection.readSelection(PID)?.structureId ?? null, + }; +} + +/** 좌측 목록 / 종단 알약 = 폼에 그 구조물(B05 `loadForm` · SVP `edit.selectStructure`). */ +const listPick = (id) => loadForm(structures.find((entry) => entry.structure_id === id)); +const pillPick = (id) => { + profile.pill = id; // 종단 그림이 먼저 그 알약을 켬 + panel.selectStructure(id); +}; +const cardPick = (at) => view.selectStation(cardAt(at).station_id); +/** 벽 누름 — SPS `revetOffset.select` 감싸개(소유 측점 폼) 뒤 카드 고름(벽 고르는 턴). */ +const wallPick = (cardAtM, ownerM) => { + wallOwner = { card: cardAtM, chainage_m: ownerM }; + wallSelecting = true; + selection.selectDesign(PID, panel.selectionAt(ownerM), { partKey: "inlet", from: "b06" }); + panel.showAtChainage(ownerM); + if (view.selected !== cardAt(cardAtM).station_id) cardPick(cardAtM); + wallSelecting = false; +}; + +(async () => { + const out = {}; + await build([PAVE, { structure_id: "sign", type_id: "hazard_sign", chainage_m: 127.86 }]); + listPick("pave"); + out.step1 = snap(); + cardPick(140); + out.step2 = snap(); + cardPick(100); + out.step2b = snap(); + wallPick(100, 80.17); // 고른 카드의 벽 = 카드 고름 리스너 안 돎 + out.step3 = snap(); + pillPick("pave"); + out.step4 = snap(); + cardPick(80); // 관 자리 카드 = 관(지금처럼) + out.pipeCard = snap(); + pillPick("pave"); + cardPick(280); // 범위 밖 = 모두 풂 + out.outside = snap(); + + // 5. B05 알약 → B06 복귀 — 넘김값 {범위 첫 카드 · sid} · `restoreStructurePick` 의 focusStation 차례 + await build([PAVE]); + selection.selectDesign(PID, { chainageM: 40, structureId: "pave" }, { from: "b05" }); + panel.prefer("pave"); + view.focusStation(cardAt(40).station_id); + panel.prefer(null); + panel.selectStructure("pave"); + out.step5 = snap(); + + // 6. 같은 측점에 노면공 둘 — 목록 둘째 → 그 층만 · 범위 안 카드 눌러도 둘째 그대로 + await build([PAVE, PAVE2]); + listPick("pave2"); + out.step6 = snap(); + cardPick(160); + out.step6b = snap(); + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/test_106_span_highlight_wording.py b/resources/tester/test_106_span_highlight_wording.py index 4c92bdf6f..aeec79260 100644 --- a/resources/tester/test_106_span_highlight_wording.py +++ b/resources/tester/test_106_span_highlight_wording.py @@ -25,7 +25,7 @@ def test_범위_강조_클래스는_고름_목록갱신_카드재생성_세_길 # applySpan 한 곳 — syncSpanHighlights · buildCrossCard 가 부름 assert VIEW.count("b06-cross-card--span") == 1 assert re.search( - r"selectMark: \(structureId, span = true\) => \{\s+(//[^\n]*\s+)?spanMarkId = span \? structureId : null;", + r"selectMark: \(structureId, span = true\) => \{\s+(//[^\n]*\s+)?spanMarkId = span \|\| isPavement\(structureId\) \? structureId : null;", VIEW, ) assert re.search( diff --git a/resources/tester/test_165_pavement_flow.py b/resources/tester/test_165_pavement_flow.py new file mode 100644 index 000000000..5ab8f210d --- /dev/null +++ b/resources/tester/test_165_pavement_flow.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +"""165 B06 노면공 고름 일관성 — 조사 K 재현 차례 6단계. + +못박는 것(단계마다 폼 · 알약 · 포장층 · 고른 카드 · 세션 sid 가 같은 것을 가리킴) + 1. 목록 노면공 = 범위 안 카드 · 포장층 · 알약 · 폼 · sid 모두 노면공(기준점이 범위 경계여도 범위 밖 카드로 안 감). + 2. 범위 안 빈 카드(관 · 덮는 구조물 없음) = 노면공 그대로 · 카드만 옮김(165-1). + 3. 고른 카드의 벽 = 관 폼 + 관 알약(알약이 꺼진 채 폼만 남지 않음 · 165-2) · 포장층 풂. + 4. 알약 다시 = 1 과 같음. 관 자리 카드 = 관 · 범위 밖 카드 = 모두 풂(지금처럼). + 5. B05 알약 → B06 복귀 = 1 과 같음 · 넘김 카드에서 안 튐. + 6. 겹친 노면공 둘 = 고른 층만 · 범위 안 카드 눌러도 그 층 그대로. + +Node 헬퍼 `helper_165_pavement_flow.cjs` 가 진짜 SPN 에 폼 · 카드 · 알약 흉내를 붙여 돌림. 화면은 사용자 확인. +""" + +import json +import os +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_165_pavement_flow.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _pave(card, layer="pave", cards=12): + return { + "form": layer, + "pill": layer, + "paveLayer": layer, + "paveCards": cards, + "card": card, + "sid": layer, + } + + +PIPE = {"form": "pipe-80.17", "pill": "pipe-80.17", "paveLayer": None, "paveCards": 0} + + +def test_1_목록_노면공(out): + assert out["step1"] == _pave(40) # 기준점 30 = 범위 경계 → 범위 안 첫 카드(20 아님) + + +def test_2_범위_안_빈_카드는_노면공_그대로(out): + assert out["step2"] == _pave(140) + assert out["step2b"] == _pave(100) + + +def test_3_벽은_관_폼과_관_알약_함께(out): + assert out["step3"] == {**PIPE, "card": 100, "sid": None} + + +def test_4_알약_다시_관_카드_범위_밖(out): + assert out["step4"] == _pave(40) + assert out["pipeCard"] == {**PIPE, "card": 80, "sid": None} + assert out["outside"] == { + "form": None, + "pill": None, + "paveLayer": None, + "paveCards": 0, + "card": 280, + "sid": None, + } + + +def test_5_B05_복귀(out): + assert out["step5"] == _pave(40) + + +def test_6_겹친_노면공(out): + assert out["step6"] == _pave(100, "pave2", 6) + assert out["step6b"] == _pave(160, "pave2", 6) + + +def test_배선(): + panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert "deps.focusChainage(focusOf(structure));" in panel + assert "return { chainageM, structureId: openId };" in panel + # 관 알약은 폼 뒤(폼이 연 구조물을 닫으며 알약을 끔) + at = panel.index("section.selectPipeByChainage(hit.pipeChainageM);") + assert at < panel.index("deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`);", at) + view = _read("B06_Section", "B06_Section_UI_Section_View.ts") + assert "spanMarkId = span || isPavement(structureId) ? structureId : null;" in view