diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index a8bd7e01..b4458007 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -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; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 85269908..0201b741 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -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 { pendingLoad = undefined; }; + /** + * 도면 캐시 — 진입 직후 **목록의 모든 도면**을 배경에서 받아 둔다(2026-08-30 사용자 지시). + * + * 서버가 도면을 요청마다 새로 조립해(측점 설계선·장 배치·표) 클릭 한 번에 0.3~0.6초, + * 구조물 얹기까지 더하면 0.8~1.0초가 걸렸다. 미리 받아 두면 클릭은 캐시에서 즉시 꺼낸다. + * sessionStorage가 아니라 **모듈 메모리**에 둔다 — 유역도 한 장이 엔티티 1,581개라 + * 세션 저장 한도(5MB)를 넘길 위험이 있고, 새로고침이면 정본에서 다시 받는 편이 맞다. + */ + const drawingCache = new Map>(); + + /** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */ + const requestDrawing = (drawing: DesignDrawingItem): Promise => { + 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 => { + 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 { 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 { 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 { 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 { 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 { // 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지. // CAD가 아직 준비 전이면 sendLoad가 pendingLoad로 대기했다가 ready 시 전송한다. if (drawings.length > 0) { - void loadDrawing(drawings[0], 0); + void loadDrawing(drawings[0], 0).then(() => { + // 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다. + void prefetchAllDrawings(); + }); } } diff --git a/B07_DesignDetail/openwebcad/src/helpers/toast-bridge.ts b/B07_DesignDetail/openwebcad/src/helpers/toast-bridge.ts new file mode 100644 index 00000000..95db4067 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/toast-bridge.ts @@ -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 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) => { + 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): null { + return null; +} diff --git a/B07_DesignDetail/openwebcad/src/main.tsx b/B07_DesignDetail/openwebcad/src/main.tsx index 19cd7387..7437e0db 100644 --- a/B07_DesignDetail/openwebcad/src/main.tsx +++ b/B07_DesignDetail/openwebcad/src/main.tsx @@ -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 diff --git a/B07_DesignDetail/openwebcad/tsconfig.app.json b/B07_DesignDetail/openwebcad/tsconfig.app.json index b1132c2e..26a85a54 100644 --- a/B07_DesignDetail/openwebcad/tsconfig.app.json +++ b/B07_DesignDetail/openwebcad/tsconfig.app.json @@ -15,6 +15,10 @@ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "react-toastify": ["./src/helpers/toast-bridge.ts"] + }, "strict": true, "noUnusedLocals": true, diff --git a/B07_DesignDetail/openwebcad/vite.config.ts b/B07_DesignDetail/openwebcad/vite.config.ts index 54bfb012..231cd06f 100644 --- a/B07_DesignDetail/openwebcad/vite.config.ts +++ b/B07_DesignDetail/openwebcad/vite.config.ts @@ -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', }, diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 3cb7d4fa..0c87f8a9 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -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"));