perf(B07): 도면을 미리 받아 두고, 커서 지연과 토스트 이질감을 걷는다

도면을 열 때마다 0.8~1.0초가 걸렸다. 서버가 요청마다 도면을 새로 조립하고(0.3~0.6초),
구조물 모듈이 공유 캐시를 건너뛰고 종횡단 상세를 매번 다시 받았기 때문이다(0.2초).
진입 후 첫 장을 띄우고 나서 목록 전체를 배경에서 받아 캐시에 담고, 구조물 모듈은
B05·B06과 같은 Section_Store를 쓴다. 클릭에서 렌더까지 55~65ms로 줄었다.

CAD 알림은 react-toastify라 프로젝트 공용 토스트와 모양이 달랐다. 호출부 30여 곳을
그대로 두려고 vite alias로 라이브러리 자리에 다리를 끼워, 부모가 있으면 메시지로 넘겨
부모 토스트로 띄운다. 백업 되살리기처럼 누르는 안내를 위해 showToast에 onClick을 더했다.

커서가 미묘하게 끌리던 것은 프레임이 느려서가 아니라(16.7ms, 60fps) 합성 파이프라인
때문이다. 화면 캔버스를 desynchronized+alpha:false로 만들어 그 큐를 건너뛴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 21:05:23 +09:00
co-authored by Claude Opus 5
parent bfaf32a043
commit 9c1cdf8f4d
7 changed files with 222 additions and 13 deletions
@@ -13,7 +13,7 @@
* ========================================================================== */
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { fetchSectionDetail } from "../B06_Section/B06_Section_Api_Fetch";
import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store";
import { appendBoxOverlay } from "../B06_Section/B06_Section_UI_Cross_Box";
import {
computeBoxLayout,
@@ -438,7 +438,9 @@ export async function appendStructureEntities(
if (!placements.length) return 0;
let sections: CrossSection[];
try {
sections = (await fetchSectionDetail(projectId, routeId)).cross_sections;
// B05·B06과 같은 공유 캐시를 쓴다 — 도면마다 새로 받으면 장을 넘길 때마다
// 종횡단 상세를 다시 내려받아 0.2초씩 더 걸린다(2026-08-30 실측).
sections = (await loadSectionDetail(projectId, routeId)).cross_sections;
} catch {
return 0;
}
+78 -8
View File
@@ -74,6 +74,10 @@ const CAD_CHANGED_MESSAGE = "aislo:b08:drawing-changed";
const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request";
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response";
const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate";
/** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시).
* CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */
const CAD_TOAST_MESSAGE = "aislo:b08:toast";
const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
/** 도면 구성 12분류 (2026-08-29 사용자 확정 순서). kind가 없으면 아직 만들지 않는 도면. */
const DRAWING_GROUPS: readonly { label: string; kind?: DesignDrawingItem["kind"] }[] = [
@@ -363,6 +367,48 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
pendingLoad = undefined;
};
/**
* 도면 캐시 — 진입 직후 **목록의 모든 도면**을 배경에서 받아 둔다(2026-08-30 사용자 지시).
*
* 서버가 도면을 요청마다 새로 조립해(측점 설계선·장 배치·표) 클릭 한 번에 0.3~0.6초,
* 구조물 얹기까지 더하면 0.8~1.0초가 걸렸다. 미리 받아 두면 클릭은 캐시에서 즉시 꺼낸다.
* sessionStorage가 아니라 **모듈 메모리**에 둔다 — 유역도 한 장이 엔티티 1,581개라
* 세션 저장 한도(5MB)를 넘길 위험이 있고, 새로고침이면 정본에서 다시 받는 편이 맞다.
*/
const drawingCache = new Map<string, Promise<DesignDrawingResponse>>();
/** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */
const requestDrawing = (drawing: DesignDrawingItem): Promise<DesignDrawingResponse> => {
const cached = drawingCache.get(drawing.id);
if (cached) return cached;
const request = (async () => {
const response = await fetchDesignDrawing(projectId as string, drawing.id);
// 구조물(배수관·기슭막이·세월교·BOX·물넘이포장)은 B06 산식이 프론트에 있어
// 여기서 얹는다. 확정본은 이미 구조물이 담겨 저장돼 있으므로 건드리지 않는다.
if (drawing.kind === "cross" && !response.confirmed) {
await appendStructureEntities(projectId as string, response.route_id, response.drawing);
}
return response;
})().catch((error) => {
drawingCache.delete(drawing.id); // 실패는 캐시에 남기지 않는다 — 다음 클릭에 다시 받는다.
throw error;
});
drawingCache.set(drawing.id, request);
return request;
};
/** 목록 전체를 순서대로 미리 받는다. 화면을 막지 않도록 한 번에 하나씩만 간다. */
const prefetchAllDrawings = async (): Promise<void> => {
if (!projectId) return;
for (const item of drawings) {
try {
await requestDrawing(item);
} catch {
/* 한 장을 못 받아도 나머지는 계속 받는다 — 클릭 때 다시 시도한다. */
}
}
};
const loadDrawing = async (drawing: DesignDrawingItem, index: number) => {
if (!projectId) return;
highlightActive(drawing.id);
@@ -370,12 +416,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
if (button) button.dataset.loading = "true";
cadHost.dataset.loading = "true";
try {
const response = await fetchDesignDrawing(projectId, drawing.id);
// 구조물(배수관·기슭막이·세월교·BOX·물넘이포장)은 B06 산식이 프론트에 있어
// 여기서 얹는다. 확정본은 이미 구조물이 담겨 저장돼 있으므로 건드리지 않는다.
if (drawing.kind === "cross" && !response.confirmed) {
await appendStructureEntities(projectId, response.route_id, response.drawing);
}
const response = await requestDrawing(drawing);
currentDrawing = drawing;
currentIndex = index;
currentConfirmed = response.confirmed;
@@ -427,6 +468,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
currentConfirmed = true;
currentDrawing.confirmed = true;
confirmButton.disabled = true;
drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다.
const button = findButton(currentDrawing.id);
if (button) button.dataset.confirmed = "true";
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다.
@@ -453,6 +496,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const invalidateCurrentDrawing = async () => {
if (!projectId || !currentDrawing) return;
const wasConfirmed = currentConfirmed;
drawingCache.delete(currentDrawing.id);
currentConfirmed = false;
allDrawingsConfirmed = false;
currentDrawing.confirmed = false;
@@ -479,8 +523,31 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
drawing?: CadDrawing;
quantityTable?: QuantityTable | null;
direction?: "prev" | "next";
kind?: string;
text?: string;
actionId?: string;
durationMs?: number;
};
if (message.type === CAD_READY_MESSAGE) {
if (message.type === CAD_TOAST_MESSAGE) {
const kind = (["info", "success", "warning", "error"] as const).find(
(item) => item === message.kind,
);
// autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다.
const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
const actionId = message.actionId;
showToast(
message.text ?? "",
kind ?? "info",
duration,
actionId
? () =>
frame.contentWindow?.postMessage(
{ type: CAD_TOAST_ACTION_MESSAGE, actionId },
window.location.origin,
)
: undefined,
);
} else if (message.type === CAD_READY_MESSAGE) {
cadReady = true;
if (pendingLoad) sendLoad(pendingLoad.drawing, pendingLoad.meta);
} else if (message.type === CAD_LOADED_MESSAGE) {
@@ -532,6 +599,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지.
// CAD가 아직 준비 전이면 sendLoad가 pendingLoad로 대기했다가 ready 시 전송한다.
if (drawings.length > 0) {
void loadDrawing(drawings[0], 0);
void loadDrawing(drawings[0], 0).then(() => {
// 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다.
void prefetchAllDrawings();
});
}
}
@@ -0,0 +1,106 @@
/**
* 토스트 다리 — CAD 앱의 알림을 **부모 페이지 토스트**로 띄운다.
*
* openwebcad 원본은 `react-toastify`를 쓴다. 임베드(B07 상세설계) 상태에서는 모양·자리·
* 닫기 버튼이 프로젝트 공용 토스트(`ui_template_elements.showToast`)와 달라 한 화면에
* 두 가지 토스트가 섞여 보였다(2026-08-30 사용자 지적). 그래서 부모가 있으면 메시지로
* 넘겨 부모가 자기 토스트로 띄우고, 단독 실행이면 예전처럼 직접 띄운다.
*
* 호출부는 그대로 `import { toast } from 'react-toastify'` 를 쓴다 — vite alias가 이
* 파일로 돌린다(`vite.config.ts`). 그래서 30여 개 파일을 고치지 않는다.
*/
const TOAST_MESSAGE = 'aislo:b08:toast';
const TOAST_ACTION_MESSAGE = 'aislo:b08:toast-action';
export type ToastKind = 'info' | 'success' | 'warning' | 'error';
interface ToastOptions {
/** false면 자동으로 닫지 않는다(부모에는 지속 시간으로 넘긴다). */
autoClose?: number | false;
onClick?: () => void;
}
/** 부모에서 눌린 토스트가 다시 찾아올 동작들. */
const actions = new Map<string, () => void>();
let actionSeq = 0;
let listening = false;
function embedded(): boolean {
return typeof window !== 'undefined' && window.parent !== window;
}
function listenForActions(): void {
if (listening || typeof window === 'undefined') return;
listening = true;
window.addEventListener('message', (event: MessageEvent<unknown>) => {
const data = event.data as { type?: string; actionId?: string } | null;
if (!data || data.type !== TOAST_ACTION_MESSAGE || !data.actionId) return;
const action = actions.get(data.actionId);
actions.delete(data.actionId);
action?.();
});
}
/** 단독 실행(부모 없음) 폴백 — 프로젝트 토스트와 같은 모양의 최소 구현. */
function showLocal(kind: ToastKind, text: string, options?: ToastOptions): void {
if (typeof document === 'undefined') return;
const host =
document.querySelector('.aislo-cad-toasts') ??
document.body.appendChild(
Object.assign(document.createElement('div'), { className: 'aislo-cad-toasts' })
);
const item = document.createElement('div');
item.className = `aislo-cad-toast aislo-cad-toast--${kind}`;
item.textContent = text;
if (options?.onClick) {
item.style.cursor = 'pointer';
item.addEventListener('click', () => {
options.onClick?.();
item.remove();
});
}
host.append(item);
if (options?.autoClose !== false) {
window.setTimeout(() => item.remove(), options?.autoClose ?? 3000);
}
}
function send(kind: ToastKind, text: string, options?: ToastOptions): void {
if (!embedded()) {
showLocal(kind, text, options);
return;
}
listenForActions();
let actionId: string | undefined;
if (options?.onClick) {
actionSeq += 1;
actionId = `toast-${actionSeq}`;
actions.set(actionId, options.onClick);
}
window.parent.postMessage(
{
type: TOAST_MESSAGE,
kind,
text,
actionId,
durationMs: options?.autoClose === false ? 0 : options?.autoClose,
},
window.location.origin
);
}
/** `react-toastify`의 toast와 같은 호출 모양만 맞춘 얇은 대체물. */
export const toast = {
info: (text: string, options?: ToastOptions) => send('info', text, options),
success: (text: string, options?: ToastOptions) => send('success', text, options),
warn: (text: string, options?: ToastOptions) => send('warning', text, options),
warning: (text: string, options?: ToastOptions) => send('warning', text, options),
error: (text: string, options?: ToastOptions) => send('error', text, options),
};
/** 원본 `ToastContainer` 자리 — 이제 그릴 것이 없다(부모가 그린다).
* 원본과 같은 props(position·theme 등)를 받아 넘겨도 조용히 무시한다. */
export function ToastContainer(_props?: Record<string, unknown>): null {
return null;
}
+7 -1
View File
@@ -143,7 +143,13 @@ function initApplication() {
if (canvas) {
setCanvas(canvas);
const context = canvas.getContext('2d');
// 저지연 캔버스 — 렌더러 합성 큐를 건너뛰어 커서·고무줄이 손에 붙는다
// (2026-08-30 커서가 미묘하게 끌린다는 지적. 프레임은 16.7ms로 정상이었고
// 남은 지연은 합성 파이프라인이었다). alpha:false는 불투명 캔버스로 만들어
// 합성을 한 겹 더 덜어 준다 — 도면 배경은 언제나 불투명하다.
// https://developer.chrome.com/blog/desynchronized
const context =
canvas.getContext('2d', { desynchronized: true, alpha: false }) ?? canvas.getContext('2d');
if (!context) return;
setEntities([], true); // Creates the first undo entry
@@ -15,6 +15,10 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"react-toastify": ["./src/helpers/toast-bridge.ts"]
},
"strict": true,
"noUnusedLocals": true,
+9 -1
View File
@@ -1,11 +1,19 @@
import { fileURLToPath } from 'node:url';
import react from '@vitejs/plugin-react-swc';
import {defineConfig} from 'vite';
import { defineConfig } from 'vite';
import svgr from 'vite-plugin-svgr'; // https://vitejs.dev/config/
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), svgr()],
base: './',
resolve: {
alias: {
// 알림은 부모 페이지 토스트로 넘긴다 — 호출부 30여 곳을 그대로 두려고
// 라이브러리 자리만 바꿔 끼운다(2026-08-30 사용자 지시).
'react-toastify': fileURLToPath(new URL('./src/helpers/toast-bridge.ts', import.meta.url)),
},
},
build: {
outDir: 'dist',
},
+14 -1
View File
@@ -288,12 +288,25 @@ let lastToast: { key: string; until: number } | null = null;
* 여러 번 나오는 경고가 화면을 덮었다. 앞 토스트가 아직 떠 있는 동안 온 같은 문구는
* 버린다. 문구가 다르거나 앞 토스트가 사라진 뒤라면 정상적으로 다시 뜬다.
*/
export function showToast(message: string, kind: ToastKind = "info", durationMs = 3000): void {
export function showToast(
message: string,
kind: ToastKind = "info",
durationMs = 3000,
/** 누르면 실행할 동작 — 지정하면 토스트가 클릭 대상이 된다(CAD 백업 되살리기 등). */
onClick?: () => void,
): void {
const key = `${kind}::${message}`;
const now = Date.now();
if (lastToast && lastToast.key === key && now < lastToast.until) return;
lastToast = { key, until: now + durationMs };
const toast = el("div", { className: `ui-toast ui-toast--${kind}`, text: message });
if (onClick) {
toast.style.cursor = "pointer";
toast.addEventListener("click", () => {
onClick();
toast.remove();
});
}
ensureToastContainer().append(toast);
// 진입 애니메이션 트리거
requestAnimationFrame(() => toast.classList.add("is-visible"));