From 835d8ce828e547a7385284f58dd863dd3eb9125a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 13:07:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=A2=85=EB=8B=A8=EC=95=8C=EC=95=BD):=203?= =?UTF-8?q?46-2=20=EB=8D=A7=203=20=EB=85=B8=EC=84=A0=20=EC=B4=88=EC=95=88?= =?UTF-8?q?=20=EB=8F=99=EC=95=88=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=C2=B7=20?= =?UTF-8?q?=EA=B4=80=20=EC=95=8C=EC=95=BD=20=3D=20=EB=AC=B6=EC=9D=8C=20fol?= =?UTF-8?q?low=20=EB=92=A4=20=EC=9E=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 logic/B05_Profile_Structure_Follow followedMarks: 묶음 입력(follow 뒤) 관 · 구조물의 새 누가거리를 알약 목록에 얹음 · 구조물 id 짝 · 구간형 시작 · 끝도 - 관 알약 id(세션 id) ≠ 묶음 관 id(서버 고정 id) — 누가거리 차례 짝 · 관 수가 안 맞으면 관은 안 옮김 - Structures_Marks: 노선 초안이 있으면(지금 프로젝트 묶음) 따라간 목록으로 알약 · 띠 · 툴팁 · 초안 버리면 저장 자리 - 시험 test_346_2_draft_cross_pills 5(횡단 카드 = 새 노선 상세 · 숨은 동안 안 그림 · 초안 버림 = 저장 노선 · 알약 자리) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- .../B05_Profile_UI_Structures_Marks.ts | 14 +- .../logic/B05_Profile_Structure_Follow.ts | 59 +++++ .../tester/helper_346_2_draft_cross_pills.cjs | 240 ++++++++++++++++++ .../tester/test_346_2_draft_cross_pills.py | 81 ++++++ 4 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 B05_Profile/logic/B05_Profile_Structure_Follow.ts create mode 100644 resources/tester/helper_346_2_draft_cross_pills.cjs create mode 100644 resources/tester/test_346_2_draft_cross_pills.py diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index acf44b1c5..8056f5040 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -12,6 +12,7 @@ * 그래프 폭에 맞춰 보여 준다. * * 그래프는 매 그리기마다 새로 만들어지므로 이 레인도 그때마다 다시 붙인다. + * 노선 초안 동안은 계산 묶음 follow 뒤 자리(좌표 따라간 새 누가거리 · 346-2 덧 3). * ========================================================================== */ import { pickPx } from "../A00_Common/touch_bridge"; @@ -25,6 +26,9 @@ import { balloonLines, type ContainerCount } from "./B05_Profile_UI_Structures_B import { stationLabel as formatStation } from "../common_util/common_util_station"; import { frameOf } from "../common_util/common_util_view_window"; import { catalogRow } from "../common_util/common_util_structure_catalog"; +import { draftProjectId } from "../A00_Common/design/design_store"; +import { pagePipeline } from "../A00_Common/design/design_pipeline_page"; +import { followedMarks } from "./logic/B05_Profile_Structure_Follow"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 * 보지 않고 시각적으로도 움직이지 않는다. 예전 10px 누적 방식은 손떨림이 쌓여 @@ -147,16 +151,20 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement lane.style.height = `${STRUCTURE_LANE_HEIGHT_PX}px`; const typeMap = new Map(options.types.map((type) => [type.type_id, type])); + const pipe = pagePipeline(draftProjectId()); + const structures = pipe?.input("route") + ? followedMarks(options.structures, pipe.input("structures"), pipe.input("pipes")) + : options.structures; // 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 알약 글·툴팁·벌룬 제목이 // 모두 이 번호를 따른다. - const texts = structurePillTexts(options.structures, typeMap); + const texts = structurePillTexts(structures, typeMap); const rows = rowAssignments( - options.structures, + structures, options.x, (structure) => texts.get(structure.structure_id ?? "")?.pill ?? "", ); - options.structures.forEach((structure) => { + structures.forEach((structure) => { const id = structure.structure_id; if (!id) return; const type = typeMap.get(structure.type_id); diff --git a/B05_Profile/logic/B05_Profile_Structure_Follow.ts b/B05_Profile/logic/B05_Profile_Structure_Follow.ts new file mode 100644 index 000000000..e1fd44a99 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Structure_Follow.ts @@ -0,0 +1,59 @@ +/* ============================================================================= + * B05_Profile_Structure_Follow.ts + * 노선 초안 동안 종단 알약 자리(PLAN 346-2 덧 3) — 계산 묶음 follow 가 좌표로 따라간 관 · 구조물의 새 + * 누가거리를 알약 목록에 얹음. 구조물 = structure_id 로 짝 · 관 알약 id = 세션 id(`pipeIdAt`) 라 묶음 관 id + * (서버 고정 id)와 다르면 누가거리 차례로 짝. + * + * 순수 — 화면 · fetch · 세션 없음. + * ========================================================================== */ + +interface Placed { + chainage_m?: number | null; + start_m?: number | null; + end_m?: number | null; +} + +interface Mark extends Placed { + structure_id?: string | null; +} + +interface Moved extends Placed { + id?: string | null; + structure_id?: string | null; +} + +const list = (value: unknown): Moved[] => (Array.isArray(value) ? (value as Moved[]) : []); +const at = (item: Placed): number => item.chainage_m ?? item.start_m ?? 0; + +/** 알약 목록 → 따라간 자리를 얹은 사본 · 짝 없는 알약은 그대로. `structures` · `pipes` = 묶음 입력(follow 뒤). */ +export function followedMarks( + marks: ReadonlyArray, + structures: unknown, + pipes: unknown, +): T[] { + const byId = new Map(); + for (const item of list(structures)) if (item.structure_id) byId.set(item.structure_id, item); + const movedPipes = list(pipes).filter((pipe) => typeof pipe.chainage_m === "number"); + for (const pipe of movedPipes) if (pipe.id) byId.set(pipe.id, pipe); + // ponytail: 차례 짝 — 노선을 고쳐 관 차례가 뒤바뀌면 틀림 · 알약이 서버 관 id 를 들면 id 짝으로 + const loose = marks + .filter((mark) => mark.structure_id?.startsWith("pipe-") && !byId.has(mark.structure_id)) + .sort((a, b) => at(a) - at(b)); + const ids = new Set(marks.map((mark) => mark.structure_id)); + const free = movedPipes.filter((pipe) => !ids.has(pipe.id)).sort((a, b) => at(a) - at(b)); + const paired = new Map(); + if (loose.length === free.length) loose.forEach((mark, index) => paired.set(mark, free[index])); + return marks.map((mark) => { + const moved = (mark.structure_id ? byId.get(mark.structure_id) : undefined) ?? paired.get(mark); + if (!moved) return mark; + const shift = at(moved) - at(mark); + const move = (value: number | null | undefined, next: number | null | undefined) => + value == null ? value : (next ?? value + shift); + return { + ...mark, + chainage_m: move(mark.chainage_m, moved.chainage_m), + start_m: move(mark.start_m, moved.start_m), + end_m: move(mark.end_m, moved.end_m), + }; + }); +} diff --git a/resources/tester/helper_346_2_draft_cross_pills.cjs b/resources/tester/helper_346_2_draft_cross_pills.cjs new file mode 100644 index 000000000..b97df81d6 --- /dev/null +++ b/resources/tester/helper_346_2_draft_cross_pills.cjs @@ -0,0 +1,240 @@ +/* PLAN 346-2 덧 3 헬퍼 — 노선 초안 동안 횡단 카드 = 묶음 새 노선 상세 · 종단 알약 = follow 뒤 자리. + * 진짜 TS(`B06_Section_UI_Page_Station_Swap` · `design_pipeline` · `design_store` · `B05_Profile_UI_Structures_Marks` · + * `logic/B05_Profile_Structure_Follow`)를 그 자리에서 트랜스파일해 Node 로 돌림. 페이지 묶음 창구 + * (`design_pipeline_page` — pagePipeline · pipelineDetail)와 알약 이름 · 벌룬 · 서버 창구만 가짜. + * 결과 = 표준출력 JSON. 짝 = `test_346_2_draft_cross_pills.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const P = "p3463"; +const R = 7; + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +} +globalThis.window = globalThis; +globalThis.sessionStorage = memoryStorage(); +globalThis.localStorage = memoryStorage(); +localStorage.setItem("frd_current_project_id", P); +const element = () => { + const node = { + style: {}, + dataset: {}, + children: [], + classList: { toggle: () => undefined, add: () => undefined }, + append: (...items) => node.children.push(...items), + prepend: (item) => node.children.unshift(item), + addEventListener: () => undefined, + }; + return node; +}; +globalThis.document = { createElement: element }; + +/* 페이지 묶음 창구 가짜 — 묶음은 진짜(`createDesignPipeline`) · 지도는 시험이 쥠 */ +const fake = { pipe: null, detail: null }; +const STUBS = { + design_pipeline_page: { + pagePipeline: (id) => (id === P ? fake.pipe : null), + pipelineDetail: (id) => (id === P ? fake.detail : null), + }, + B06_Section_Section_Store: { + onAnySwap: () => () => undefined, + swapSectionStations: async () => null, + }, + touch_bridge: { pickPx: () => 14 }, + B05_Profile_Api_Structures: { + structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, + }, + B05_Profile_UI_Structures_List: { etcName: () => null, numberDuplicateNames: (names) => names }, + B05_Profile_UI_Structures_Balloon: { balloonLines: () => [] }, + common_util_structure_catalog: { + catalogRow: (id) => (id === "wall" ? { placement: "interval", owner: "structures" } : null), + }, +}; + +const cache = new Map(); +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + const key = Object.keys(STUBS).find((name) => request.endsWith(name)); + if (key) return STUBS[key]; + if (request.endsWith("config_frontend") || request.startsWith("@config/")) + return { CURRENT_PROJECT_ID_KEY: "frd_current_project_id", API_BASE_URL: "http://fake" }; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const load = (...parts) => loadTs(path.join(ROOT, ...parts)); + +const pipeMod = load("A00_Common", "design", "design_pipeline.ts"); +const storeMod = load("A00_Common", "design", "design_store.ts"); +const swap = load("B06_Section", "B06_Section_UI_Page_Station_Swap.ts"); +const marks = load("B05_Profile", "B05_Profile_UI_Structures_Marks.ts"); +const follow = load("B05_Profile", "logic", "B05_Profile_Structure_Follow.ts"); + +const tick = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms)); +const out = {}; + +async function main() { + /* 1. 횡단 카드 — 저장 노선 → 노선 초안(새 노선 상세) → 숨은 동안 안 그림 → 초안 버림(저장 노선) */ + { + const saved = { tag: "saved", cross_sections: [] }; + const drawn = []; + let shown = true; + const before = swap.shownDetail(P, saved).tag; + // 횡단 보기가 먼저 섬 — 묶음은 아직 없음(종단 보기가 세움) + const stop = swap.followSwaps( + P, + () => R, + { visible: () => shown }, + async () => void drawn.push(swap.shownDetail(P, saved).tag), + ); + const pipe = pipeMod.createDesignPipeline({ + stages: { + profile: (ctx) => ({ grade: ctx.inputs.alignment }), + cross: () => ({ byKey: {} }), + }, + }); + // 페이지 묶음과 같은 차례 — 가게 잇기 · 새 노선 상세(초안 동안만) · 그다음 듣는 쪽 + pipe.subscribe(pipeMod.publishToStore(storeMod.useDesignStore(P, null))); + pipe.subscribe((event) => { + if (event.kind !== "stage" || (event.stage !== "profile" && event.stage !== "cross")) return; + fake.detail = pipe.input("route") + ? { tag: `draft-${event.stage}-${event.generation}`, cross_sections: [] } + : null; + }); + fake.pipe = pipe; + await pipe.start({ + route: { rev: 1 }, + alignment: 1, + cross: null, + pipes: null, + structures: null, + }); + await tick(); + const afterDraft = [...drawn]; + const draftNow = swap.shownDetail(P, saved).tag; + shown = false; + await pipe.update("grade", 2); + await tick(); + const whileHidden = [...drawn]; + const hiddenNow = swap.shownDetail(P, saved).tag; + shown = true; + await pipe.update("route", null); + await tick(); + await pipe.update("grade", 3); + await tick(); + const discarded = [...drawn]; + const savedNow = swap.shownDetail(P, saved).tag; + stop(); + await pipe.update("route", { rev: 2 }); + await pipe.update("grade", 4); + await tick(); + out.cards = { + before, + afterDraft, + draftNow, + whileHidden, + hiddenNow, + discarded, + savedNow, + afterStop: drawn.length, + }; + pipe.dispose(); + fake.pipe = null; + fake.detail = null; + } + + /* 2. 종단 알약 — follow 뒤 자리(구조물 id 짝 · 관은 서버 id 와 세션 id 가 달라 차례 짝 · 구간형 띠도) */ + { + const pill = (structure_id, type_id, chainage_m, start_m = null, end_m = null) => ({ + structure_id, + type_id, + placement: start_m === null ? "point" : "interval", + chainage_m, + start_m, + end_m, + options: {}, + }); + const pills = [ + pill("pipe-s1", "pipe", 30), + pill("st-1", "gabion", 50), + pill("st-2", "wall", 70, 60, 80), + pill("pipe-s2", "pipe", 90), + pill("st-9", "gabion", 120), + ]; + const inputs = { + route: { rev: 3 }, + structures: [ + { structure_id: "st-1", chainage_m: 55 }, + { structure_id: "st-2", chainage_m: 72, start_m: 62, end_m: 82 }, + ], + pipes: [ + { id: "PB", chainage_m: 96 }, + { id: "PA", chainage_m: 33 }, + ], + }; + fake.pipe = { input: (key) => inputs[key] ?? null }; + const lane = () => { + const root = marks.buildStructureLane({ + structures: pills, + types: [], + x: (chainage) => chainage * 10, + chainageAt: (px) => px / 10, + maxChainageM: 200, + widthPx: 2000, + axisWidthPx: 40, + stationIntervalM: 20, + selectedId: null, + onSelect: () => undefined, + onMove: () => undefined, + }); + const left = {}; + const band = []; + for (const node of root.children) { + if (node.dataset.structureId) left[node.dataset.structureId] = parseFloat(node.style.left); + if (node.className?.startsWith("b05-structure__band")) + band.push(parseFloat(node.style.left), parseFloat(node.style.width)); + } + return { left, band }; + }; + const draft = lane(); + inputs.route = null; // 초안 버림 — 저장 자리 + const saved = lane(); + // 관 수가 안 맞으면 관 알약은 안 옮김(잘못 짝짓지 않음) + const short = follow.followedMarks(pills, inputs.structures, [{ id: "PA", chainage_m: 33 }]); + out.pills = { + draft, + saved, + short: short.map((s) => s.chainage_m), + untouched: pills[1].chainage_m, + }; + fake.pipe = null; + } + + process.stdout.write(JSON.stringify(out)); +} +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/resources/tester/test_346_2_draft_cross_pills.py b/resources/tester/test_346_2_draft_cross_pills.py new file mode 100644 index 000000000..2ef7bcf4e --- /dev/null +++ b/resources/tester/test_346_2_draft_cross_pills.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +"""346-2 덧 3 — 노선 초안 동안 횡단 카드 · 종단 구조물 알약. + +못박는 것 + - 횡단 카드 = 노선 초안 동안 묶음 새 노선 상세(`pipelineDetail`) · profile · cross 단계 뒤 다시 그림 · + 숨은 동안은 안 그림(상세는 새것) · 초안을 버리면 저장 노선 카드 · 묶음이 횡단 보기보다 늦게 서도 붙음 · + 끊으면 안 그림. + - 종단 알약 = follow 뒤 자리 — 구조물은 id 짝 · 관은 세션 id ≠ 서버 id 라 누가거리 차례 짝 · + 구간형 띠도 옮김 · 짝 없는 것은 그대로 · 관 수가 안 맞으면 관은 안 옮김 · 초안을 버리면 저장 자리. + +Node 헬퍼 `helper_346_2_draft_cross_pills.cjs` 가 진짜 TS 를 트랜스파일해 부름. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", str(HERE / "helper_346_2_draft_cross_pills.cjs")], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_노선_초안_동안_카드는_새_노선_상세(out): + cards = out["cards"] + assert cards["before"] == "saved" + assert cards["afterDraft"] == ["draft-profile-1", "draft-cross-1"] + assert cards["draftNow"] == "draft-cross-1" + + +def test_숨은_동안은_안_그림(out): + cards = out["cards"] + assert cards["whileHidden"] == ["draft-profile-1", "draft-cross-1"] + assert cards["hiddenNow"] == "draft-cross-2" + + +def test_초안_버리면_저장_노선_카드(out): + cards = out["cards"] + assert cards["discarded"][-1] == "saved" and cards["savedNow"] == "saved" + assert cards["afterStop"] == 3 # 끊은 뒤엔 안 그림 + + +def test_알약은_따라간_누가거리(out): + draft = out["pills"]["draft"] + # x = 누가거리 × 10 px — 관 30 → 33 · 90 → 96(차례 짝) · 구조물 id 짝 · 짝 없는 st-9 그대로 + assert draft["left"] == { + "pipe-s1": 330, + "st-1": 550, + "st-2": 720, + "pipe-s2": 960, + "st-9": 1200, + } + assert draft["band"] == [620, 200] # 구간형 띠 62 ~ 82 m + + +def test_초안_버리면_저장_자리_관_수_다르면_관은_그대로(out): + pills = out["pills"] + assert pills["saved"]["left"] == { + "pipe-s1": 300, + "st-1": 500, + "st-2": 700, + "pipe-s2": 900, + "st-9": 1200, + } + assert pills["saved"]["band"] == [600, 200] + assert pills["short"] == [30, 55, 72, 90, 120] + assert pills["untouched"] == 50 # 원본 목록은 안 고침