perf(횡단): 346-2 국부 연산 · [전체 측점 반영] 한 번 · 보이는 카드 먼저

- 횡단 다시 셈이 측점별 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)을 기억 — 같으면 앞 결과 그대로 · 계획고 ▲ 한 번 = 10 측점 중 2 측점만 다시 셈 · 결과는 통째 셈과 같음
- [전체 측점 반영] = 다시 셈 한 번(측점마다 66 × 66 돌던 것) · 지금 값을 「사용자 선택」으로 박는 동작 없앰(암 경계 다시 셈 포함 · 사용자 확정 10-16)
- 횡단 카드: 보이는 카드만 바로 갈아 끼움 · 화면 밖은 쉬는 틈에 몇 장씩(경고칩 · 면적 합은 계산값이라 그대로)
- 머묾 재현 · 국부 연산 · 흙 배분 열쇠 · 전체 반영 · 카드 시험(vite ssr 로 진짜 모듈 묶음)

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 11:16:19 +09:00
co-authored by Claude Opus 5.5
parent 48ddca36a2
commit ca8c88f810
8 changed files with 588 additions and 46 deletions
+21 -1
View File
@@ -318,6 +318,12 @@ function readCutSlopeRatios(projectId: string, routeId: number): Map<number, num
* 그걸 쓰면 한 박자 늦은 값이 된다. 계산 재료(선형 저장분·표준단면)를 갖추지 못하면
* `null` 을 돌려 서버 폴백으로 넘긴다.
*/
/** 측점별 앞 계산 — 측점 객체가 바뀌면(상세 다시 받음) 저절로 비워진다. */
const memos = new WeakMap<
CrossSection,
{ samples: CrossSection["samples"]; fingerprint: string; design: CrossSection["design"] }
>();
function refreshLocally(input: CrossRefreshInput): number[] | null {
const { projectId, detail, edits } = input;
const stored = readAlignment(detail.longitudinal);
@@ -334,6 +340,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
if (!alignment) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다.
const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null;
if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로.
const standardKey = JSON.stringify(standard);
// 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된
// 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음
// 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중
@@ -460,9 +467,21 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
}
: null,
};
// 국부 연산(346-2) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고
// 설계가 그때 낸 그 객체면 앞 결과 그대로. 계획고 ▲ 한 번에 다시 세는 측점은 곡선 둘레 몇 곳뿐.
const samples = section.samples ?? [];
const fingerprint = `${standardKey}|${elevation}|${samples.length}|${JSON.stringify({ ...options, standard: null })}`;
const memo = memos.get(section);
if (
memo?.samples === section.samples &&
memo.fingerprint === fingerprint &&
memo.design === previous
) {
continue;
}
let next;
try {
next = computeCrossDesign(section.samples ?? [], elevation, options);
next = computeCrossDesign(samples, elevation, options);
} catch {
continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
}
@@ -477,6 +496,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
const own = next[`berm_depth_${side}_m`];
if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own;
}
memos.set(section, { samples: section.samples, fingerprint, design: section.design });
updated.push(section.chainage_m);
}
return updated;
+12 -32
View File
@@ -117,28 +117,13 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
await reconcileStaleDesigns({ force: true });
}
/** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */
function changeFromDesign(chainageM: number): CrossDesignChange | null {
const target = ctx
.detail()
?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01);
const design = target?.design;
if (!design) return null;
return {
ground_type: design.ground_type,
section_mode: design.section_mode,
ditch_side: design.ditch_side ?? null,
ditch_type: design.ditch_type ?? "standard",
paved: design.paved,
two_stage_slope: design.two_stage_slope ?? false,
ditch_choice: design.ditch_choice ?? null,
};
}
/** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */
/** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 다시 셈 — 지금 값을 「사용자 선택」으로
* 박지 않음(자동 판정이 굳으면 노선이 바뀌어도 재판정 안 됨 · 346-2). 암 경계는 세션에서 읽음. */
function recomputeIfRock(chainageM: number): void {
const change = changeFromDesign(chainageM);
if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change);
const design = ctx
.detail()
?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01)?.design;
if (design && design.ground_type !== "soil") void reconcileStaleDesigns({ force: true });
}
/**
@@ -215,23 +200,18 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
void reconcileStaleDesigns({ force: true });
}
/** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다.
* handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */
/** 패널 [전체 반영](N-2-1 · 346-2): 표준값으로 전 측점을 **한 번** 다시 셈 — 측점마다 돌면
* 66 × 66 = 4.1 s 였음. 표준값은 다시 셈이 `effectiveStandardCross` 로 읽고, 측점별 선택은
* 세션에 있는 것만 씀(지금 값을 「사용자 선택」으로 박지 않음 · 사용자 확정 10-16). */
let applyingAll = false;
async function applyPanelToAll(): Promise<void> {
const sectionDetail = ctx.detail();
if (applyingAll || !sectionDetail || !projectId || ctx.routeId() === null) return;
const targets = sectionDetail.cross_sections.filter((section) => section.design);
if (!targets.length) return;
// 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장).
// 대신 도는 동안 다시 누르는 것만 막는다.
if (!sectionDetail.cross_sections.some((section) => section.design)) return;
// 화면을 잠그지 않는다(CLAUDE.md 5장) — 도는 동안 다시 누르는 것만 막는다.
applyingAll = true;
try {
for (const section of targets) {
const change = changeFromDesign(section.chainage_m);
if (change) await handleDesignChange(section.chainage_m, change);
}
await reconcileStaleDesigns({ force: true });
showToast(L("B06_Std_ApplyAll_Success"), "success");
} finally {
applyingAll = false;
@@ -133,6 +133,28 @@ export function staleKey(
.join("|");
}
/**
* 쉬는 틈 일거리 줄(346-2) — 화면 밖 횡단 카드를 몇 장씩 갈아 끼움(카드 하나 ≈ 8 ms · 0.54 s / 66).
* 줄에 든 측점은 `run` 이 꺼내 그림 · 통째로 다시 그리면 `queue.clear()`.
*/
export function createIdleQueue(run: (chainageM: number) => void, batch = 6) {
const queue = new Set<number>();
let waiting = 0;
const drain = (): void => {
if (waiting || !queue.size) return;
const idle = window.requestIdleCallback ?? ((next: () => void) => window.setTimeout(next, 16));
waiting = idle(() => {
waiting = 0;
for (const chainageM of [...queue].slice(0, batch)) {
queue.delete(chainageM);
run(chainageM);
}
drain();
});
};
return { queue, drain };
}
/** 낡음 지문(`staleKey`)에 든 측점 누가거리 — 다시 셈 뒤에도 남은 측점을 표시할 때(342-1). */
export function staleChainages(key: string): number[] {
return key ? key.split("|").map((entry) => Number(entry.split(":")[0])) : [];
+30 -10
View File
@@ -43,7 +43,7 @@ import {
naturalHeightAtWidth,
} from "./B06_Section_UI_Cross_View_Metrics";
import { cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom";
import { CROSS_GRID_GAP, CROSS_HEIGHT } from "./B06_Section_UI_Section_Common";
import { CROSS_GRID_GAP, CROSS_HEIGHT, createIdleQueue } from "./B06_Section_UI_Section_Common";
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";
@@ -415,8 +415,11 @@ export function createSectionView(
});
}
/** 화면 밖에서 아직 옛 그림인 카드 — 쉬는 틈에 몇 장씩(346-2). */
const offscreen = createIdleQueue((chainageM) => rebuildCard(chainageM));
const draw = (): void => {
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
offscreen.queue.clear(); // 통째로 다시 그림 — 밀린 카드도 새 그림
const detail = currentDetail;
cachedStationInterval =
currentStationInterval ?? inferStationInterval(detail.longitudinal.stations);
@@ -488,17 +491,23 @@ export function createSectionView(
syncProfile();
};
/** 카드 한 장만 갈아 끼운다(종단도는 안 건드린다). 못 찾으면 false. */
const rebuildCard = (chainageM: number): boolean => {
if (!currentDetail) return false;
const section = currentDetail.cross_sections.find(
/** 측점의 자료 · 카드 — 측점이 없으면 null. */
const cardAt = (chainageM: number) => {
const section = currentDetail?.cross_sections.find(
(candidate) => Math.abs(candidate.chainage_m - chainageM) < 0.01,
);
if (!section) return false;
const existing = document.getElementById(`cross-${section.station_id}`);
return section && { section, el: document.getElementById(`cross-${section.station_id}`) };
};
/** 카드 한 장만 갈아 끼운다(종단도는 안 건드린다). 못 찾으면 false. */
const rebuildCard = (chainageM: number): boolean => {
const card = cardAt(chainageM);
if (!card) return false;
offscreen.queue.delete(chainageM);
// 단건 갱신은 draw에서 정해둔 행 높이를 재사용해 같은 행 카드와 높이를 유지한다.
if (existing)
existing.replaceWith(buildCrossCard(section, cachedRowHeight.get(section.station_id)));
card.el?.replaceWith(
buildCrossCard(card.section, cachedRowHeight.get(card.section.station_id)),
);
return true;
};
@@ -515,7 +524,18 @@ export function createSectionView(
*/
const refreshCards = (chainages: ReadonlyArray<number>, redraw = true): void => {
let touched = false;
for (const chainageM of chainages) if (rebuildCard(chainageM)) touched = true;
// 보이는 카드 먼저(346-2) — 화면 밖 카드는 쉬는 틈에 나눠 그림 · 경고칩 · 면적 합은 계산값이라 그대로
const view = cards.getBoundingClientRect();
for (const chainageM of chainages) {
const card = cardAt(chainageM);
if (!card) continue;
touched = true;
const rect = card.el?.getBoundingClientRect();
if (rect && rect.height > 0 && rect.bottom >= view.top && rect.top <= view.bottom)
rebuildCard(chainageM);
else offscreen.queue.add(chainageM);
}
offscreen.drain();
if (!touched || !currentDetail) return;
if (redraw) redrawProfile();
else fillSlopeNotice.update(currentDetail.cross_sections, cachedStationInterval);
+28
View File
@@ -0,0 +1,28 @@
/* 342-1 · 346-2 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름.
* 화면 · 서버 없이 계획선 한 벌 · 낡음 머묾 · 국부 연산 · 흙 배분 열쇠 · [전체 측점 반영] 을 잰다.
*/
export {
holdScreenAlignment,
refreshCrossDesigns,
staleAgainstScreen,
} from "../../B06_Section/B06_Section_Cross_Refresh";
export {
createIdleQueue,
staleChainages,
staleKey,
} from "../../B06_Section/B06_Section_UI_Section_Common";
export {
buildAlignment,
emptyEdits,
toAlignmentBase,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
export { toDesignProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Data";
export { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
export {
irregularGraphStations,
maxChainageOf,
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
export { createCrossPreview } from "../../B05_Profile/B05_Profile_UI_Profile_Preview";
export { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
export { createDesignSync } from "../../B06_Section/B06_Section_UI_Page_Design_Sync";
export { writeState } from "../../A00_Common/b_page_state";
@@ -0,0 +1,347 @@
/* 342-1 · 346-2 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_342_1_entry.ts)을 부르고 결과를 JSON 한 벌로.
* node helper_342_1_station_line.mjs <묶음.mjs> <입력.json>
* 입력 = { standard: 파이썬 config 표준단면, policy: 종단 정책 } · 화면 선 = design_profile.ts 의 진짜
* `listed()` 본문(소스에서 떼어 트랜스파일 · helper_177_3 방식).
*/
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const require = createRequire(import.meta.url);
const ts = require(join(ROOT, "config", "node_modules", "typescript"));
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(),
keys: () => [...m.keys()],
};
};
globalThis.window = globalThis;
globalThis.sessionStorage = store();
globalThis.localStorage = store();
// 토스트 등 DOM 을 건드리는 자리 — 무엇을 불러도 받아 주는 빈 객체
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 warns = [];
console.warn = (...args) => warns.push(args.map(String).join(" "));
const [bundlePath, inputPath] = process.argv.slice(2);
const m = await import(pathToFileURL(bundlePath).href);
const input = JSON.parse(readFileSync(inputPath, "utf8"));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const transpile = (code) =>
ts.transpileModule(code, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const PID = "p342";
const RID = 7;
m.writeState("std-cross", input.standard, PID);
/* ── 견본 노선 0~160 m — 서버 비정규 측점 85.054(0.001 m) · 관 지점 85.05(0.01 m) · 종단곡선 안 ── */
const SERVER_IRR = 85.054;
const PIPE = 85.05;
const regular = [0, 20, 40, 60, 80, 100, 120, 140, 160];
const serverStations = [...regular, SERVER_IRR]
.sort((a, b) => a - b)
.map((c) => ({
station_id: `s${c}`,
chainage_m: c,
kind: c === SERVER_IRR ? "irregular" : c === 0 ? "bp" : "regular",
label: String(c),
...(c === SERVER_IRR ? { structure: "파형강관 D800" } : {}),
}));
const groundAt = (c) => 100 + 0.08 * c - 6 * Math.sin(c / 23);
const chainage = Array.from({ length: 161 }, (_, i) => i);
const basePvi = serverStations.map((s, i) => ({
chainage_m: s.chainage_m,
elevation_m: groundAt(s.chainage_m) + (i % 2 ? 1.6 : -1.3),
}));
const base = {
policy: input.policy,
onlyExplicitCurves: false,
basePvi,
chainage,
ground: chainage.map(groundAt),
stations: serverStations.map((s) => ({ station_id: s.station_id, chainage_m: s.chainage_m })),
};
const stored = m.buildAlignment(base, m.emptyEdits());
const crossSamples = (c) =>
Array.from({ length: 49 }, (_, i) => {
const offset = -12 + i * 0.5;
return { offset_m: offset, elevation_m: groundAt(c) + 0.3 * offset, valid: true };
});
const freshDesign = () => ({
design_elevation_m: 0,
ground_type: "soil",
section_mode: "left_cut",
ditch_side: "left",
ditch_type: "standard",
two_stage_slope: false,
});
const makeDetail = () => ({
longitudinal: {
length_m: 160,
stations: serverStations.map((s) => ({ ...s })),
samples: chainage.map((c) => ({ chainage_m: c, elevation_m: groundAt(c), valid: true })),
profile_alignment: JSON.parse(JSON.stringify(stored)),
design_profiles: [m.toDesignProfile(stored, undefined)],
},
cross_sections: serverStations.map((s) => ({
station_id: s.station_id,
chainage_m: s.chainage_m,
samples: crossSamples(s.chainage_m),
design: freshDesign(),
})),
});
const irregularStations = [
{ id: "pipe-1", station: 4, remainder: 5.05, chainage_m: PIPE, structure: "파형강관 D800" },
];
/* 화면 선 = design_profile.ts 의 진짜 listed() */
const dpSrc = readFileSync(join(ROOT, "A00_Common", "design", "design_profile.ts"), "utf8");
const listedSrc = dpSrc.match(/\r?\n function listed\(\)[\s\S]*?\r?\n \}\r?\n/)[0];
const listedOf = new Function(
"storedBase",
"detail",
"irregularStations",
"oneStationList",
"irregularGraphStations",
"maxChainageOf",
"followMovedNodes",
`${transpile(listedSrc)}\nreturn listed();`,
);
const screenBase = (detail) =>
listedOf(
m.toAlignmentBase(stored),
detail,
irregularStations,
m.oneStationList,
m.irregularGraphStations,
m.maxChainageOf,
m.followMovedNodes,
);
const staleOf = (detail, line) =>
m.staleKey({
longitudinal: { design_profiles: [m.toDesignProfile(line, undefined)] },
cross_sections: detail.cross_sections,
});
const refresh = (detail, extra = {}) =>
m.refreshCrossDesigns({
projectId: PID,
routeId: RID,
detail,
edits: m.emptyEdits(),
...extra,
});
const out = {};
/* 1. 머묾 재현 — 고치기 전 길(저장분 선) 대 화면 선 */
{
const detail = makeDetail();
await refresh(detail, { alignment: stored }); // 저장본 = 서버가 저장분 선으로 낸 설계
const screen = m.buildAlignment(screenBase(detail), m.emptyEdits());
out.screenStations = screen.stations.map((s) => s.chainage_m);
out.staleBefore = m.staleChainages(staleOf(detail, screen));
await refresh(detail); // 옛 길 — 맡긴 선 없음 → 저장분 선으로 다시 셈
out.staleOldPath = m.staleChainages(staleOf(detail, screen));
const release = m.holdScreenAlignment((id) => (id === RID ? screen : null));
out.staleAgainstScreenBefore = m.staleAgainstScreen(detail, RID);
await refresh(detail); // 새 길 — 종단도가 맡긴 화면 선(B06 · 3D 와 같은 길)
out.staleNewPath = m.staleChainages(staleOf(detail, screen));
out.staleAgainstScreenAfter = m.staleAgainstScreen(detail, RID);
// B06 판정(다시 셈이 얹은 design_profiles)과 B05 판정(화면 선)이 같은가
out.staleB06View = m.staleChainages(
m.staleKey({
longitudinal: detail.longitudinal,
cross_sections: detail.cross_sections,
}),
);
release();
}
/* 2. 국부 연산 — 같은 입력 다시 셈 0 · 계획고 ▲ 한 번 = 몇 측점만 · 결과는 통째 셈과 같음 */
{
const detail = makeDetail();
const screen = m.buildAlignment(screenBase(detail), m.emptyEdits());
await refresh(detail, { alignment: screen });
await refresh(detail, { alignment: screen }); // 첫 셈이 결과 칸(측구 유무 등)을 채운 뒤 한 번 더 — 저장본 상태
const ids = () => detail.cross_sections.map((s) => s.design);
let before = ids();
await refresh(detail, { alignment: screen });
out.recomputedSame = ids().filter((d, i) => d !== before[i]).length;
const edited = m.buildAlignment(screenBase(detail), {
station_offsets: { "140.000": 0.3 },
curve_radii: {},
});
before = ids();
const changed = await refresh(detail, { alignment: edited });
out.recomputedEdit = ids().filter((d, i) => d !== before[i]).length;
out.changedEdit = changed.length;
out.total = detail.cross_sections.length;
// 통째 셈(새 측점 객체 = 기억 없음)과 결과 같음
const whole = makeDetail();
whole.cross_sections.forEach((s, i) => (s.design = { ...detail.cross_sections[i].design }));
await refresh(whole, { alignment: stored });
await refresh(whole, { alignment: edited });
out.sameAsWhole =
JSON.stringify(whole.cross_sections.map((s) => s.design)) ===
JSON.stringify(detail.cross_sections.map((s) => s.design));
}
/* 3. 다시 셈 뒤에도 남은 낡음 — 기다림(true) → 남음(false) · 콘솔 경고 · 다시 그림 */
{
const detail = makeDetail();
const screen = m.buildAlignment(screenBase(detail), m.emptyEdits());
await refresh(detail, { alignment: screen });
const broken = detail.cross_sections[3];
broken.samples = []; // 다시 셈이 못 고치는 측점
broken.design.design_elevation_m += 1;
const applied = [];
const preview = m.createCrossPreview({
projectId: PID,
detail: () => detail,
routeId: () => RID,
edits: () => m.emptyEdits(),
alignment: () => screen,
debounceMs: () => 0,
onApplied: (updated) => applied.push(updated.length),
});
const key = staleOf(detail, screen);
out.settleFirst = preview.scheduleIfStale(key);
await sleep(50);
const again = staleOf(detail, screen);
out.settleSameKey = again === key;
out.settleAfter = preview.scheduleIfStale(again);
out.settleWarns = warns.filter((w) => w.includes("낡은 측점")).length;
out.settleRedraws = applied.length;
out.settleClear = preview.scheduleIfStale("");
preview.dispose();
}
/* 4. 흙 배분 — 실패한 열쇠 다시 보냄 · 받은 열쇠는 다시 안 물음 */
{
const replies = [false, true, true];
const posts = [];
globalThis.fetch = async (_url, init) => {
posts.push(JSON.parse(init.body).result.points.length);
const ok = replies.shift() ?? true;
return {
ok,
json: async () => ({ haul_plan: { tag: JSON.parse(init.body).result.points.length } }),
};
};
let ready = 0;
const prefetch = m.createHaulPlanPrefetch(
PID,
() => RID,
() => (ready += 1),
);
const A = { points: [{ cumulative_volume_m3: 1 }, { cumulative_volume_m3: 2 }] };
const B = {
points: [{ cumulative_volume_m3: 1 }, { cumulative_volume_m3: 2 }, { cumulative_volume_m3: 3 }],
};
prefetch.schedule(A);
await sleep(450);
out.haulAfterFail = posts.length; // 1 — 실패
prefetch.schedule(A); // 같은 열쇠 — 다시 보냄
await sleep(450);
out.haulRetry = posts.length; // 2
prefetch.schedule(B);
await sleep(450);
out.haulB = posts.length; // 3
prefetch.schedule(A); // 받아 둔 열쇠 — 안 물음
await sleep(450);
out.haulKept = posts.length; // 3
out.haulKeptPlan = prefetch.current()?.tag; // 2 (A 의 점 2개)
out.haulReady = ready;
prefetch.dispose();
}
/* 5. [전체 측점 반영] — 다시 셈 한 번 · 측점 선택을 세션에 안 박음 */
{
const detail = makeDetail();
const screen = m.buildAlignment(screenBase(detail), m.emptyEdits());
const release = m.holdScreenAlignment((id) => (id === RID ? screen : null));
const cardsCalls = [];
const sync = m.createDesignSync({
projectId: PID,
routeId: () => RID,
detail: () => detail,
view: () => ({
refreshCards: (updated) => cardsCalls.push(updated.length),
refreshCard: () => cardsCalls.push(-1),
detailChart: () => null,
}),
});
const t0 = performance.now();
await sync.applyPanelToAll();
out.applyAllMs = performance.now() - t0;
out.applyAllCalls = cardsCalls;
out.applyAllChoiceKeys = sessionStorage.keys().filter((k) => k.includes("crossdesign")).length;
release();
}
/* 6. 횡단 카드 보이는 것 먼저 — B06_Section_UI_Section_View.ts 의 진짜 refreshCards 본문 */
{
const viewSrc = readFileSync(join(ROOT, "B06_Section", "B06_Section_UI_Section_View.ts"), "utf8");
const body = viewSrc.match(/\r?\n const refreshCards = [\s\S]*?\r?\n \};\r?\n/)[0];
const rebuilt = [];
let redraws = 0;
const top = { 0: 0, 20: 150, 40: 300, 60: 450, 80: 600 }; // 카드 위치(px) · 화면 = 0~320
const factory = new Function(
"cards",
"cardAt",
"rebuildCard",
"offscreen",
"currentDetail",
"redrawProfile",
"fillSlopeNotice",
"cachedStationInterval",
`${transpile(body)}\nreturn refreshCards;`,
);
let offscreen;
const rebuildCard = (c) => {
offscreen.queue.delete(c);
rebuilt.push(c);
return true;
};
offscreen = m.createIdleQueue(rebuildCard, 2);
const refreshCards = factory(
{ getBoundingClientRect: () => ({ top: 0, bottom: 320 }) },
(c) => ({
section: {},
el: { getBoundingClientRect: () => ({ top: top[c], bottom: top[c] + 140, height: 140 }) },
}),
rebuildCard,
offscreen,
{ cross_sections: [] },
() => (redraws += 1),
{ update: () => undefined },
20,
);
refreshCards([80, 0, 60, 20, 40]);
out.cardsNow = [...rebuilt];
out.cardsRedraws = redraws;
await sleep(120);
out.cardsAll = [...rebuilt];
}
process.stdout.write(JSON.stringify(out));
+2 -3
View File
@@ -80,9 +80,8 @@ def test_ts_defaults_off_and_stored_kept():
assert "twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope)," in _read(
"B06_Section/B06_Section_Cross_Refresh.ts"
)
assert "two_stage_slope: design.two_stage_slope ?? false," in _read(
"B06_Section/B06_Section_UI_Page_Design_Sync.ts"
)
# 지금 값을 「사용자 선택」으로 박는 길은 없앰(346-2 · 사용자 확정 10-16) — 선택은 세션 것만
assert "changeFromDesign" not in _read("B06_Section/B06_Section_UI_Page_Design_Sync.ts")
def test_3d_first_view_perspective():
+126
View File
@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
"""342-1 · 346-2 — 계획선 한 벌 · 낡음 머묾 · 국부 연산 · 흙 배분 열쇠 · [전체 측점 반영] · 카드.
못박는 것
- 머묾 재현: 서버 측점 85.054(0.001 m) · 관 지점 85.05(0.01 m) · 종단곡선 안 — 저장분 선으로
다시 세면 화면 선 기준 낡음이 남음(옛 길) · 화면 선을 받으면 낡음 0 · B05 · B06 판정 같음.
- 국부 연산: 같은 입력 다시 셈 0 측점 · 계획고 ▲ 한 번 = 곡선 둘레 몇 측점 · 결과 = 통째 셈.
- 다시 셈 뒤에도 남은 낡음 = 기다림 끝(false) · 콘솔 경고 한 줄 · 다시 그림(곡선은 그림).
- 흙 배분: 실패한 열쇠 다시 보냄 · 받아 둔 열쇠는 다시 안 물음.
- [전체 측점 반영] = 다시 셈 한 번 · 측점 선택을 세션에 안 박음.
- 횡단 카드: 보이는 카드만 바로 · 나머지는 쉬는 틈에.
Node 헬퍼 `helper_342_1_station_line.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_342_1_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 config.config_system import STANDARD_CROSS_SECTION # noqa: E402
FIXTURE = HERE / "fixtures" / "b05_192_1_moved_pipe_longitudinal.json"
@pytest.fixture(scope="module")
def out(tmp_path_factory):
work = tmp_path_factory.mktemp("b342")
build = subprocess.run(
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_342_1_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_342_1_entry.js", bundle)
policy = json.loads(FIXTURE.read_text(encoding="utf-8"))["profile_alignment"]["policy"]
source = work / "input.json"
source.write_text(
json.dumps({"standard": STANDARD_CROSS_SECTION, "policy": policy}, default=str),
encoding="utf-8",
)
proc = subprocess.run(
["node", str(HERE / "helper_342_1_station_line.mjs"), str(bundle), str(source)],
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):
assert 85.05 in out["screenStations"] and 85.054 not in out["screenStations"]
def test_머묾_재현_옛_길은_낡음이_남음(out):
assert out["staleBefore"] == [85.054]
assert out["staleOldPath"] == [85.054]
assert out["staleAgainstScreenBefore"] is True
def test_화면_선을_받으면_낡음_0_두_판정_같음(out):
assert out["staleNewPath"] == []
assert out["staleAgainstScreenAfter"] is False
assert out["staleB06View"] == []
def test_국부_연산_바뀐_측점만(out):
assert out["recomputedSame"] == 0
assert 0 < out["recomputedEdit"] < out["total"]
assert out["sameAsWhole"] is True
def test_남은_낡음은_곡선을_그리고_경고(out):
assert out["settleFirst"] is True # 다시 셈 기다림
assert out["settleSameKey"] is True # 다시 셈이 못 고침
assert out["settleAfter"] is False # 기다림 끝 — 곡선 그림
assert out["settleWarns"] == 1
assert out["settleRedraws"] >= 1 # 바뀐 것 없어도 다시 그림
assert out["settleClear"] is False
def test_흙_배분_실패_열쇠_다시_보냄_받은_열쇠는_안_물음(out):
assert out["haulAfterFail"] == 1
assert out["haulRetry"] == 2
assert out["haulB"] == 3
assert out["haulKept"] == 3
assert out["haulKeptPlan"] == 2
def test_전체_측점_반영은_한_번_선택_안_박음(out):
assert out["applyAllCalls"] == [out["total"]]
assert out["applyAllChoiceKeys"] == 0
def test_횡단_카드_보이는_것_먼저(out):
assert out["cardsNow"] == [0, 20, 40]
assert out["cardsRedraws"] == 1
assert sorted(out["cardsAll"]) == [0, 20, 40, 60, 80]