perf(B06): 54-22 S1 횡단 보기 ▲▼ 멈춤 되돌림 — 120ms 디바운스 · 종단도 한 번 · 바뀐 카드만

- 횡단 재계산 대기: 횡단 보기 120ms · 종단 보기 16ms 그대로(실시간)
- 계획고 편집 반영 알림을 받은 횡단 보기는 카드만 갈아 끼움 — 종단도는 보낸 쪽이 이미 그림
- 숨은 횡단 보기는 알림 건너뜀 — 다시 보일 때 정합이 카드를 맞춤
- 재계산이 실제 값이 바뀐 측점만 돌려줌 — ▲ 한 번에 66장 전부 갈아 끼우던 것 → 3장
- 실측(sub7_검증 · 66 측점 · 540m ▲): 한 번 최장 577~585 → 58~81ms · 2.5초 길게 누적 1,468 → 236~282ms
- B05 ▲: 50ms 넘는 멈춤 0 · 누른 뒤 55~67ms 반영 · 길게 누름 중 100ms 마다 따라옴

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
2026-10-06 12:38:41 +09:00
co-authored by Claude Opus 5.5
parent c8ca79063c
commit 717c28a956
6 changed files with 105 additions and 9 deletions
+3 -2
View File
@@ -121,7 +121,8 @@ export interface DesignProfileContext {
* 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 —
* 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다.
* 60→16ms(2026-09-06) — ▲ 한 번에 라벨이 서기까지 중앙 130ms 를 쟀는데 그중 60ms 가
* 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다. */
* 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다.
* 횡단 보기는 120ms — 계산마다 카드를 갈아 끼워 길게 누르면 멈춤이 쌓임(9da48d5e7 · 54-22). */
const CROSS_PREVIEW_DEBOUNCE_MS = 16;
export function createDesignProfile(ctx: DesignProfileContext) {
@@ -384,7 +385,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
detail: () => detail,
routeId: () => routeId,
edits: () => store.edits(),
debounceMs: CROSS_PREVIEW_DEBOUNCE_MS,
debounceMs: () => (root.dataset.view === "cross" ? 120 : CROSS_PREVIEW_DEBOUNCE_MS),
onApplied: (updated) => {
draw();
callbacks?.onCrossDesignsUpdated?.(updated);
@@ -18,7 +18,8 @@ export interface CrossPreviewContext {
detail: () => SectionDetailResponse | null;
routeId: () => number | null;
edits: () => AlignmentEdits;
debounceMs: number;
/** 부를 때마다 읽음 — 보기마다 다름. */
debounceMs: () => number;
/** 반영이 끝난 뒤 다시 그린다 — 다시 계산한 측점 누가거리를 넘긴다(횡단 카드 갱신용). */
onApplied: (updated: number[]) => void;
}
@@ -61,7 +62,7 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview {
.catch(() => {
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
});
}, ctx.debounceMs);
}, ctx.debounceMs());
},
dispose() {
window.clearTimeout(timer);
+6 -1
View File
@@ -124,8 +124,11 @@ function preserveUserFields(
/**
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22).
*/
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
const sections = input.detail.cross_sections;
const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)]));
const updated = refreshLocally(input) ?? (await refreshFromServer(input));
// 구조물이 선 측점은 폐회로 면적이 정본이다 — 설계를 갈아 끼운 **이 자리에서 한 번** 다시
// 얹는다. 부르는 쪽마다 얹으면 B06 흐름만 얹고 B05 는 안 얹어 두 보기의 유토 뱃지가
@@ -136,7 +139,9 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<num
structureAreaRows(input.detail.cross_sections),
);
}
return updated;
return sections
.filter((section) => JSON.stringify(section.design) !== before.get(section))
.map((section) => section.chainage_m);
}
/**
+6 -3
View File
@@ -121,7 +121,8 @@ export function createSectionView(
else revealCard(stationId, "smooth");
},
edit: () => structureEdit,
refreshCards: (chainages) => refreshCards(chainages),
// 종단도는 보낸 쪽(계획고 편집 반영)이 이미 다시 그림 — 카드만(54-22)
refreshCards: (chainages) => refreshCards(chainages, false),
},
shared as DesignPageShared,
);
@@ -390,10 +391,12 @@ export function createSectionView(
* 측점마다 `refreshCard` 를 부르면 측점 수만큼 종단 그래프·유토곡선을 다시 그려,
* 계획선 ▲▼ 한 번에 화면이 몇 백 ms 씩 멈췄다(2026-09-12 B05 대비 느림 원인).
*/
const refreshCards = (chainages: ReadonlyArray<number>): void => {
const refreshCards = (chainages: ReadonlyArray<number>, redraw = true): void => {
let touched = false;
for (const chainageM of chainages) if (rebuildCard(chainageM)) touched = true;
if (touched) redrawProfile();
if (!touched || !currentDetail) return;
if (redraw) redrawProfile();
else fillSlopeNotice.update(currentDetail.cross_sections, cachedStationInterval);
};
const resizeObserver = new ResizeObserver(() => {
@@ -180,7 +180,10 @@ export function createSectionProfile(
profile: {
onSelectStation: (stationId) => host.selectCard(cardOf(stationId)),
callbacks: {
onCrossDesignsUpdated: (updated) => host.refreshCards(updated),
// 숨은 횡단 보기는 건너뜀 — 다시 보일 때 정합이 카드를 맞춤(onShow)
onCrossDesignsUpdated: (updated) => {
if (shared.view() === "cross") host.refreshCards(updated);
},
onStructureMove: (from, to, station) => {
if (isPipeStation(station)) host.edit()?.movePipe(from, to);
},
@@ -0,0 +1,83 @@
/* 54-22 — 종단 점 ▲ 누름 멈춤(긴 작업) 재기: B06 은 120ms 디바운스 · 종단도 한 번 · B05 는 실시간 그대로
* 시작 화면: B06(#/b06-section) 또는 B05(#/b05-profile) · 검증 프로젝트 · 로그인
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5422 = "단계";` · 긴 단계는 `void` 로 띄우고
* `sessionStorage.getItem("__5422_out_{단계}")` 로 읽음
* snap → 처음 초안(세션 원문)을 `__5422_orig` 에 남김(이미 있으면 그대로)
* one → 가운데 측점 ▲ 한 번 — 긴 작업 최장 · 합 · 횡단 재계산까지 걸린 때
* hold → 가운데 측점 ▲ 2.5초 길게 — 긴 작업 최장 · 합 · 재계산 횟수
* restore → 처음 초안 원문으로 되돌림 → 그 뒤 orca reload
* 판정: B06 one 최장 ≈200ms 안팎 · hold 합 ≈0.5초 안팎(고치기 전 587 / 2,684ms) ·
* B05 one 재계산 때 = 16ms 디바운스 근처(실시간)
* 쓰는 것: sessionStorage(계획선 초안) — 정본(서버)은 안 씀
*/
(async () => {
const MODE = window.__m5422 || "one";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const DRAFTS = /^aislo:draft:/;
const out = { mode: MODE, page: location.hash };
const save = () => sessionStorage.setItem(`__5422_out_${MODE}`, JSON.stringify(out));
if (MODE === "snap") {
if (!sessionStorage.getItem("__5422_orig")) {
const orig = Object.fromEntries(
Object.keys(sessionStorage)
.filter((k) => DRAFTS.test(k))
.map((k) => [k, sessionStorage.getItem(k)]),
);
sessionStorage.setItem("__5422_orig", JSON.stringify(orig));
}
out.keys = Object.keys(JSON.parse(sessionStorage.getItem("__5422_orig")));
save();
return JSON.stringify(out);
}
if (MODE === "restore") {
const orig = JSON.parse(sessionStorage.getItem("__5422_orig") || "null");
if (!orig) return "no orig";
for (const k of Object.keys(sessionStorage)) if (DRAFTS.test(k)) sessionStorage.removeItem(k);
for (const [k, v] of Object.entries(orig)) sessionStorage.setItem(k, v);
for (const k of Object.keys(sessionStorage))
if (k.startsWith("__5422")) sessionStorage.removeItem(k);
return JSON.stringify({ restored: Object.keys(orig) });
}
const ups = [...document.querySelectorAll(".b05-profile-edit__btn.is-up")];
const button = ups[Math.floor(ups.length / 2)];
if (!button) {
out.error = "▲ 없음";
save();
return JSON.stringify(out);
}
out.button = button.title.split("\n")[0];
const tasks = [];
const observer = new PerformanceObserver((list) =>
list.getEntries().forEach((e) => tasks.push(Math.round(e.duration))),
);
observer.observe({ type: "longtask" });
// 횡단 재계산이 카드 · 캐시에 닿은 때 — 계획선 초안 말고 다른 캐시 쓰기
const writes = [];
const t0 = performance.now();
const origSet = Storage.prototype.setItem;
Storage.prototype.setItem = function (k, v) {
if (this === sessionStorage && !k.startsWith("__5422"))
writes.push([Math.round(performance.now() - t0), k.replace(/:[0-9a-f-]{36}.*/, "")]);
return origSet.call(this, k, v);
};
// 화면이 바뀐 때(ms) — 누른 뒤 재계산 반영이 닿는 때를 봄(B05 ≈16ms 디바운스 · B06 ≈120ms)
const domAt = new Set();
const mo = new MutationObserver(() => domAt.add(Math.round(performance.now() - t0)));
mo.observe(document.body, { subtree: true, childList: true, characterData: true });
button.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1 }));
await w(MODE === "hold" ? 2500 : 60);
window.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }));
await w(2500);
Storage.prototype.setItem = origSet;
observer.disconnect();
mo.disconnect();
out.domAt = [...domAt].slice(0, 12);
out.longTasks = tasks;
out.max = tasks.length ? Math.max(...tasks) : 0;
out.sum = tasks.reduce((a, b) => a + b, 0);
out.writes = writes;
save();
return JSON.stringify(out);
})();