feat(종단알약): 346-2 덧 3 노선 초안 동안 구조물 · 관 알약 = 묶음 follow 뒤 자리
- 새 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<T extends Mark>(
|
||||
marks: ReadonlyArray<T>,
|
||||
structures: unknown,
|
||||
pipes: unknown,
|
||||
): T[] {
|
||||
const byId = new Map<string, Moved>();
|
||||
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<T, Moved>();
|
||||
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),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 # 원본 목록은 안 고침
|
||||
Reference in New Issue
Block a user