feat(횡단목록): 346-2 덧 측점 수가 바뀔 때 횡단 목록 갈아끼움 — 묶음 측점 → 새 상세 · 열쇠로 이어 받기 · 보이는 카드 먼저

- logic/B06_Section_Station_Swap: 묶음 측점(PipelineStation[]) → 새 상세 객체 · 같은 측점(열쇠 · 없으면 같은 자리 같은 종류)은 앞 횡단 이어 받음(설계 · 구조물 제원 · 이름) · 자리 · 지반 표본만 새것 · 새 측점 = 지반 표본 + 기본 설계 · 빠진 측점 뺌 · formatStation(파이썬 format_station 짝)
- Section_Store swapSectionStations: 같은 목록이면 그대로 · 바뀌면 캐시 · 세션 상세 갈아끼움 · 옛 열쇠 = 세워 둔 목록(옮긴 관도 옛 id) · 새 열쇠 = 묶음이 정한 것 · 노선 모양이 바뀐 차례면 세션 고침 넘김(사라진 측점 = 「노선 밖 고침 N 건」) · 듣는 쪽 subscribeSectionDetail
- 횡단 보기 followSwaps(가게 stations 따라감): 조정창 값(반폭 · 기슭막이 · 유입 · 집수정 · 추가 벽)을 옮긴 자리로 · 암 경계 · 암 경사 다시 읽음 · 보일 때 카드 · 종단도 다시 · 떠난 보기의 옛 맵은 지금 값을 안 덮음
- 카드 통째 그리기 = 보이는 줄 + 한 줄만 바로 · 나머지는 같은 id 빈 자리 → 쉬는 틈에 진짜 카드
- 시험: 셋 늘고 둘 빠진 목록(카드 8) · 관 45.054 → 47 m 고침 이어 받음 · 노선 밖 고침 버림 · 조정창 옮김 · 같은 목록 안 갈아끼움 · 보이는 카드 수

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-16 12:14:54 +09:00
co-authored by Claude Opus 5.5
parent 6757d1dcb8
commit 20f2044afe
13 changed files with 765 additions and 39 deletions
+2
View File
@@ -38,6 +38,7 @@ import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pi
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
import { paintMapPick } from "./design_pick_profile";
import { watchDrafts } from "./design_structure_status";
import { followSwaps } from "../../B06_Section/B06_Section_UI_Page_Station_Swap";
import { savingDesign } from "./design_save";
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync";
@@ -570,6 +571,7 @@ export async function mountCrossView(
host.onDispose(onStaleStructureTypes(() => host.visible() && reloadPanel()));
// 바뀜 신호 하나(323-6 · `watchDrafts`) — 초안이 어느 길로 바뀌든 보일 때 카드 · 그림 다시(옛 되부름 갈음)
host.onDispose(watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures()));
host.onDispose(followSwaps(projectId, () => currentRouteId, host, refreshDetailForStructures));
host.onDispose(() => {
window.clearTimeout(pipeReloadTimer);
window.clearTimeout(wallAdjustTimer);
+3 -23
View File
@@ -19,6 +19,9 @@ import type {
StageContext,
} from "../A00_Common/design/design_pipeline_types";
import { planElevationAt } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import { groundSamples } from "./logic/B06_Section_Station_Swap";
export { groundSamples };
/** 측점 하나의 횡단 입력 — 비면 기본(지반 리핑암 · 좌절토 · 표준 측구 · 표준 경사). 확폭은 측점(stations)이 줌. */
export type CrossStationInput = Partial<
@@ -31,29 +34,6 @@ export interface CrossStageInput {
byKey: Record<string, CrossStationInput>;
}
interface Sample {
offset_m: number;
elevation_m: number | null;
valid: boolean;
}
/** 지반 표본(중심 → 바깥 · 좌 +) → 엔진 표본(오프셋 오름차순 · 우 −). */
export function groundSamples(station: PipelineStation): Sample[] {
const ground = station.ground;
if (!ground) return [];
const point = (offset: number, z: number | null): Sample => ({
offset_m: offset,
elevation_m: z,
valid: z !== null && Number.isFinite(z),
});
const right = ground.right.map((s) => point(-s.offsetM, s.z));
const left = ground.left.map((s) => point(s.offsetM, s.z));
const out = [...right, ...left];
if (ground.centerZ !== null && !out.some((s) => Math.abs(s.offset_m) < 1e-9))
out.push(point(0, ground.centerZ));
return out.sort((a, b) => a.offset_m - b.offset_m);
}
/** 측점 하나의 엔진 입력. */
function optionsFor(
station: PipelineStation,
+55
View File
@@ -30,6 +30,14 @@ import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
import { USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh";
import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options";
import type { PipelineStation } from "../A00_Common/design/design_pipeline_types";
import {
carryStationKeys,
registeredKeys,
stationIntervalOf,
useStationKeyList,
} from "./B06_Section_Station_Keys";
import { sameStationList, swapCrossSections } from "./logic/B06_Section_Station_Swap";
const cache = new Map<string, SectionDetailResponse>();
const pending = new Map<string, Promise<SectionDetailResponse>>();
@@ -103,6 +111,53 @@ export async function loadSectionDetail(
return request;
}
/** 새 상세 · 이어 받은 측점의 누가거리 옮김(옛 0.01 m 글 → 새 글 · 조작값 맵을 옮길 때). */
type DetailListener = (detail: SectionDetailResponse, moves: ReadonlyMap<string, string>) => void;
const listeners = new Map<string, Set<DetailListener>>();
/** 상세 객체가 갈아끼워짐(측점 수가 바뀜 · 346-2 4파) — 쥔 옛 객체를 버리고 새것으로 다시 그릴 것. */
export function subscribeSectionDetail(
projectId: string,
routeId: number,
fn: DetailListener,
): () => void {
const key = keyOf(projectId, routeId);
const set = listeners.get(key) ?? new Set<DetailListener>();
set.add(fn);
listeners.set(key, set);
return () => set.delete(fn);
}
/**
* 계산 묶음 측점(가게 `stations`)이 측점 목록을 바꿨으면 상세를 **갈아끼움** — 같은 목록이면 그대로(제자리
* 갱신 길이 맡음 · null). 앞 횡단은 측점 열쇠로 이어 받고, 노선 모양이 바뀐 차례면 세션 고침도 열쇠로 넘김
* (사라진 측점 = 「노선 밖 고침 N 건」). 새 상세 = 캐시 · 세션 · 듣는 쪽(횡단 · 종단 보기 · 3D).
*/
export async function swapSectionStations(
projectId: string,
routeId: number,
stations: ReadonlyArray<PipelineStation>,
): Promise<{ detail: SectionDetailResponse; moves: Map<string, string> } | null> {
const key = keyOf(projectId, routeId);
const current = cache.get(key);
if (!current || !stations.length) return null;
const interval = stationIntervalOf(current);
const oldKeys = registeredKeys(projectId, routeId, current.cross_sections, interval);
if (sameStationList(current.cross_sections, oldKeys, stations)) return null;
const swap = swapCrossSections(current, stations, oldKeys, interval);
// 새 목록 열쇠 = 묶음 측점이 정한 것 그대로(규칙 한 벌 · 다시 셈하지 않음)
const nextKeys = stations.map((station) => ({ key: station.key, chainageM: station.chainageM }));
if (swap.moved) carryStationKeys(projectId, routeId, nextKeys);
else useStationKeyList(projectId, routeId, nextKeys);
cache.set(key, swap.detail);
writeState("section-detail", swap.detail, projectId, routeId);
await withDraftShapes(swap.detail, projectId); // 새 측점에도 구조물 그림 자료(323-4)
withStructureAreas(swap.detail);
if (cache.get(key) !== swap.detail) return null; // 그 사이 다른 갈아끼움 · 새로 받음
listeners.get(key)?.forEach((fn) => fn(swap.detail, swap.moves));
return { detail: swap.detail, moves: swap.moves };
}
/**
* 캐시를 비운다. routeId를 생략하면 그 프로젝트 전부 —
* 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다.
+54 -10
View File
@@ -41,7 +41,7 @@ const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope"];
const NEAR_M = 0.01;
const HINT = "@at";
type Keyed = { key: string; chainageM: number };
export type Keyed = { key: string; chainageM: number };
type Raw = Record<string, unknown> & { [HINT]?: Record<string, number> };
const lists = new Map<string, Keyed[]>();
@@ -111,21 +111,46 @@ export function stationKeysOf(
});
}
/** 상세가 측점을 다 얹었다 — 이 노선의 지금 측점 열쇠를 세움(읽기 · 쓰기가 이 목록으로 옮김). */
export function useStationKeys(
projectId: string,
routeId: number,
detail: Pick<SectionDetailResponse, "cross_sections" | "longitudinal">,
): void {
const interval =
type KeyedDetail = Pick<SectionDetailResponse, "cross_sections" | "longitudinal">;
/** 측점 간격 — 종단 정책 → 없으면 측점에서 짐작. */
export function stationIntervalOf(detail: KeyedDetail): number {
return (
(
detail.longitudinal?.profile_alignment as
{ policy?: { station_interval_m?: number } } | undefined
)?.policy?.station_interval_m ?? inferStationInterval(detail.cross_sections);
lists.set(scopeOf(projectId, routeId), stationKeysOf(projectId, detail.cross_sections, interval));
)?.policy?.station_interval_m ?? inferStationInterval(detail.cross_sections)
);
}
/** 상세가 측점을 다 얹었다 — 이 노선의 지금 측점 열쇠를 세움(읽기 · 쓰기가 이 목록으로 옮김). */
export function useStationKeys(projectId: string, routeId: number, detail: KeyedDetail): void {
const list = stationKeysOf(projectId, detail.cross_sections, stationIntervalOf(detail));
lists.set(scopeOf(projectId, routeId), list);
current = { projectId, routeId };
}
/** 열쇠 목록을 그대로 세움 — 계산 묶음 측점(`PipelineStation.key`)이 정한 열쇠(346-2 4파). */
export function useStationKeyList(projectId: string, routeId: number, list: Keyed[]): void {
lists.set(scopeOf(projectId, routeId), list);
current = { projectId, routeId };
}
/**
* 지금 상세 측점마다 열쇠 — 세워 둔 목록(그 자리 0.01 m) → 없으면 규칙으로 셈. 관 장부가 이미 새 자리로
* 옮긴 뒤에도(옮긴 관) 옛 측점은 세울 때의 열쇠로 찾음.
*/
export function registeredKeys(
projectId: string,
routeId: number,
sections: ReadonlyArray<CrossSection>,
intervalM: number | null,
): string[] {
const list = lists.get(scopeOf(projectId, routeId));
const computed = stationKeysOf(projectId, sections, intervalM);
return sections.map((s, i) => (list && nearest(list, s.chainage_m)?.key) ?? computed[i].key);
}
/** 지금 노선(마지막으로 세운 목록) — 세션 열쇠만 아는 저장소(암 경계 · 암 경사)가 씀. */
export const currentStationScope = () => current;
@@ -261,6 +286,25 @@ export function carryStationDrafts(
return outside.size;
}
/** 잠시 쓰는 새 목록 자리 — 같은 노선 안에서 목록이 갈릴 때. */
const NEXT_ROUTE = -1;
/**
* 같은 노선에서 노선 모양이 바뀌어 측점 목록이 갈림(346-2 4파 · 상세 갈아끼움) — 지금 목록 고침을 새 목록에
* 넘기고(넘김 규칙 = `carryStationDrafts`) 새 목록을 세움. 돌려주는 값 = 노선 밖으로 나간 고침 수.
*/
export function carryStationKeys(projectId: string, routeId: number, next: Keyed[]): number {
lists.set(scopeOf(projectId, NEXT_ROUTE), next);
const outside = carryStationDrafts(projectId, routeId, NEXT_ROUTE);
for (const name of KEYED_STATES) {
writeState(name, readRaw(name, projectId, NEXT_ROUTE), projectId, routeId);
writeState(name, null, projectId, NEXT_ROUTE);
}
lists.delete(scopeOf(projectId, NEXT_ROUTE));
useStationKeyList(projectId, routeId, next);
return outside;
}
/** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */
function carriedValue(name: KeyedState, value: unknown): unknown {
if (CARRY_WHOLE.includes(name)) return value;
@@ -26,6 +26,7 @@ import {
} from "./B06_Section_UI_Cross_Culvert_Wire";
import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure";
import type { PipeRevetChoice } from "../A00_Common/design/design_structure_pipe_calc";
import { renameChainageKeys } from "./logic/B06_Section_Station_Swap";
/** 세션에 담기는 측점별 조작값 한 겹 맵. */
export interface SessionMap<V> {
@@ -40,12 +41,36 @@ export interface SessionMap<V> {
* 세션 보관 맵을 만든다. `accept` 가 항목마다 값을 검증·정규화해 돌려주고,
* `undefined` 를 내면 그 항목은 버린다(손상값·옛 형식 정리 자리).
*/
/** 만든 조작값 맵 → 세션 열쇠 — 측점 목록을 갈아끼우면 누가거리 열쇠를 새 자리로 옮김(346-2 4파). */
const alive = new Map<SessionMap<unknown>, () => string | null>();
/**
* 측점이 움직임(이어 받은 측점의 누가거리가 바뀜 · `옛 0.01 m 글 → 새 글`) — 조정창 값(반폭 · 기슭막이 ·
* 유입 형식 · 집수정 · 추가 벽 · 단별 구간값)을 새 자리로 옮겨 세션에 씀. 빠진 측점 값은 그대로 둠.
*/
export function moveSessionMaps(moves: ReadonlyMap<string, string>): void {
if (!moves.size) return;
// 같은 세션 칸은 마지막에 만든 맵 하나만(떠난 보기의 옛 맵이 지금 값을 덮지 않게)
const latest = new Map<string, SessionMap<unknown>>();
alive.forEach((key, map) => {
const at = key();
if (at) latest.set(at, map);
});
for (const map of latest.values()) {
map.load();
const next = renameChainageKeys(map.values, moves);
map.values.clear();
next.forEach((value, key) => map.values.set(key, value));
map.persist();
}
}
export function createSessionMap<V>(
sessionKey: () => string | null,
accept: (value: unknown) => V | undefined,
): SessionMap<V> {
const values = new Map<string, V>();
return {
const map: SessionMap<V> = {
values,
load(): void {
values.clear();
@@ -73,6 +98,8 @@ export function createSessionMap<V>(
}
},
};
alive.set(map as SessionMap<unknown>, sessionKey);
return map;
}
export interface LinkFlagMaps {
+16 -4
View File
@@ -75,6 +75,18 @@ export function readCutSlopeSession(projectId: string, routeId: number): Record<
return Object.fromEntries(readKeyed<number>("cutslope", projectId, routeId));
}
/** 지금 사는 암 경계 · 암 경사 저장소 — 측점 목록을 갈아끼우면 새 목록 누가거리로 다시 읽음(346-2 4파). */
const keyedStores = new Set<{ load: () => void }>();
function keepAlive<T extends { load: () => void }>(store: T): T {
keyedStores.add(store);
return store;
}
/** 측점 목록이 갈아끼워짐 — 암 경계 · 암 경사 값을 측점 열쇠 세션에서 다시 읽음. */
export function reloadKeyedStores(): void {
keyedStores.forEach((store) => store.load());
}
/** 세션 열쇠 글(`aislo:draft:rockb:{p}:{r}`)의 프로젝트 · 노선 — 저장소가 측점 열쇠 창구로 읽고 씀. */
function keyedScope(storageKey: string): { projectId: string; routeId: number } | null {
const [projectId, route] = storageKey.split(":").slice(-2);
@@ -249,7 +261,7 @@ export function createRockBoundaryStore(options: {
}
}
return {
return keepAlive({
offsets,
load(): void {
offsets.clear();
@@ -297,7 +309,7 @@ export function createRockBoundaryStore(options: {
recompute(chainageM);
},
},
};
});
}
/** 측점별 암 절토 경사비 저장소 — 값(Map)과 카드 제어기를 함께 낸다(2026-09-07). */
@@ -349,7 +361,7 @@ export function createCutSlopeStore(options: {
}
}
return {
return keepAlive({
ratios,
load(): void {
ratios.clear();
@@ -384,7 +396,7 @@ export function createCutSlopeStore(options: {
recompute(chainageM);
},
},
};
});
}
/** [저장]·[확정]이 함께 쓰는 페이지 상태 창구. */
@@ -0,0 +1,31 @@
/* =============================================================================
* B06_Section_UI_Page_Station_Swap.ts
* 횡단 보기가 계산 묶음 측점을 따라감(PLAN 346-2 4파) — 가게 `stations` 가 측점 목록을 바꾸면(늘고 줄면)
* 상세 객체를 갈아끼우고(`swapSectionStations`) 조정창 값 · 암 경계 · 암 경사를 새 자리로 옮긴 뒤 보일 때 카드 ·
* 종단도를 다시(카드는 보이는 것 먼저 · 다시 셈은 그 길이 맡음 · 안 보이면 다시 보일 때 그림).
* ========================================================================== */
import { useDesignStore } from "../A00_Common/design/design_store";
import { swapSectionStations } from "./B06_Section_Section_Store";
import { moveSessionMaps } from "./B06_Section_UI_Page_Link_Session";
import { reloadKeyedStores } from "./B06_Section_UI_Page_Persist";
/** 묶음 측점을 따라감 — 돌려준 함수로 끊음. `redraw` = 캐시에서 상세를 다시 읽어 카드 · 그림 다시. */
export function followSwaps(
projectId: string | null,
routeId: () => number | null,
host: { visible: () => boolean },
redraw: () => Promise<void>,
): () => void {
if (!projectId) return () => undefined;
return useDesignStore(projectId, null).subscribe("stations", (stations) => {
const route = routeId();
if (route === null || !stations?.length) return;
void swapSectionStations(projectId, route, stations).then((swap) => {
if (!swap) return; // 같은 목록 — 제자리 갱신 길이 맡음
moveSessionMaps(swap.moves); // 조정창 값(반폭 · 기슭막이 · 유입 · 집수정 · 추가 벽)
reloadKeyedStores(); // 암 경계 · 암 경사 = 측점 열쇠 세션 → 새 목록 누가거리로
if (host.visible()) void redraw();
});
});
}
+8 -1
View File
@@ -44,6 +44,7 @@ import {
} from "./B06_Section_UI_Cross_View_Metrics";
import { cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom";
import { CROSS_GRID_GAP, CROSS_HEIGHT, createIdleQueue } from "./B06_Section_UI_Section_Common";
import { eagerCardCount, pendingCard } from "./B06_Section_UI_Section_View_Lazy";
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
@@ -477,9 +478,14 @@ export function createSectionView(
cachedCardWidth,
cols,
);
// 보이는 카드 먼저(346-2) — 나머지는 빈 자리 · 쉬는 틈에 진짜 카드(`_Section_View_Lazy`)
const eager = eagerCardCount(cards, cols, rowHeights[0] ?? CROSS_HEIGHT, CROSS_GRID_GAP);
sections.forEach((section, index) => {
cachedRowHeight.set(section.station_id, rowHeights[index]);
grid.append(buildCrossCard(section, rowHeights[index], plotBases[index]));
if (index < eager)
grid.append(buildCrossCard(section, rowHeights[index], plotBases[index]));
else grid.append(pendingCard(section.station_id, rowHeights[index]));
if (index >= eager) offscreen.queue.add(section.chainage_m);
});
} else {
grid.append(emptyView(L("B06_Profile_View_NoCross")));
@@ -488,6 +494,7 @@ export function createSectionView(
const keepScrollTop = cards.scrollTop;
cards.replaceChildren(fillSlopeNotice.root, grid);
cards.scrollTop = keepScrollTop;
offscreen.drain();
syncProfile();
};
@@ -0,0 +1,29 @@
/* =============================================================================
* B06_Section_UI_Section_View_Lazy.ts
* 횡단 카드 목록 통째 그리기에서 **보이는 카드 먼저**(PLAN 346-2 4파 · 측점 수가 바뀌어 목록을 다시 짤 때) —
* 보이는 줄 + 한 줄만 바로 그리고 나머지는 같은 id · 같은 높이 빈 자리로 두었다가 쉬는 틈에 진짜 카드로
* (`createIdleQueue` · 화면 밖 카드 갈아 끼우기와 같은 줄). 스크롤 자리 · 측점 찾기는 빈 자리로도 그대로.
* ========================================================================== */
/** 처음에 바로 그릴 카드 수 — 보이는 줄 + 한 줄 · 칸이 아직 안 보이면(높이 0) 두 줄. */
export function eagerCardCount(
view: { clientHeight: number; scrollTop: number },
cols: number,
rowHeightPx: number,
gapPx: number,
): number {
const rows =
view.clientHeight > 0
? Math.ceil((view.scrollTop + view.clientHeight) / Math.max(rowHeightPx + gapPx, 1)) + 1
: 2;
return rows * Math.max(cols, 1);
}
/** 아직 안 그린 카드 자리 — 진짜 카드와 같은 id · 높이. */
export function pendingCard(stationId: string, heightPx: number): HTMLElement {
const slot = document.createElement("article");
slot.id = `cross-${stationId}`;
slot.className = "b06-cross-card b06-cross-card--pending";
slot.style.height = `${heightPx}px`;
return slot;
}
@@ -0,0 +1,182 @@
/* =============================================================================
* B06_Section_Station_Swap.ts
* 측점 수가 바뀔 때 횡단 목록 갈아끼움(PLAN 346-2 4파) — 계산 묶음 측점(`PipelineStation[]`) → 새 상세 객체.
*
* · 같은 측점(열쇠 · 없으면 같은 자리 같은 종류)은 앞 횡단을 이어 받음 — 설계 · 구조물 제원 · 이름 그대로 ·
* 자리(누가거리 · 좌표 · 방위 · 확폭)와 지반 표본(채워 왔으면)만 새것.
* · 새 측점 = 지반 표본 + 기본 설계(계획고 0 → 낡음 → 브라우저 다시 셈이 곧 채움).
* · 빠진 측점 = 목록에서 뺌(세션 고침은 열쇠로 남음 · 노선이 바뀐 때만 「노선 밖 고침」 · 부르는 쪽).
* · 옛 상세는 그대로 둠 — 새 객체를 돌려줌(쥔 쪽이 갈아끼움).
*
* ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` `format_station` ↔ `formatStation`.
* 거울 시험 `resources/tester/test_346_2_station_swap.py`.
* 순수 계산(DOM · fetch · 세션 없음).
* ========================================================================== */
import type { PipelineStation } from "../../A00_Common/design/design_pipeline_types";
import type { CrossSection, SectionDetailResponse, SectionStation } from "../B06_Section_Api_Fetch";
interface Sample {
offset_m: number;
elevation_m: number | null;
valid: boolean;
}
/** 지반 표본(중심 → 바깥 · 좌 +) → 엔진 표본(오프셋 오름차순 · 우 −). */
export function groundSamples(station: PipelineStation): Sample[] {
const ground = station.ground;
if (!ground) return [];
const point = (offset: number, z: number | null): Sample => ({
offset_m: offset,
elevation_m: z,
valid: z !== null && Number.isFinite(z),
});
const right = ground.right.map((s) => point(-s.offsetM, s.z));
const left = ground.left.map((s) => point(s.offsetM, s.z));
const out = [...right, ...left];
if (ground.centerZ !== null && !out.some((s) => Math.abs(s.offset_m) < 1e-9))
out.push(point(0, ground.centerZ));
return out.sort((a, b) => a.offset_m - b.offset_m);
}
/** 짝: `format_station` — `STA.k+mmm.mmm`. */
export function formatStation(chainageM: number): string {
const at = Math.max(chainageM, 0);
const km = Math.floor(at / 1000);
const rest = (at - km * 1000).toFixed(3).padStart(7, "0");
return `STA.${km}+${rest}`;
}
/** 서버 측점 id 꼴(`station_000000484210` = 누가거리 mm 12자리). */
export const stationIdOf = (chainageM: number): string =>
`station_${String(Math.round(chainageM * 1000)).padStart(12, "0")}`;
/** 새 측점의 기본 설계 — 계획고 0 이라 늘 낡음 → 다시 셈이 세션 선택 · 기본값으로 채움. */
const freshDesign = () =>
({
design_elevation_m: 0,
ground_type: "ripping_rock",
section_mode: "left_cut",
ditch_type: "standard",
}) as unknown as NonNullable<CrossSection["design"]>;
function kindOf(station: PipelineStation): SectionStation["kind"] {
if (station.kind === "end") return station.key.endsWith("EP") ? "ep" : "bp";
return station.kind === "grid" ? "regular" : "irregular";
}
/** 같은 목록인가 — 열쇠 · 누가거리가 차례대로 같음(제자리 갱신이면 갈아끼우지 않음). */
export function sameStationList(
sections: ReadonlyArray<{ chainage_m: number }>,
oldKeys: ReadonlyArray<string>,
stations: ReadonlyArray<PipelineStation>,
): boolean {
return (
sections.length === stations.length &&
stations.every(
(station, index) =>
Math.abs(station.chainageM - sections[index].chainage_m) < 1e-6 &&
(oldKeys[index] === station.key || kindOf(station) === "regular"),
)
);
}
/** 조작값 맵 열쇠(`85.05` · `85.05:outlet` · `b85.05`)의 누가거리를 새 자리로 — 안 옮기는 열쇠는 그대로. */
export function renameChainageKeys<V>(
values: ReadonlyMap<string, V>,
moves: ReadonlyMap<string, string>,
): Map<string, V> {
const out = new Map<string, V>();
for (const [key, value] of values) {
const match = /^(b?)(-?\d+\.\d{2})(:.*)?$/.exec(key);
const to = match && moves.get(match[2]);
out.set(to ? `${match[1]}${to}${match[3] ?? ""}` : key, value);
}
return out;
}
export interface StationSwap {
detail: SectionDetailResponse;
added: string[];
removed: string[];
/** 이어 받은 측점 중 누가거리가 바뀐 것 — 옛 0.01 m 글 → 새 글(조작값 맵 옮김). */
moves: Map<string, string>;
/** 이어 받은 측점 중 좌표가 0.01 m 넘게 움직인 것이 있나 — 노선이 바뀐 차례(「노선 밖 고침」 셈). */
moved: boolean;
}
/**
* 새 측점 목록으로 상세를 새로 짬. `oldKeys` = 지금 상세 측점마다 열쇠(같은 차례 · `stationKeysOf`) ·
* 열쇠가 안 맞으면 같은 자리(0.01 m) · 같은 종류의 앞 횡단을 씀.
*/
export function swapCrossSections(
detail: SectionDetailResponse,
stations: ReadonlyArray<PipelineStation>,
oldKeys: ReadonlyArray<string>,
intervalM: number | null,
): StationSwap {
const byKey = new Map(detail.cross_sections.map((section, i) => [oldKeys[i], section]));
const used = new Set<CrossSection>();
let moved = false;
const added: string[] = [];
const moves = new Map<string, string>();
const text = (chainageM: number) => chainageM.toFixed(2);
const priorOf = (station: PipelineStation): CrossSection | undefined => {
const hit = byKey.get(station.key);
if (hit && !used.has(hit)) return hit;
return detail.cross_sections.find(
(section) =>
!used.has(section) &&
section.kind === kindOf(station) &&
Math.abs(section.chainage_m - station.chainageM) < 0.01,
);
};
const longStations: SectionStation[] = [];
const cross_sections = stations.map((station): CrossSection => {
const prior = priorOf(station);
if (prior) used.add(prior);
else added.push(station.key);
if (prior && Math.hypot(prior.center_x - station.x, prior.center_y - station.y) > 0.01)
moved = true;
if (prior && text(prior.chainage_m) !== text(station.chainageM))
moves.set(text(prior.chainage_m), text(station.chainageM));
const kind = kindOf(station);
const onGrid =
kind === "irregular" &&
!!intervalM &&
Math.abs(station.chainageM / intervalM - Math.round(station.chainageM / intervalM)) < 1e-6;
const place = {
station_id: prior?.station_id ?? stationIdOf(station.chainageM),
chainage_m: station.chainageM,
label: formatStation(station.chainageM),
kind,
...(onGrid ? { grid: true } : {}),
center_z: station.ground?.centerZ ?? prior?.center_z ?? null,
azimuth_deg: station.azimuthDeg,
plan_radius_m: station.planRadiusM,
curve_outer_side: station.curveOuterSide,
curve_widening_m: station.curveWideningM,
center_x: station.x,
center_y: station.y,
frame: { left_xy: [station.left[0], station.left[1]] as [number, number] },
};
longStations.push({ ...place, ...(prior?.structure ? { structure: prior.structure } : {}) });
const samples = station.ground ? groundSamples(station) : (prior?.samples ?? []);
return prior ? { ...prior, ...place, samples } : { ...place, samples, design: freshDesign() };
});
const removed = detail.cross_sections
.map((section, i) => (used.has(section) ? null : oldKeys[i]))
.filter((key): key is string => key !== null);
const last = stations[stations.length - 1]?.chainageM ?? detail.longitudinal.length_m;
return {
detail: {
...detail,
longitudinal: { ...detail.longitudinal, stations: longStations, length_m: last },
cross_sections,
},
added,
removed,
moved,
moves,
};
}
@@ -0,0 +1,211 @@
/* 346-2 4파 헬퍼 — 측점 셋 늘고 둘 빠진 목록으로 상세 갈아끼움 · 카드 수 · 열쇠 이어 받기 · 보이는 카드 먼저.
* node helper_346_2_station_swap.mjs <묶음.mjs>
* vite ssr 로 묶은 진짜 모듈(helper_346_2_swap_entry.ts) · 표준출력 JSON 한 벌.
*/
import { pathToFileURL } from "node:url";
const store = () => {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
key: (i) => [...m.keys()][i] ?? null,
get length() {
return m.size;
},
clear: () => m.clear(),
};
};
globalThis.window = globalThis;
globalThis.sessionStorage = store();
globalThis.localStorage = store();
const any = new Proxy(function () {}, {
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
apply: () => any,
construct: () => any,
});
globalThis.document = any;
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
const toasts = [];
globalThis.__toasts = toasts;
const m = await import(pathToFileURL(process.argv[2]).href);
const P = "p346";
const R = 9;
const out = {};
/* 옛 목록 7 — 시점 · 격자 넷 · 관 하나 · 종점(간격 20 m) */
const section = (chainage_m, kind, extra = {}) => ({
station_id: `station_${String(Math.round(chainage_m * 1000)).padStart(12, "0")}`,
chainage_m,
label: "",
kind,
center_z: 100,
azimuth_deg: 90,
center_x: chainage_m,
center_y: 0,
frame: { left_xy: [0, 1] },
samples: [{ offset_m: 0, elevation_m: 100, valid: true }],
design: { design_elevation_m: 101, mark: `old-${chainage_m}` },
...extra,
});
const oldDetail = () => ({
longitudinal: {
length_m: 100,
stations: [],
profile_alignment: { policy: { station_interval_m: 20 } },
},
cross_sections: [
section(0, "bp"),
section(20, "regular"),
section(40, "regular"),
section(45.054, "irregular", { structure: "파형강관 D800", culvert: {} }),
section(60, "regular"),
section(80, "regular"),
section(100, "ep"),
],
});
const OLD_KEYS = ["end:BP", "grid:1", "grid:2", "pipe:pipe-s1", "grid:3", "grid:4", "end:EP"];
/* 새 목록 8 — 셋 늘고(st-1 · st-2 · st-3) 둘 빠짐(grid:2 · grid:4) · 관 47 m 로 · 종점 90 m 로 */
const station = (key, chainageM, kind, id = null, dy = 0) => ({
key,
chainageM,
x: chainageM,
y: dy,
azimuthDeg: 90,
left: [0, 1],
kind,
id,
ground: {
centerZ: 99,
left: [{ offsetM: 0.5, z: 99.1 }],
right: [{ offsetM: 0.5, z: 98.9 }],
},
planRadiusM: null,
curveOuterSide: null,
curveWideningM: null,
});
const NEW = (dy) => [
station("end:BP", 0, "end", null, dy),
station("grid:1", 20, "grid", null, dy),
station("pipe:pipe-s1", 47, "pipe", "pipe-s1", dy),
station("structure:st-1", 52, "structure", "st-1", dy),
station("grid:3", 60, "grid", null, dy),
station("structure:st-2", 65, "structure", "st-2", dy),
station("structure:st-3", 75, "structure", "st-3", dy),
station("end:EP", 90, "end", null, dy),
];
/* 1. 순수 갈아끼움 */
{
const before = oldDetail();
const swap = m.swapCrossSections(before, NEW(0), OLD_KEYS, 20);
out.pure = {
count: swap.detail.cross_sections.length,
longCount: swap.detail.longitudinal.stations.length,
added: swap.added,
removed: swap.removed,
moves: Object.fromEntries(swap.moves),
moved: swap.moved,
keptMark: swap.detail.cross_sections.map((s) => s.design?.mark ?? null),
kinds: swap.detail.cross_sections.map((s) => s.kind),
labels: [swap.detail.cross_sections[2].label, swap.detail.cross_sections[7].label],
samples: swap.detail.cross_sections[1].samples.map((s) => s.offset_m),
oldUntouched:
before.cross_sections.length === 7 && before.cross_sections[3].chainage_m === 45.054,
length: swap.detail.longitudinal.length_m,
same: m.sameStationList(before.cross_sections, OLD_KEYS, NEW(0)),
sameSelf: m.sameStationList(
swap.detail.cross_sections,
NEW(0).map((s) => s.key),
NEW(0),
),
};
out.format = [0, 45.054, 1234.5].map((c) => m.formatStation(c));
}
/* 2. 가게 → 상세 갈아끼움 · 열쇠 이어 받기 · 노선 밖 고침 */
{
m.writeState("section-detail", oldDetail(), P, R);
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 45.054 }] }, P);
const first = await m.loadSectionDetail(P, R); // 세션 한 벌 → 캐시 · 측점 열쇠 세움
const choice = (ground) => ({
ground_type: ground,
section_mode: "right_cut",
ditch_side: "right",
});
m.writeCrossDesignChoice(P, R, 20, choice("soil"));
m.writeCrossDesignChoice(P, R, 40, choice("soil"));
m.writeCrossDesignChoice(P, R, 45.054, choice("blasting_rock"));
const widths = m.createSessionMap(
() => "aislo:test:crossw",
(v) => v,
);
widths.values.set("45.05", 12);
widths.values.set("45.05:outlet", 3);
widths.values.set("20.00", 8);
widths.persist(); // 조정창은 바꿀 때마다 세션에 씀(제어기와 같은 차례)
const heard = [];
// 관 · 구조물 자리 장부 — 새 목록에서 관은 47 m · 구조물 id 는 측점 계획이 줌
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 47 }] }, P);
m.useStructureIds(P, [
{ structure_id: "st-1", chainage_m: 52 },
{ structure_id: "st-2", chainage_m: 65 },
{ structure_id: "st-3", chainage_m: 75 },
]);
const stopListen = m.subscribeSectionDetail(P, R, (detail, moves) =>
heard.push({ count: detail.cross_sections.length, moves: Object.fromEntries(moves) }),
);
let redraws = 0;
const stop = m.followSwaps(
P,
() => R,
{ visible: () => true },
async () => {
redraws += 1;
},
);
m.useDesignStore(P, null).set("stations", NEW(0.5)); // 좌표가 움직임 = 노선 모양 바뀜
await new Promise((resolve) => setTimeout(resolve, 30));
const now = await m.loadSectionDetail(P, R);
out.store = {
replaced: now !== first,
count: now.cross_sections.length,
heard,
choices: [...m.crossDesignChoices(P, R).entries()]
.sort((a, b) => a[0] - b[0])
.map(([at, c]) => [at, c.ground_type, c.section_mode ?? null]),
rawKeys: Object.keys(m.readState("crossdesign", P, R) ?? {})
.filter((k) => k !== "@at")
.sort(),
widths: Object.fromEntries(widths.values),
redraws,
};
// 같은 목록을 다시 내면 갈아끼우지 않음
const again = await m.swapSectionStations(P, R, NEW(0.5));
out.store.again = again === null;
stop();
stopListen();
}
/* 3. 보이는 카드 먼저 — 바로 그릴 수 */
out.eager = [
m.eagerCardCount({ clientHeight: 600, scrollTop: 0 }, 3, 250, 12),
m.eagerCardCount({ clientHeight: 600, scrollTop: 1310 }, 3, 250, 12),
m.eagerCardCount({ clientHeight: 0, scrollTop: 0 }, 2, 250, 12),
];
out.rename = Object.fromEntries(
m.renameChainageKeys(
new Map([
["45.05", 1],
["b45.05", 2],
["45.05:outlet", 3],
["60.00", 4],
]),
new Map([["45.05", "47.00"]]),
),
);
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,25 @@
/* 346-2 4파 시험 묶음 진입점 — 측점 수가 바뀔 때 횡단 목록 갈아끼움(진짜 모듈 · vite ssr). */
export {
formatStation,
renameChainageKeys,
sameStationList,
swapCrossSections,
} from "../../B06_Section/logic/B06_Section_Station_Swap";
export { eagerCardCount } from "../../B06_Section/B06_Section_UI_Section_View_Lazy";
export {
loadSectionDetail,
subscribeSectionDetail,
swapSectionStations,
} from "../../B06_Section/B06_Section_Section_Store";
export { followSwaps } from "../../B06_Section/B06_Section_UI_Page_Station_Swap";
export {
crossDesignChoices,
writeCrossDesignChoice,
} from "../../B06_Section/B06_Section_Cross_Design_Session";
export { useStructureIds } from "../../B06_Section/B06_Section_Station_Keys";
export {
createSessionMap,
moveSessionMaps,
} from "../../B06_Section/B06_Section_UI_Page_Link_Session";
export { useDesignStore } from "../../A00_Common/design/design_store";
export { readState, writeState } from "../../A00_Common/b_page_state";
+121
View File
@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
"""346-2 4파 — 측점 수가 바뀔 때 횡단 목록 갈아끼움.
못박는 것
- 측점 셋 늘고 둘 빠진 목록 → 새 상세 객체(카드 8 · 종단 측점 8) · 이어 받은 측점은 설계 그대로 ·
새 측점은 지반 표본 + 기본 설계 · 옛 상세는 그대로 · 같은 목록이면 안 갈아끼움.
- 가게 `stations` → 캐시 상세 갈아끼움 · 듣는 쪽에 새 상세 + 누가거리 옮김 ·
카드 선택은 측점 열쇠로 새 자리(관 45.054 → 47 m) · 노선 모양이 바뀌면 넘김 규칙
(단면 유형 버림) · 사라진 측점 고침 버림 · 조정창 값도 옮긴 자리로.
- 보이는 카드 먼저 — 보이는 줄 + 한 줄만 바로.
- `formatStation` ↔ 파이썬 `format_station` 같은 글.
Node 헬퍼 `helper_346_2_station_swap.mjs` 가 vite ssr 로 묶은 진짜 모듈
(`helper_346_2_swap_entry.ts`)을 부름.
"""
import json
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Engine_Sections_Core import format_station # noqa: E402
@pytest.fixture(scope="module")
def out(tmp_path_factory):
work = tmp_path_factory.mktemp("b346")
build = subprocess.run(
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_346_2_swap_entry.ts",
"--outDir",
str(work),
"--logLevel",
"error",
],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
)
assert build.returncode == 0, build.stderr
bundle = work / "bundle.mjs"
shutil.copy(work / "helper_346_2_swap_entry.js", bundle)
proc = subprocess.run(
["node", str(HERE / "helper_346_2_station_swap.mjs"), str(bundle)],
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):
pure = out["pure"]
assert pure["count"] == 8 and pure["longCount"] == 8
assert pure["added"] == ["structure:st-1", "structure:st-2", "structure:st-3"]
assert pure["removed"] == ["grid:2", "grid:4"]
assert pure["moves"] == {"45.05": "47.00", "100.00": "90.00"}
assert pure["keptMark"] == [
"old-0",
"old-20",
"old-45.054",
None,
"old-60",
None,
None,
"old-100",
]
assert pure["kinds"] == [
"bp",
"regular",
"irregular",
"irregular",
"regular",
"irregular",
"irregular",
"ep",
]
assert pure["labels"] == ["STA.0+047.000", "STA.0+090.000"]
assert pure["samples"] == [-0.5, 0, 0.5]
assert pure["oldUntouched"] is True and pure["length"] == 90
assert pure["same"] is False and pure["sameSelf"] is True
def test_측점_글_파이썬과_같음(out):
assert out["format"] == [format_station(c) for c in (0, 45.054, 1234.5)]
def test_가게_측점으로_상세_갈아끼움_열쇠로_이어_받기(out):
store = out["store"]
assert store["replaced"] is True and store["count"] == 8
assert store["heard"] == [{"count": 8, "moves": {"45.05": "47.00", "100.00": "90.00"}}]
# 관 고침은 47 m 로 · 노선 모양이 바뀌어 단면 유형은 버림 · 40 m(grid:2) 고침은 노선 밖
assert store["choices"] == [[20, "soil", None], [47, "blasting_rock", None]]
assert store["rawKeys"] == ["grid:1", "pipe:pipe-s1"]
assert store["widths"] == {"47.00": 12, "47.00:outlet": 3, "20.00": 8}
assert store["redraws"] == 1 # 보이는 횡단 보기 = 카드 · 그림 한 번 다시
assert store["again"] is True # 같은 목록 = 안 갈아끼움
def test_보이는_카드_먼저(out):
assert out["eager"] == [12, 27, 4] # 3열 · 600 px · (250+12) px 줄 = 3 줄 + 1
assert out["rename"] == {"47.00": 1, "b47.00": 2, "47.00:outlet": 3, "60.00": 4}