Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8

This commit is contained in:
2026-10-10 17:59:31 +09:00
17 changed files with 430 additions and 41 deletions
+4 -3
View File
@@ -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 사용자 보고 · 실측 확인).
+2 -1
View File
@@ -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;
+6 -2
View File
@@ -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();
+8 -4
View File
@@ -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);
+13 -6
View File
@@ -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));
/**
@@ -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();
});
@@ -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 {
@@ -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);
@@ -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();
});
+2 -7
View File
@@ -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;
/** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */
@@ -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;
}
+12 -3
View File
@@ -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();
});
+18
View File
@@ -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);
}
@@ -128,3 +128,4 @@
102. B05에서 종단 새창으로 띄우고 브라우져 최대화한경우 마우스 스크롤로 종단을 움직이는 기능이 작동이 안됨. 최대화를 안하면 작동됨.
103. 노면공 범위 관련은 1번 방향이 더 좋을것같아. 대신 하이라이트는 여러군데 연동되어 있어 좀더 폭넓게 조사해보고 작동해줘.
104. 3D의 측점의 노선을 선택할때 하이라이트되는데 이게 바로 꺼지는 상황이야. 일단 하이라이트가 계속 문제되는데 전체적인 조사가 필요해. 일단 조사부터 해줘. (조사)
105. 추가로 하이라이트에서 소수점 단위 구조물 측점들의 경우 1단위로 사사오입하여 측점을 지정하게 반영했던걸로 기억하는데 언제 했고 어떤식으로 했는지 알아보고 어떻게 개선할지 조사만 해봐. (조사)
@@ -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 },
});
@@ -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));
@@ -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"(?<![.\w])(?:window\.)?(requestAnimationFrame|cancelAnimationFrame|setTimeout|setInterval)\("
)
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_230_2_popout_frames.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_공용_함수_놓인_창(r):
assert r["viewOf"] == {"popup": "popup", "detached": "main", "none": "main"}
def test_계획고_편집_뒤_다시_그림(r):
assert r["rebuild"] == {"draws": 1, "previews": 2, "mainTimers": 0} # 두 번 불러도 한 번
def test_높이_맞춤(r):
assert r["heights"] == {"syncs": 1, "mainTimers": 0}
def test_표_유토곡선_크기_끌기_다시_그림(r):
assert r["table"] == {"changed": 1, "handles": 2, "mainTimers": 0}
assert r["massHaul"] == {"changed": 1, "handles": 2, "mainTimers": 0}
def test_유토곡선_세로창_미끄러짐(r):
assert r["settle"] == {"redraws": 1, "frame": 0, "view": "popup", "mainTimers": 0}
def test_끌기_뒤_다시_그림(r):
assert r["drag"] == {"draws": 1, "mainTimers": 0}
def test_길게_누르기_새_창_시계(r):
# 0.5초 뒤부터 0.1초마다 — 1초 동안 5번 · 멈춘 뒤 더 안 돎
assert r["hold"] == {"acts": 5, "afterStop": 5, "mainTimers": 0, "popupTimers": 0}
def test_배수유역도_다시_그림(r):
assert r["drainage"] == {"draws": 1, "mainTimers": 0}
def test_새_창_패널_코드에_메인_창_틀_시계_없음():
found = []
for rel in POPOUT_FILES:
with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
for no, line in enumerate(f, 1):
code = line.split("//")[0]
if code.lstrip().startswith("*"):
continue
if MAIN_WINDOW_CLOCK.search(code):
found.append(f"{rel}:{no}: {line.strip()}")
assert found == []
def test_design_profile_700줄_안():
with open(os.path.join(ROOT, "A00_Common/design/design_profile.ts"), encoding="utf-8") as f:
assert sum(1 for _ in f) <= 700