From 3ec57791691c80f93d646d873b317d2cd746c008 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 17:52:01 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=EC=A2=85=EB=8B=A8=20=EC=83=88=20?= =?UTF-8?q?=EC=B0=BD=20=EC=B5=9C=EB=8C=80=ED=99=94=20=EB=95=8C=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=20=EB=86=93=EC=9D=B8=20=EC=B0=BD=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=8B=80=20=C2=B7=20=EC=8B=9C=EA=B3=84=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=ED=95=A8=EC=88=98=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC(230-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공용 common_util_view_window(viewOf · frameOf) 신설. design_profile rebuild · 접기 · render, Heights 높이 맞춤, 표 · 유토곡선 크기 끌기, 유토곡선 세로창 미끄러짐, 끌기 뒤 그리기, ▲▼ · 방향키 길게 누르기, 배수유역도, 구조물 말풍선 — 메인 창 전역 틀 · 시계 대신 요소가 놓인 창 것. 새 시험 test_230_2_popout_frames(메인 창 틀 멈춤 흉내 · 새 창 패널 코드 훑기). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/design/design_profile.ts | 7 +- A00_Common/design/design_profile_viewport.ts | 3 +- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 8 +- B05_Profile/B05_Profile_UI_Profile_Drag.ts | 12 +- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 19 +- B05_Profile/B05_Profile_UI_Profile_Heights.ts | 3 +- .../B05_Profile_UI_Profile_MassHaul.ts | 13 +- .../B05_Profile_UI_Profile_Panel_Tools.ts | 18 +- .../B05_Profile_UI_Profile_TableOverlay.ts | 3 +- B05_Profile/B05_Profile_UI_Profile_Wheel.ts | 9 +- .../B05_Profile_UI_Structures_Marks.ts | 4 +- common_util/common_util_mass_haul_view.ts | 15 +- common_util/common_util_view_window.ts | 18 ++ .../tester/helper_230_1_popout_wheel.cjs | 6 +- .../tester/helper_230_2_popout_frames.cjs | 227 ++++++++++++++++++ resources/tester/test_230_2_popout_frames.py | 105 ++++++++ 16 files changed, 429 insertions(+), 41 deletions(-) create mode 100644 common_util/common_util_view_window.ts create mode 100644 resources/tester/helper_230_2_popout_frames.cjs create mode 100644 resources/tester/test_230_2_popout_frames.py diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 8145a6ba3..7dda48d16 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -77,6 +77,7 @@ import { } from "../../B05_Profile/B05_Profile_UI_Profile_Guard"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import type { ElevationWindowResult } from "@util/common_util_chart_ywindow"; +import { frameOf } from "@util/common_util_view_window"; import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots"; import { noticeDroppedEdits } from "./design_save"; import { createProfileOverlays } from "./design_profile_overlays"; @@ -341,7 +342,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { scheduleCrossPreview(); if (redrawPending) return; redrawPending = true; - requestAnimationFrame(() => { + frameOf(root, () => { redrawPending = false; draw(); }); @@ -443,7 +444,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { }); // 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫). - bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw)); + bindProfileCollapse(root, panelHandle, () => frameOf(root, draw)); /** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */ function syncDrainageSpans(): void { @@ -482,7 +483,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { nextDetail.balloon_offsets ?? undefined, ); draw(); - requestAnimationFrame(draw); + frameOf(root, draw); // 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 — // 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D // 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인). diff --git a/A00_Common/design/design_profile_viewport.ts b/A00_Common/design/design_profile_viewport.ts index 7a9b7bb3a..3be92e541 100644 --- a/A00_Common/design/design_profile_viewport.ts +++ b/A00_Common/design/design_profile_viewport.ts @@ -6,6 +6,7 @@ import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel"; import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; +import { viewOf } from "@util/common_util_view_window"; /** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ const SCROLL_SETTLE_MS = 80; @@ -39,7 +40,7 @@ export function bindProfileViewport(deps: ProfileViewportDeps): () => void { const fitted = deps.fitElevation(); timerView.clearTimeout(scrollSettleTimer); if (!needsFullRedraw(fitted)) return; - timerView = body.ownerDocument.defaultView ?? window; + timerView = viewOf(body); scrollSettleTimer = timerView.setTimeout(() => { if (deps.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; settledScrollLeft = body.scrollLeft; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 28f7f2d5f..1deeea3c5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -1,5 +1,6 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; +import { viewOf } from "../common_util/common_util_view_window"; import { computeDetailBasins, fetchDetailPipePoints, @@ -172,6 +173,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra let offsetX = 0; let offsetY = 0; let frameHandle = 0; + /** 화면 틀은 판이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 틀은 멈춤(230-2). */ + let frameView: Window = window; let loadSequence = 0; let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택 /** 우클릭으로 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1). */ @@ -301,7 +304,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra function scheduleDraw(): void { if (frameHandle) return; - frameHandle = window.requestAnimationFrame(() => { + frameView = viewOf(root); + frameHandle = frameView.requestAnimationFrame(() => { frameHandle = 0; draw(); }); @@ -667,7 +671,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra dispose() { loadSequence += 1; if (frameHandle) { - window.cancelAnimationFrame(frameHandle); + frameView.cancelAnimationFrame(frameHandle); frameHandle = 0; } resizeObserver.disconnect(); diff --git a/B05_Profile/B05_Profile_UI_Profile_Drag.ts b/B05_Profile/B05_Profile_UI_Profile_Drag.ts index e3cbe853c..b5c22166a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Drag.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Drag.ts @@ -12,6 +12,7 @@ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_stat import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights"; import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; +import { viewOf } from "../common_util/common_util_view_window"; const MIN_CHART_HEIGHT = 100; /** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. @@ -123,9 +124,12 @@ export interface ProfileDrag { export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { const { root, body, massHaul, tableOverlay } = ctx; let resizeTimer = 0; + /** 시계는 종단이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */ + let timerView: Window = window; const scheduleDraw = (): void => { - window.clearTimeout(resizeTimer); - resizeTimer = window.setTimeout(ctx.draw, 120); + timerView.clearTimeout(resizeTimer); + timerView = viewOf(root); + resizeTimer = timerView.setTimeout(ctx.draw, 120); }; // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. @@ -211,9 +215,9 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { mainDragCooldown = false; }, scheduleDraw, - cancelDraw: () => window.clearTimeout(resizeTimer), + cancelDraw: () => timerView.clearTimeout(resizeTimer), dispose() { - window.clearTimeout(resizeTimer); + timerView.clearTimeout(resizeTimer); heightResizer.dispose(); window.removeEventListener("pointerup", clearDragFlags); window.removeEventListener("pointercancel", clearDragFlags); diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index babaca90c..2c653d574 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -23,6 +23,7 @@ import { useDesignStore, } from "../A00_Common/design/design_store"; import { clearState } from "../A00_Common/b_page_state"; +import { viewOf } from "../common_util/common_util_view_window"; /** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */ const BUTTON_CLEARANCE_PX = 23; @@ -38,13 +39,18 @@ export const HOLD_INTERVAL_MS = 100; * 버튼이 아니라 이 컨트롤러가 window 이벤트로 들고 있어야 반복이 끊기거나 * 반대로 멈추지 않는 상황이 생기지 않는다. */ -function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } { +function createHoldRepeater(node: Node): { + start: (action: () => void) => void; + stop: () => void; +} { let delayTimer = 0; let repeatTimer = 0; + /** 시계는 버튼 층이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */ + let timerView: Window = window; function stop(): void { - window.clearTimeout(delayTimer); - window.clearInterval(repeatTimer); + timerView.clearTimeout(delayTimer); + timerView.clearInterval(repeatTimer); delayTimer = 0; repeatTimer = 0; window.removeEventListener("pointerup", stop); @@ -54,8 +60,9 @@ function createHoldRepeater(): { start: (action: () => void) => void; stop: () = function start(action: () => void): void { stop(); - delayTimer = window.setTimeout(() => { - repeatTimer = window.setInterval(action, HOLD_INTERVAL_MS); + timerView = viewOf(node); + delayTimer = timerView.setTimeout(() => { + repeatTimer = timerView.setInterval(action, HOLD_INTERVAL_MS); }, HOLD_DELAY_MS); window.addEventListener("pointerup", stop); window.addEventListener("pointercancel", stop); @@ -329,7 +336,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { layer.className = "b05-profile-edit"; layer.style.width = `${width}px`; - const repeater = createHoldRepeater(); + const repeater = createHoldRepeater(layer); const edited = new Set(Object.keys(alignment.edits.station_offsets)); /** diff --git a/B05_Profile/B05_Profile_UI_Profile_Heights.ts b/B05_Profile/B05_Profile_UI_Profile_Heights.ts index 24561487d..3771fdf1e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Heights.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Heights.ts @@ -11,6 +11,7 @@ import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; import { STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; +import { frameOf } from "../common_util/common_util_view_window"; /** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */ const MIN_CHART_HEIGHT = 100; @@ -182,7 +183,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { function scheduleLightSync(): void { if (lightSyncPending) return; lightSyncPending = true; - requestAnimationFrame(() => { + frameOf(deps.body, () => { lightSyncPending = false; syncHeightsLight(); }); diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index a85a79bf7..2ffa158bc 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -56,6 +56,7 @@ import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; import "./B05_Profile_UI_Style_MassHaul.css"; import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; +import { frameOf } from "../common_util/common_util_view_window"; /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ /* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ @@ -235,7 +236,7 @@ export function createRouteMassHaulPanel( syncHandlePosition(); if (resizeRedrawPending) return; resizeRedrawPending = true; - requestAnimationFrame(() => { + frameOf(overlay, () => { resizeRedrawPending = false; onChanged(); }); @@ -488,9 +489,13 @@ export function createRouteMassHaulPanel( ); drawnOnce = true; // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다. - scheduleMassHaulSettle(windowState, () => { - if (lastParams) draw(lastParams); - }); + scheduleMassHaulSettle( + windowState, + () => { + if (lastParams) draw(lastParams); + }, + overlay, + ); } return { diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 61b8c5add..64127e8f2 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -10,6 +10,7 @@ * ========================================================================== */ import { isCtrlKey } from "../A00_Common/key_shortcut"; +import { viewOf } from "../common_util/common_util_view_window"; import type { AlignmentBase, AlignmentEdits, @@ -254,11 +255,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { * 건드린다 — 키를 뗀 뒤 이만큼 기다렸다 한 덩어리로 기록한다. */ const HOLD_SETTLE_MS = 700; let settleTimer = 0; + /** 시계는 패널이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */ + let timerView: Window = window; /** 반복을 끊고, 잠시 뒤 그 동안의 변화를 이력 한 덩어리로 남긴다. */ function stopHold(): void { - window.clearTimeout(holdDelayTimer); - window.clearInterval(holdRepeatTimer); + timerView.clearTimeout(holdDelayTimer); + timerView.clearInterval(holdRepeatTimer); holdDelayTimer = 0; holdRepeatTimer = 0; if (heldKey === null) return; @@ -267,8 +270,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { holdStation = null; window.removeEventListener("keyup", onKeyUp); window.removeEventListener("blur", stopHold); - window.clearTimeout(settleTimer); - settleTimer = window.setTimeout(() => { + timerView.clearTimeout(settleTimer); + settleTimer = timerView.setTimeout(() => { settleTimer = 0; history.resume(); }, HOLD_SETTLE_MS); @@ -278,7 +281,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { function flushHold(): void { stopHold(); if (!settleTimer) return; - window.clearTimeout(settleTimer); + timerView.clearTimeout(settleTimer); settleTimer = 0; history.resume(); } @@ -292,8 +295,9 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { heldKey = key; window.addEventListener("keyup", onKeyUp); window.addEventListener("blur", stopHold); - holdDelayTimer = window.setTimeout(() => { - holdRepeatTimer = window.setInterval(() => { + timerView = viewOf(ctx.root); // 앞선 덩어리는 flushHold 가 이미 닫음 + holdDelayTimer = timerView.setTimeout(() => { + holdRepeatTimer = timerView.setInterval(() => { if (!nudge(key)) stopHold(); // 더 움직일 곳이 없으면 스스로 멈춘다. }, HOLD_INTERVAL_MS); }, HOLD_DELAY_MS); diff --git a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts index ea11fbcec..e206a08f0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts +++ b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts @@ -15,6 +15,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { TABLE_ROW_COUNT } from "./B05_Profile_UI_Profile_Layout"; import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; +import { frameOf } from "../common_util/common_util_view_window"; /* 펼침·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ const HEIGHT_KEY = stateKey("table-height") ?? ""; @@ -87,7 +88,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa syncHandlePosition(); if (resizeRedrawPending) return; resizeRedrawPending = true; - requestAnimationFrame(() => { + frameOf(overlay, () => { resizeRedrawPending = false; onChanged(); }); diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts index 1f6aa5ed4..f1298db83 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -18,6 +18,8 @@ * 유토곡선·측점 테이블)이 함께 얻는다. * ========================================================================== */ +import { viewOf } from "../common_util/common_util_view_window"; + /** 프레임마다 남은 거리에서 좁히는 비율. 0.35면 60fps에서 약 0.13초에 도착한다 — * 눈으로는 이어져 보이면서, 멈춘 뒤 세로 눈금이 따라오는 시간도 예전만큼 짧다. */ const EASE = 0.35; @@ -37,13 +39,6 @@ const BASE_FRAME_MS = 1000 / 60; * 쓰면 한 번에 목표까지 튄다. */ const MAX_FRAME_MS = 64; -/** 스크롤러가 지금 놓인 창 — 종단을 새 창으로 빼면 그 창이다(230-1). - * 메인 창 전역 `requestAnimationFrame` 을 쓰면, 새 창을 최대화해 메인 창이 가려질 때 - * 브라우저가 메인 창 프레임을 멈춰 휠 이동이 한 걸음도 안 나갔다. */ -function viewOf(scroller: HTMLElement): Window { - return scroller.ownerDocument.defaultView ?? window; -} - export function attachWheelHorizontalScroll(scroller: HTMLElement): void { let target = -1; /** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index a2a03a295..9f883cc85 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -22,6 +22,7 @@ import { import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; import { balloonLines, type ContainerCount } from "./B05_Profile_UI_Structures_Balloon"; import { stationLabel as formatStation } from "../common_util/common_util_station"; +import { frameOf } from "../common_util/common_util_view_window"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 * 보지 않고 시각적으로도 움직이지 않는다. 예전 10px 누적 방식은 손떨림이 쌓여 @@ -268,7 +269,8 @@ function buildBalloon( return span; }), ); - requestAnimationFrame(() => flipIfClipped(balloon)); + // 붙은 뒤 그 창의 틀에서 잼 — 만들 때는 아직 메인 문서 소속이라 새 창 최대화 때 멈춤(230-2). + queueMicrotask(() => frameOf(balloon, () => flipIfClipped(balloon))); return balloon; } diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index 06149fb23..95d618d03 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -23,6 +23,7 @@ import { MASS_HAUL_BALANCE_KEY } from "./common_util_mass_haul"; import { appendBalanceLayer, haulPlanChips } from "./common_util_mass_haul_balance_view"; import { segmentOf } from "./common_util_mass_haul_curve"; import { L, stationLabel, svgElement, svgText } from "./common_util_svg"; +import { viewOf } from "./common_util_view_window"; /** * 유토곡선 X축을 **호출한 쪽 종단 그래프와 맞추는** 값. 좌우 여백과 누가거리 최댓값이 @@ -74,6 +75,8 @@ export interface MassHaulWindowState { settled: boolean; /** 예약해 둔 다음 프레임(중복 예약 방지). */ frame: number; + /** `frame` 을 받아 둔 창 — 걷을 때도 같은 창에서(230-2). */ + view?: Window; } export function createMassHaulWindowState(): MassHaulWindowState { @@ -81,11 +84,17 @@ export function createMassHaulWindowState(): MassHaulWindowState { } /** 아직 목표에 못 갔으면 다음 프레임에 한 번 더 그리게 예약한다. */ -export function scheduleMassHaulSettle(state: MassHaulWindowState, redraw: () => void): void { - if (state.frame) cancelAnimationFrame(state.frame); +/** `node` = 곡선이 놓인 요소 — 그 창의 틀에서 잰다(새 창 최대화 때 메인 창 틀은 멈춤 · 230-2). */ +export function scheduleMassHaulSettle( + state: MassHaulWindowState, + redraw: () => void, + node?: Node, +): void { + if (state.frame) (state.view ?? window).cancelAnimationFrame(state.frame); state.frame = 0; if (state.settled) return; - state.frame = requestAnimationFrame(() => { + state.view = viewOf(node); + state.frame = state.view.requestAnimationFrame(() => { state.frame = 0; redraw(); }); diff --git a/common_util/common_util_view_window.ts b/common_util/common_util_view_window.ts new file mode 100644 index 000000000..88070a390 --- /dev/null +++ b/common_util/common_util_view_window.ts @@ -0,0 +1,18 @@ +/* ============================================================================= + * common_util_view_window.ts + * 요소가 지금 놓인 창 — 종단 패널을 새 창으로 빼면 그 창이다(230-1 · 230-2). + * + * 메인 창 전역 `requestAnimationFrame` · 시계를 쓰면, 새 창을 최대화해 메인 창이 가려질 때 + * 브라우저가 메인 창 화면 틀을 멈추고 시계를 늦춰 휠 · 편집 · 다시 그리기가 멈췄다. + * 화면 틀 · 시계는 이 함수로 그 요소의 창에서 받는다. + * ========================================================================== */ + +/** 요소가 놓인 창(문서에 안 붙었으면 메인 창). */ +export function viewOf(node: Node | null | undefined): Window { + return node?.ownerDocument?.defaultView ?? window; +} + +/** 요소가 놓인 창의 다음 화면 틀에 부른다. */ +export function frameOf(node: Node | null | undefined, callback: FrameRequestCallback): number { + return viewOf(node).requestAnimationFrame(callback); +} diff --git a/resources/tester/helper_230_1_popout_wheel.cjs b/resources/tester/helper_230_1_popout_wheel.cjs index 57b2cb4fe..b05bb981b 100644 --- a/resources/tester/helper_230_1_popout_wheel.cjs +++ b/resources/tester/helper_230_1_popout_wheel.cjs @@ -79,9 +79,13 @@ globalThis.ResizeObserver = class { disconnect() {} }; -const wheelMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts")); +const viewWindow = load(path.join(ROOT, "common_util", "common_util_view_window.ts")); +const wheelMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts"), { + "../common_util/common_util_view_window": viewWindow, +}); const viewport = load(path.join(ROOT, "A00_Common", "design", "design_profile_viewport.ts"), { "../../B05_Profile/B05_Profile_UI_Profile_Wheel": wheelMod, + "@util/common_util_view_window": viewWindow, "@util/common_util_chart_ywindow": { needsFullRedraw: () => true }, }); diff --git a/resources/tester/helper_230_2_popout_frames.cjs b/resources/tester/helper_230_2_popout_frames.cjs new file mode 100644 index 000000000..00fbd81f7 --- /dev/null +++ b/resources/tester/helper_230_2_popout_frames.cjs @@ -0,0 +1,227 @@ +/* 종단 새 창 최대화 — 같은 꼴 나머지(계획서 230-2) 단위검증 헬퍼. + * 메인 창은 「가려짐」(화면 틀 · 시계 멈춤) · 종단 요소는 새 창 소속. 실제 TS 의 해당 함수 본문을 + * 잘라 그 자리에서 트랜스파일해 돌리고 JSON 으로 냄 — 다시 그리기 · 높이 맞춤 · 표 · 유토곡선 · + * 끌기 뒤 그리기 · 길게 누르기 · 배수유역도가 새 창 틀 · 시계로 도는지. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), "utf8"); +const run = (code, scope) => { + const js = ts.transpileModule(code, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const names = Object.keys(scope); + return new Function(...names, js)(...names.map((n) => scope[n])); +}; +/** `start` 부터 짝 맞는 닫는 중괄호까지 잘라 냄 — 본문 여는 괄호가 반환 타입 뒤면 `open` 으로 짚음. */ +function block(src, start, open = "{") { + const at = src.indexOf(start); + if (at < 0) throw new Error(`본문 없음: ${start}`); + let depth = 0; + for (let i = src.indexOf(open, at) + open.length - 1; i < src.length; i++) { + if (src[i] === "{") depth++; + else if (src[i] === "}" && --depth === 0) return src.slice(at, i + 1); + } + throw new Error(`닫힘 없음: ${start}`); +} + +// ---- 가짜 창 — frozen 이면 화면 틀 · 시계가 안 돎 ---- +function fakeWindow(name, frozen) { + const win = { name, frozen, now: 0, timers: [], seq: 0, frames: 0, listeners: [] }; + win.setTimeout = (fn, ms) => (win.timers.push({ id: ++win.seq, at: win.now + ms, fn }), win.seq); + win.clearTimeout = (id) => (win.timers = win.timers.filter((t) => t.id !== id)); + win.setInterval = (fn, ms) => { + const id = ++win.seq; + const again = (at) => win.timers.push({ id, at, fn: () => (fn(), again(win.now + ms)) }); + again(win.now + ms); + return id; + }; + win.clearInterval = win.clearTimeout; + win.requestAnimationFrame = (fn) => win.setTimeout(() => (win.frames++, fn(win.now)), 16); + win.cancelAnimationFrame = win.clearTimeout; + win.addEventListener = (type, fn) => win.listeners.push([type, fn]); + win.removeEventListener = (type, fn) => + (win.listeners = win.listeners.filter(([t, f]) => t !== type || f !== fn)); + win.tick = (ms) => { + if (win.frozen) return; + const end = win.now + ms; + for (;;) { + const next = win.timers.filter((t) => t.at <= end).sort((a, b) => a.at - b.at)[0]; + if (!next) break; + win.now = next.at; + win.clearTimeout(next.id); + next.fn(); + } + win.now = end; + }; + return win; +} + +const main = fakeWindow("main", true); +const popup = fakeWindow("popup", false); +globalThis.window = main; +globalThis.requestAnimationFrame = main.requestAnimationFrame; // 옛 코드가 쓰던 전역 = 메인 창 +globalThis.cancelAnimationFrame = main.cancelAnimationFrame; +const inPopup = () => ({ ownerDocument: { defaultView: popup }, style: { setProperty() {} } }); + +const vw = {}; +run( + read("common_util", "common_util_view_window.ts").replace(/export /g, "") + + "\nObject.assign(vw, { viewOf, frameOf });", + { vw }, +); +const { viewOf, frameOf } = vw; +const out = {}; +const mainBusy = () => main.timers.length; + +// ① 공용 함수 — 새 창 요소는 새 창 · 안 붙은 요소는 메인 창 +out.viewOf = { + popup: viewOf(inPopup()).name, + detached: viewOf({ ownerDocument: { defaultView: null } }).name, + none: viewOf(null).name, +}; + +// ② 계획고 편집 뒤 다시 그리기(design_profile rebuild) — 두 번 불러도 한 번만 그림 +{ + const s = { draws: 0, previews: 0 }; + const src = block( + read("A00_Common", "design", "design_profile.ts"), + "function rebuild(): void {", + ); + run(`let redrawPending = false; ${src}\nrebuild(); rebuild(); s.rebuild = true;`, { + s, + base: null, + alignment: null, + buildAlignment: () => null, + store: { edits: () => ({}) }, + scheduleCrossPreview: () => s.previews++, + draw: () => s.draws++, + root: inPopup(), + frameOf, + }); + popup.tick(50); + out.rebuild = { draws: s.draws, previews: s.previews, mainTimers: mainBusy() }; +} + +// ③ 높이 맞춤(Heights scheduleLightSync) +{ + const s = { syncs: 0 }; + const src = block( + read("B05_Profile", "B05_Profile_UI_Profile_Heights.ts"), + "function scheduleLightSync(): void {", + ); + run(`let lightSyncPending = false; ${src}\nscheduleLightSync(); scheduleLightSync();`, { + deps: { body: inPopup() }, + syncHeightsLight: () => s.syncs++, + frameOf, + }); + popup.tick(50); + out.heights = { syncs: s.syncs, mainTimers: mainBusy() }; +} + +// ④ 표 · 유토곡선 크기 끌기(TableOverlay · MassHaul onResize) +for (const [key, file] of [ + ["table", "B05_Profile_UI_Profile_TableOverlay.ts"], + ["massHaul", "B05_Profile_UI_Profile_MassHaul.ts"], +]) { + const s = { changed: 0, handles: 0 }; + const src = block(read("B05_Profile", file), "onResize: () => {").replace( + /^onResize: /, + "const onResize = ", + ); + run(`let resizeRedrawPending = false; ${src}\nonResize(); onResize();`, { + syncHandlePosition: () => s.handles++, + onChanged: () => s.changed++, + overlay: inPopup(), + frameOf, + }); + popup.tick(50); + out[key] = { changed: s.changed, handles: s.handles, mainTimers: mainBusy() }; +} + +// ⑤ 유토곡선 세로창 미끄러짐(scheduleMassHaulSettle) +{ + const s = { redraws: 0 }; + const src = block( + read("common_util", "common_util_mass_haul_view.ts"), + "export function scheduleMassHaulSettle(", + ).replace(/^export /, ""); + const state = { held: null, settled: false, frame: 0 }; + run( + `${src}\nscheduleMassHaulSettle(state, () => s.redraws++, node); scheduleMassHaulSettle(state, () => s.redraws++, node);`, + { + s, + state, + node: inPopup(), + viewOf, + }, + ); + popup.tick(50); + out.settle = { + redraws: s.redraws, + frame: state.frame, + view: state.view?.name, + mainTimers: mainBusy(), + }; +} + +// ⑥ 끌기 뒤 다시 그리기(Profile_Drag scheduleDraw · 120ms 모음) +{ + const s = { draws: 0 }; + const src = block( + read("B05_Profile", "B05_Profile_UI_Profile_Drag.ts"), + "const scheduleDraw = (): void => {", + ); + run(`let resizeTimer = 0; let timerView = window; ${src}\nscheduleDraw(); scheduleDraw();`, { + window: main, + root: inPopup(), + ctx: { draw: () => s.draws++ }, + viewOf, + }); + popup.tick(200); + out.drag = { draws: s.draws, mainTimers: mainBusy() }; +} + +// ⑦ ▲▼ 길게 누르기(Profile_Edit createHoldRepeater) — 0.5초 뒤 초당 10회 +{ + const s = { acts: 0 }; + const src = block( + read("B05_Profile", "B05_Profile_UI_Profile_Edit.ts"), + "function createHoldRepeater(", + "} {", + ); + run(`${src}\nconst r = createHoldRepeater(node); r.start(() => s.acts++); s.r = r;`, { + s, + window: main, + node: inPopup(), + viewOf, + HOLD_DELAY_MS: 500, + HOLD_INTERVAL_MS: 100, + }); + popup.tick(1000); + const acts = s.acts; + s.r.stop(); + popup.tick(1000); + out.hold = { acts, afterStop: s.acts, mainTimers: mainBusy(), popupTimers: popup.timers.length }; +} + +// ⑧ 배수유역도(Drainage_Panel scheduleDraw) +{ + const s = { draws: 0 }; + const src = block( + read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts"), + "function scheduleDraw(): void {", + ); + run(`let frameHandle = 0; let frameView = window; ${src}\nscheduleDraw(); scheduleDraw();`, { + window: main, + root: inPopup(), + draw: () => s.draws++, + viewOf, + }); + popup.tick(50); + out.drainage = { draws: s.draws, mainTimers: mainBusy() }; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_230_2_popout_frames.py b/resources/tester/test_230_2_popout_frames.py new file mode 100644 index 000000000..a0f51bd03 --- /dev/null +++ b/resources/tester/test_230_2_popout_frames.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +"""종단 새 창 최대화 — 같은 꼴 나머지(계획서 230-2) 회귀검증. + +230-1 과 같은 까닭 — 메인 창 전역 화면 틀 · 시계는 새 창 최대화로 메인 창이 가려지면 멈추거나 +늦게 돎. 새 창으로 가는 종단 패널(종단도 · 표 · 유토곡선 · 배수유역도)의 틀 · 시계는 공용 +`viewOf` · `frameOf`(common_util_view_window) 로 그 요소가 놓인 창에서 받음. + +`helper_230_2_popout_frames.cjs` 가 메인 창을 「가려짐」(틀 · 시계 멈춤)으로 두고 실제 함수 본문을 돌림. +옛 코드로는 아래 다시 그리기가 모두 0 번. +""" + +import json +import os +import re +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) + +# 새 창으로 가는 종단 패널의 화면 코드 +POPOUT_FILES = [ + "A00_Common/design/design_profile.ts", + "A00_Common/design/design_profile_viewport.ts", + "B05_Profile/B05_Profile_UI_Profile_Wheel.ts", + "B05_Profile/B05_Profile_UI_Profile_Heights.ts", + "B05_Profile/B05_Profile_UI_Profile_MassHaul.ts", + "B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts", + "B05_Profile/B05_Profile_UI_Profile_Drag.ts", + "B05_Profile/B05_Profile_UI_Profile_Edit.ts", + "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts", + "B05_Profile/B05_Profile_UI_Drainage_Panel.ts", + "B05_Profile/B05_Profile_UI_Structures_Marks.ts", + "common_util/common_util_mass_haul_view.ts", +] +# 메인 창 전역 틀 · 시계 — `x.requestAnimationFrame` 처럼 창을 짚은 것은 제외 +MAIN_WINDOW_CLOCK = re.compile( + r"(?