Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-18 09:55:52 +09:00
7 changed files with 133 additions and 52 deletions
+25 -15
View File
@@ -58,26 +58,36 @@ export function pipeChainageOf(id: string, projectId: string | null = active): n
/** 관을 옮김 — 옛 자리 id 가 새 자리로(새 자리에 남은 옛 기록은 뺌) · 옮긴 관의 id. */
export function movePipeId(fromM: number, toM: number, projectId: string | null = active): string {
if (!projectId) return `pipe-${toM.toFixed(2)}`;
return movePipeIds([[fromM, toM]], projectId)[0] ?? pipeIdAt(toM, projectId);
const book = bookOf(projectId);
const moving = nearestWithin(book.ids, fromM, (entry) => entry.at);
if (!moving) return pipeIdAt(toM, projectId);
const stale = nearestWithin(
book.ids.filter((entry) => entry !== moving),
toM,
(entry) => entry.at,
);
const ids = book.ids
.filter((entry) => entry !== stale)
.map((entry) => (entry === moving ? { ...entry, at: toM } : entry));
writeState("pipe-ids", { next: book.next, ids }, projectId);
return moving.id;
}
/** 관 여럿을 한 번에 옮김(노선 따라감 · 411-B) — 옮길 기록을 먼저 다 찾고 옮김(앞 관이 뒤 관 옛 자리로 가도
* 뒤 관 기록을 안 지움) · 새 자리에 남은 옮기지 않는 옛 기록은 뺌 · 옮긴 관마다 id(못 찾으면 null). */
export function movePipeIds(
moves: ReadonlyArray<readonly [number, number]>,
/** 관 세션 id 를 그 자리로(화면 목록 맞춤 · 411-B) — 짝 = 관 고정 id 가 쥔 세션 id(노선 따라감 · 끌기 · 되돌리기
* 어느 길로 옮겨도 그 id) · 그 자리 근처의 다른 기록(옛 측점 열쇠가 지은 헛 id)은 뺌 · 장부에 없는 id 는 적음. */
export function placePipeIds(
places: ReadonlyArray<readonly [string, number]>,
projectId: string | null = active,
): Array<string | null> {
if (!projectId) return moves.map(() => null);
): void {
if (!projectId || !places.length) return;
const book = bookOf(projectId);
const moving = moves.map(([fromM]) => nearestWithin(book.ids, fromM, (entry) => entry.at));
const to = new Map(moving.flatMap((entry, i) => (entry ? [[entry, moves[i][1]]] : [])));
const still = book.ids.filter((entry) => !to.has(entry));
const stale = new Set([...to.values()].map((at) => nearestWithin(still, at, (e) => e.at)));
const to = new Map(places);
const ids = book.ids
.filter((entry) => !stale.has(entry))
.map((entry) => (to.has(entry) ? { ...entry, at: to.get(entry)! } : entry));
if (to.size) writeState("pipe-ids", { next: book.next, ids }, projectId);
return moving.map((entry) => entry?.id ?? null);
.filter((entry) => to.has(entry.id) || !places.some(([, at]) => isNear(entry.at, at)))
.map((entry) => (to.has(entry.id) ? { ...entry, at: to.get(entry.id)! } : entry));
for (const [id, at] of places) if (!ids.some((entry) => entry.id === id)) ids.push({ id, at });
if (JSON.stringify(ids) !== JSON.stringify(book.ids))
writeState("pipe-ids", { next: book.next, ids }, projectId);
}
/** 관 목록에 세션 id 를 얹은 사본 — 측점 계획의 덮는 구조물 id(종단 측점 id · 알약 짝)가 세션 id. */
+15 -2
View File
@@ -317,13 +317,26 @@ function createPagePipeline(
replay = false;
if (kept) return { alignment: kept }; // 관 · 구조물도 세션 그대로(가게 · 쓰기 그대로)
const line = ctx.before.route?.line ?? savedLine() ?? [];
// 자리를 읽을 선 — 초안 둔 채 열기(앞 노선 없음)의 가게 값 = 이미 초안 노선 자리(앞 따라감이 씀 · 410)
const frame = (list: unknown, saved: unknown) =>
!ctx.before.route && ctx.inputs.route && list !== saved
? (ctx.out.route?.line ?? line)
: line;
const { pipes, structures } = ctx.inputs;
// 좌표 없는 관 = 앞 따라감(같은 id · 같은 자리) → 정본 좌표 · 옮긴 것 = 그 자리의 앞 노선 위(410)
const followed = (ctx.before.follow?.pipes ?? []) as FollowingStructure[];
const inputs = {
...ctx.inputs,
pipes: placed(pipes, line, [...(savedPipes as FollowingStructure[]), ...followed]),
structures: placed(structures, line, [], SECTION_STATION_INTERVAL_M),
pipes: placed(pipes, frame(pipes, savedPipes), [
...(savedPipes as FollowingStructure[]),
...followed,
]),
structures: placed(
structures,
frame(structures, savedStructures),
[],
SECTION_STATION_INTERVAL_M,
),
};
return followStage({ ...ctx, inputs });
},
+15 -12
View File
@@ -66,7 +66,7 @@ import "../../B05_Profile/B05_Profile_UI_Style.css";
import "../../B05_Profile/B05_Profile_UI_Style_Table.css";
import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
import { movePipeIds, pipeIdAt } from "./design_pipe_ids";
import { pipeIdAt, placePipeIds } from "./design_pipe_ids";
import { useDesignStore } from "./design_store";
import { setPlusStations } from "../../common_util/common_util_structure_station";
import {
@@ -114,21 +114,24 @@ export async function mountProfileView(
let lastPipes: PipeReport | null = shared.lastPipes ?? null;
const designStore = useDesignStore(activeProjectId, null);
/** 화면 관 목록 = 배수유역도 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 세션 id 도
* 옮김 — 짝 = 관 고정 id(411-B · 앞 관을 지나 옮겨도 그 관) · id 없는 관은 안 옮김(막 놓은 관). */
let lastShown: PipeReport = [];
* 그 자리로 — 짝 = 관 고정 id(411-B · 앞 관을 지나 옮겨도 · 되돌려도 그 관) · id 없는 관(막 놓은 관)은 자리로. */
let sessionIds = new Map<string, string>(); // 관 고정 id → 세션 id(알약 id)
const shownPipes = (pipes: PipeReport): PipeReport => {
const shown = followPipeReports(pipes, designStore.get("pipes")).sort(
(x, y) => x.chainage_m - y.chainage_m,
);
const was = new Map(lastShown.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe.chainage_m]] : [])));
const moves = shown.flatMap((pipe) => {
const from = pipe.id ? was.get(pipe.id) : undefined;
return from === undefined || Math.abs(pipe.chainage_m - from) <= 0.005
? []
: [[from, pipe.chainage_m] as const];
});
if (moves.length) movePipeIds(moves, activeProjectId);
lastShown = shown;
placePipeIds(
shown.flatMap((pipe) => {
const sid = pipe.id && sessionIds.get(pipe.id);
return sid ? [[sid, pipe.chainage_m] as const] : [];
}),
activeProjectId,
);
sessionIds = new Map(
shown.flatMap((pipe) =>
pipe.id ? [[pipe.id, pipeIdAt(pipe.chainage_m, activeProjectId)] as const] : [],
),
);
return shown;
};
const applyPipes = (reported: PipeReport, carryEdits = true): void => {
+13 -4
View File
@@ -342,17 +342,26 @@ export function mountDrainageToggles(
);
}
/** 노선 초안 follow 가 다시 매긴 관 자리(가게 관 초안)를 편집기에 얹음(410) — 짝 = 관 id · 시설 속성 ·
* 고름도 따라감 · 재계산 · 초안 쓰기 없음(가게가 이미 그 값). 편집기가 옛 노선 누가거리로 초안을 덮지 않게. */
/** 노선 초안 follow 가 다시 매긴 관 자리(가게 관 초안)를 편집기에 얹음(410) — 짝 = 관 id(막 놓아 초안에 id
* 없는 관끼리는 누가거리 차례 · 수가 같을 때만) · 시설 속성 · 고름도 따라감 · 재계산 · 초안 쓰기 없음(가게가
* 이미 그 값). 편집기가 옛 노선 누가거리로 초안을 덮지 않게. */
export function followEditorPipes(
editor: PipeEditor,
facilities: FacilityStore,
followed: ReadonlyArray<{ id?: string | null; chainage_m: number }>,
): void {
const to = new Map(followed.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe.chainage_m]] : [])));
const to = new Map<PipePoint | string, number>(
followed.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe.chainage_m]] : [])),
);
const before = editor.pipes();
const byOrder = (a: { chainage_m: number }, b: { chainage_m: number }) =>
a.chainage_m - b.chainage_m;
const looseEditor = before.filter((pipe) => !(pipe.id && to.has(pipe.id))).sort(byOrder);
const looseFollowed = followed.filter((pipe) => !pipe.id).sort(byOrder);
if (looseEditor.length === looseFollowed.length)
looseEditor.forEach((pipe, i) => to.set(pipe, looseFollowed[i].chainage_m));
const moves = before.flatMap((pipe) => {
const at = pipe.id ? to.get(pipe.id) : undefined;
const at = (pipe.id ? to.get(pipe.id) : undefined) ?? to.get(pipe);
return at === undefined || Math.abs(at - pipe.chainage_m) < 0.005
? []
: [{ pipe, at, attributes: facilities.get(pipe.chainage_m) }];
+1 -1
View File
@@ -5,7 +5,7 @@ export { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_
export { followedMarks } from "../../B05_Profile/logic/B05_Profile_Structure_Follow";
export {
movePipeId,
movePipeIds,
placePipeIds,
pipeChainageOf,
pipeIdAt,
usePipeIds,
@@ -46,28 +46,42 @@ out.reports = m
.followPipeReports(reports, draft)
.map((r) => [r.id, r.chainage_m, r.start_m ?? null]);
/* 2. 세션 id 장부 — 끌어 옮김(followMovedPipe 길) 뒤 화면 목록 짝(shownPipes 규칙 = id) */
/* 2. 세션 id 장부 — 끌어 옮김(followMovedPipe 길) · 그 사이 옛 측점 열쇠가 옛 자리에 헛 id 를 지음 →
* 화면 목록 맞춤(shownPipes = 관 고정 id 가 쥔 세션 id 를 새 자리로 · placePipeIds) */
const idA = m.pipeIdAt(100, P);
const idB = m.pipeIdAt(150, P);
const sessionIds = new Map([
["p10000", idA],
["p15000", idB],
]);
m.movePipeId(150, 80, P); // 끌기 = 옮긴 관 id 그대로
const was = new Map(reports.map((r) => [r.id, r.chainage_m]));
const shown = m.followPipeReports(reports, draft);
const moves = shown.flatMap((r) => {
const from = was.get(r.id);
return from === undefined || Math.abs(from - r.chainage_m) <= 0.005 ? [] : [[from, r.chainage_m]];
});
m.movePipeIds(moves, P);
out.ids = { idA, idB, at80: m.pipeIdAt(80, P), at100: m.pipeIdAt(100, P) };
out.bookNext = m.pipeIdAt(999, P); // 새 관 = 다음 차례(앞에서 헛 id 가 안 생겼으면 s3)
const stray = m.pipeIdAt(150, P); // 옛 측점 열쇠가 옛 자리를 물음
const place = (shown, project) =>
m.placePipeIds(
shown.map((r) => [sessionIds.get(r.id), r.chainage_m]),
project,
);
place(m.followPipeReports(reports, draft), P);
out.ids = { idA, idB, stray, at80: m.pipeIdAt(80, P), at100: m.pipeIdAt(100, P) };
/* 3. 노선 따라감이 두 관을 함께 밂(앞 관이 뒤 관 옛 자리로) — 한 번에 옮김 · 기록 안 잃음 */
/* 2-1. 되돌림 — 관이 옛 자리로 돌아감 · 그 자리에 헛 id 가 먼저 있어도 그 관 id(헛 기록은 뺌) */
place(
[
{ id: "p15000", chainage_m: 150 },
{ id: "p10000", chainage_m: 100 },
],
P,
);
out.back = { at150: m.pipeIdAt(150, P), strayAt: m.pipeChainageOf(stray, P) };
/* 3. 노선 따라감이 두 관을 함께 밂(앞 관이 뒤 관 옛 자리로) — 기록 안 잃음 · 헛 id 안 생김 */
const Q = "q411";
const q1 = m.pipeIdAt(100, Q);
const q2 = m.pipeIdAt(103, Q);
m.movePipeIds(
m.placePipeIds(
[
[100, 103],
[103, 106],
[q1, 103],
[q2, 106],
],
Q,
);
@@ -111,6 +125,27 @@ out.editor = {
m.followEditorPipes(editor, facilities, [{ id: "p10000", chainage_m: 103 }]); // 같은 값 = 그대로
out.editorSame = editor.pipes().map((p) => p.chainage_m);
/* 5-1. 막 놓은 관 — 초안엔 id 없음 · 편집기는 서버 id(재계산 응답) → id 없는 것끼리 차례 짝 */
const fresh = m.createPipeEditor(() => {});
fresh.setPipes([
{ chainage_m: 120, reason: "confirmed", id: "p12000" },
{ chainage_m: 351, reason: "stream", id: "p35100" },
]);
m.followEditorPipes(fresh, m.createFacilityStore(), [
{ chainage_m: 120 },
{ id: "p35100", chainage_m: 353 },
]);
const fresh2 = m.createPipeEditor(() => {});
fresh2.setPipes([
{ chainage_m: 200, reason: "confirmed", id: "p20000" },
{ chainage_m: 351, reason: "stream", id: "p35100" },
]);
m.followEditorPipes(fresh2, m.createFacilityStore(), [
{ chainage_m: 204 },
{ id: "p35100", chainage_m: 353 },
]);
out.fresh = [fresh.pipes(), fresh2.pipes()].map((list) => list.map((p) => [p.id, p.chainage_m]));
/* 6. B06 측점 열쇠 — 서버 관 id 도 관(계산 묶음 `pipe:` 와 같음) */
const planned = [
{ chainage_m: 80.17, owners: ["p8017"], role: "anchor" },
+15 -4
View File
@@ -6,7 +6,7 @@ B06 열쇠 = 계산 묶음과 같음.
새 `pipe-sN` 을 지어 새 측점 · 계획고 0 이 생기던 흠. 진짜 TS 를 vite ssr 로
묶어(`helper_411_b_entry.ts`) Node 로 잰다:
① `followPipeReports` 짝 = 관 고정 id
② 세션 id 장부(`movePipeId` · `movePipeIds`)
② 세션 id 장부(`movePipeId` 끌기 · `placePipeIds` 화면 맞춤)
③ `followedMarks`
④ 배수 편집기 `followEditorPipes`(410 · 따라간 자리 받음)
⑤ B06 `useStationOwners` · `stationKeysOf`.
@@ -75,15 +75,19 @@ def test_알림_목록_짝은_id(out):
def test_앞_관_지나_옮겨도_세션_id_그대로(out):
ids = out["ids"]
assert ids["at80"] == ids["idB"] # 옮긴 관 = 제 id
assert ids["at80"] == ids["idB"] # 옮긴 관 = 제 id(옛 자리 헛 id 를 안 받음)
assert ids["at100"] == ids["idA"] # 이웃 관 = 제 id(새 pipe-sN 아님)
assert out["bookNext"] == "pipe-s3" # 헛 id 없음 = 알약 · 측점 수 그대로
def test_되돌려_옛_자리로_가도_그_관_id(out):
assert out["back"]["at150"] == out["ids"]["idB"]
assert out["back"]["strayAt"] is None # 그 자리 헛 기록은 장부에서 빠짐
def test_따라감이_여럿을_함께_밀어도_기록_안_잃음(out):
batch = out["batch"]
assert batch["at103"] == batch["q1"] and batch["at106"] == batch["q2"]
assert batch["next"] == "pipe-s3"
assert batch["next"] == "pipe-s3" # 헛 id 없음 = 알약 · 측점 수 그대로
def test_알약은_그_관_자리(out):
@@ -98,6 +102,13 @@ def test_배수_편집기는_따라간_자리를_받음(out):
assert out["editorSame"] == [98, 103]
def test_막_놓은_관은_id_없는_것끼리_차례로_따라감(out):
assert out["fresh"] == [
[["p12000", 120], ["p35100", 353]],
[["p20000", 204], ["p35100", 353]],
]
def test_B06_열쇠는_서버_관_id_도_pipe(out):
assert out["keys"] == ["structure:st-1", "pipe:p8017"]
assert out["keys"][1] == out["pipelineKey"] # 계산 묶음 측점 열쇠와 같음