Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_4' and 'origin/sub_laptop_7' into sub_laptop_5
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { leaveOrStay } from "@ui/ui_template_leave_dialog";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { listenWhileAlive } from "@ui/ui_template_window_watch";
|
||||
import {
|
||||
clearState,
|
||||
namesInBucket,
|
||||
@@ -99,12 +100,11 @@ export function guardDesignLeave(
|
||||
save: () => Promise<unknown>,
|
||||
): void {
|
||||
const unload = (event: BeforeUnloadEvent): void => {
|
||||
if (!root.isConnected) return window.removeEventListener("beforeunload", unload);
|
||||
if (!designDirty(projectId)) return;
|
||||
if (!root.isConnected || !designDirty(projectId)) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", unload);
|
||||
listenWhileAlive(window, "beforeunload", root, unload); // 다시 세운 옛 페이지를 안 붙듦(298-3)
|
||||
const guard = async (): Promise<boolean> => {
|
||||
if (!root.isConnected) return true;
|
||||
if (SAME_PAGE.has(currentRoute())) {
|
||||
|
||||
@@ -44,6 +44,12 @@ interface CacheEntry {
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
/** 지금 노선 한 벌만 — 노선 [저장]마다 새 노선 번호라 옛 칸이 쌓임(298-3). */
|
||||
function remember(key: string, entry: CacheEntry): void {
|
||||
cache.clear();
|
||||
cache.set(key, entry);
|
||||
}
|
||||
|
||||
function keyOf(projectId: string, routeId: number): string {
|
||||
return `${projectId}:${routeId}`;
|
||||
}
|
||||
@@ -158,7 +164,7 @@ export async function ensureCorridor(
|
||||
if (lookup.kind === "envelope" && lookup.envelope.hash === hash) {
|
||||
try {
|
||||
const build = deserialize(lookup.envelope);
|
||||
cache.set(key, { hash, build });
|
||||
remember(key, { hash, build });
|
||||
markSource("stored", hash);
|
||||
return build;
|
||||
} catch {
|
||||
@@ -182,7 +188,7 @@ export async function ensureCorridor(
|
||||
}
|
||||
// 미리보기 — 메모리에만(올리지 않음 · 서버가 [저장]·[확정] 때 정본으로 지음)
|
||||
markSource("built", hash);
|
||||
cache.set(key, { hash, build });
|
||||
remember(key, { hash, build });
|
||||
return build;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createSelectField } from "@ui/ui_template_elements";
|
||||
import { listenWhileAlive } from "@ui/ui_template_window_watch";
|
||||
import {
|
||||
L,
|
||||
PROFILE_CRITERIA,
|
||||
@@ -137,13 +138,13 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) {
|
||||
const gradeSummary = document.createElement("summary");
|
||||
gradeSummary.textContent = "기준값 직접 지정";
|
||||
// 칸을 펼 때 · 펴 둔 동안 누름 · 키 뒤마다 종단 편집을 다시 비춤(끝점 ▲▼ · 되돌리기 · [저장] 뒤)
|
||||
// ponytail: 문서 듣기는 페이지마다 하나 · 떼지 않음(가벼움) — 새는 게 보이면 페이지 수명에 묶을 것
|
||||
// 문서 듣기는 칸 수명 — 페이지를 다시 세워도 옛 칸을 붙들지 않음(298-3)
|
||||
gradeAdvanced.addEventListener("toggle", () => gradeAdvanced.open && showEndOffsets());
|
||||
const refreshSoon = (): void => {
|
||||
if (gradeAdvanced.isConnected && gradeAdvanced.open) requestAnimationFrame(showEndOffsets);
|
||||
};
|
||||
document.addEventListener("pointerup", refreshSoon);
|
||||
document.addEventListener("keyup", refreshSoon);
|
||||
listenWhileAlive(document, "pointerup", gradeAdvanced, refreshSoon);
|
||||
listenWhileAlive(document, "keyup", gradeAdvanced, refreshSoon);
|
||||
gradeAdvanced.append(
|
||||
gradeSummary,
|
||||
maxGradePct.wrapper,
|
||||
|
||||
@@ -61,7 +61,13 @@ export function crossPickFromEvent(
|
||||
? Number(marker.getAttribute("data-chainage"))
|
||||
: null;
|
||||
const hit: CrossPickHit =
|
||||
plusRaw !== null ? { plusM: plusRaw } : marker || target.closest("button") ? "other" : null;
|
||||
plusRaw !== null
|
||||
? { plusM: plusRaw }
|
||||
: target.closest("button")
|
||||
? "other"
|
||||
: marker
|
||||
? "station"
|
||||
: null;
|
||||
const svg = chart.querySelector<SVGSVGElement>(`svg[${Y_WINDOW_ATTRS.auto}="1"]`);
|
||||
const read = (name: string): number => Number(svg?.getAttribute(name));
|
||||
const view: ChartWindow = {
|
||||
@@ -93,7 +99,7 @@ export function crossPickFromEvent(
|
||||
}
|
||||
|
||||
/** 임시 횡단이 열리는 자리 가까이 = 작은 원 · 세로 점선 · 측점 글 · 손가락 커서(490-1).
|
||||
* 멀어짐 · 측점선 · 단추 · 도구 중 · 끌기 · 스크롤 = 안 그림. */
|
||||
* 멀어짐 · 띠 밖 측점선 · 단추 · 도구 중 · 끌기 · 스크롤 = 안 그림. */
|
||||
export function mountCrossHint(
|
||||
chart: HTMLElement,
|
||||
scroller: HTMLElement,
|
||||
|
||||
@@ -308,36 +308,44 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
chartWrap.style.height = `${chartHeight}px`;
|
||||
// 빈 곳 누름은 고름을 안 풂 — 풀기는 Esc · 같은 것 다시 누름 한 길(3D 와 같은 규칙 · 233-4).
|
||||
// 도구([직선화] · [쉬프트] · [자유지정]) 중 빈 곳 누름은 그 도구 몫 그대로.
|
||||
chartWrap.addEventListener("click", (event) => {
|
||||
// [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면
|
||||
// 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02).
|
||||
if (ctx.toolActive()) {
|
||||
const marker = (event.target as HTMLElement).closest(".b06-chart__station");
|
||||
const raw = marker?.getAttribute("data-chainage");
|
||||
if (raw !== null && raw !== undefined) {
|
||||
if (ctx.handleToolPick(Number(raw))) return;
|
||||
} else {
|
||||
const rect = chartWrap.getBoundingClientRect();
|
||||
const chainage = chainageInverter(
|
||||
longitudinal,
|
||||
width,
|
||||
layout.originOffset,
|
||||
)(event.clientX - rect.left + chartWrap.scrollLeft);
|
||||
if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return;
|
||||
// 잡는 단계(capture) — 띠 안 측점선 누름은 임시 횡단이 먼저라 측점선 자기 고름을 막아야 함(500-2).
|
||||
chartWrap.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
// [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면
|
||||
// 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02).
|
||||
if (ctx.toolActive()) {
|
||||
const marker = (event.target as HTMLElement).closest(".b06-chart__station");
|
||||
const raw = marker?.getAttribute("data-chainage");
|
||||
if (raw !== null && raw !== undefined) {
|
||||
if (ctx.handleToolPick(Number(raw))) return;
|
||||
} else {
|
||||
const rect = chartWrap.getBoundingClientRect();
|
||||
const chainage = chainageInverter(
|
||||
longitudinal,
|
||||
width,
|
||||
layout.originOffset,
|
||||
)(event.clientX - rect.left + chartWrap.scrollLeft);
|
||||
if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 원지반선 근처 · 추가 측점 선 누름 = 그 자리 임시 횡단 작은 창(368-3)
|
||||
// 다른 측점선 · 단추 누름은 그 몫(측점 고름 · ▲▼)
|
||||
const toChainageAt = chainageInverter(longitudinal, width, layout.originOffset);
|
||||
const at = crossPickFromEvent(chartWrap, event, toChainageAt, graphData.samples);
|
||||
if (at === null) return;
|
||||
openCrossModal(body.parentElement ?? body, {
|
||||
chainageM: at.chainageM,
|
||||
stations: graphLongitudinal.stations.map((station) => station.chainage_m),
|
||||
intervalM: stationIntervalM ?? 20,
|
||||
});
|
||||
});
|
||||
// 원지반선 근처 · 추가 측점 선 누름 = 그 자리 임시 횡단 작은 창(368-3)
|
||||
// 띠 밖 측점선 · 단추 누름은 그 몫(측점 고름 · ▲▼) — 띠 안 측점선은 임시 횡단이 먼저(500-2)
|
||||
const toChainageAt = chainageInverter(longitudinal, width, layout.originOffset);
|
||||
const at = crossPickFromEvent(chartWrap, event, toChainageAt, graphData.samples);
|
||||
if (at === null) return;
|
||||
const marker = (event.target as Element).closest(".b06-chart__station");
|
||||
if (marker && !marker.querySelector(".b06-chart__station-line--plus"))
|
||||
event.stopPropagation();
|
||||
openCrossModal(body.parentElement ?? body, {
|
||||
chainageM: at.chainageM,
|
||||
stations: graphLongitudinal.stations.map((station) => station.chainage_m),
|
||||
intervalM: stationIntervalM ?? 20,
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
const derived = deriveProfile(ctx, detail, alignment, irregularStations);
|
||||
const { designProfiles, graphData, graphLongitudinal } = derived;
|
||||
// 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
* logic/B05_Profile_Profile_CrossPick.ts
|
||||
* 종단 그래프 한 점 → 임시 횡단을 열 누가거리(정수 m) · 아니면 null(368-3 · 490-1).
|
||||
*
|
||||
* 누름(작은 창 열기)과 가까이 표시가 같은 판정을 씀 — 추가 측점 선 = 그 측점 · 다른 측점선 · 단추 =
|
||||
* 그 몫(null) · 빈 곳 = 원지반선 위아래 `GROUND_SLACK_PX` 안.
|
||||
* 누름(작은 창 열기)과 가까이 표시가 같은 판정을 씀 — 추가 측점 선 = 그 측점 · 단추 = 그 몫(null) ·
|
||||
* 다른 측점선 · 빈 곳 = 원지반선 위아래 `GROUND_SLACK_PX` 안(측점선은 띠 밖이면 측점 고름 몫 · 500-2).
|
||||
* ========================================================================== */
|
||||
|
||||
/** 원지반선을 눌렀다고 볼 위아래 여유(px). */
|
||||
export const GROUND_SLACK_PX = 12;
|
||||
|
||||
/** 포인터 아래 = 추가 측점 선(그 누가거리) · 다른 측점선 · 단추("other") · 빈 곳(null). */
|
||||
export type CrossPickHit = { plusM: number } | "other" | null;
|
||||
/** 포인터 아래 = 추가 측점 선(그 누가거리) · 다른 측점선("station") · 단추("other") · 빈 곳(null). */
|
||||
export type CrossPickHit = { plusM: number } | "station" | "other" | null;
|
||||
|
||||
export type GroundSample = { chainage_m?: number; elevation_m?: number | null };
|
||||
|
||||
@@ -44,9 +44,10 @@ export function groundElevationAt(
|
||||
/** 임시 횡단을 열 누가거리(정수 m) · 아니면 null. */
|
||||
export function crossPickAt(input: CrossPickInput): number | null {
|
||||
if (input.hit === "other") return null;
|
||||
if (input.hit) return Math.round(input.hit.plusM);
|
||||
if (typeof input.hit === "object" && input.hit) return Math.round(input.hit.plusM);
|
||||
const ground = groundElevationAt(input.samples, input.chainageM);
|
||||
if (ground === null) return null;
|
||||
// "station"·null 모두 띠 안이면 임시 횡단 — 측점선은 띠 밖에서만 측점 고름 몫.
|
||||
return Math.abs(input.pointerY - input.groundY(ground)) <= GROUND_SLACK_PX
|
||||
? Math.round(input.chainageM)
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/* 298-3 옛 페이지 풀림 단위검증 헬퍼 — `ui_template_window_watch` · `ui_template_overlay_drag` ·
|
||||
* `design_leave` 를 그 자리에서 트랜스파일 · 페이지를 다섯 번 다시 세운 뒤 창 듣개 수 · 옛 노드 풀림.
|
||||
* `node --expose-gc` 로 부름 · 결과 JSON 을 stdout 으로.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
const session = new Map();
|
||||
const storage = {
|
||||
getItem: (key) => (session.has(key) ? session.get(key) : null),
|
||||
setItem: (key, value) => session.set(key, String(value)),
|
||||
removeItem: (key) => session.delete(key),
|
||||
key: (index) => [...session.keys()][index] ?? null,
|
||||
get length() {
|
||||
return session.size;
|
||||
},
|
||||
};
|
||||
const win = new EventTarget();
|
||||
const doc = new EventTarget();
|
||||
const counts = { window: 0, document: 0 };
|
||||
for (const [name, target] of [
|
||||
["window", win],
|
||||
["document", doc],
|
||||
]) {
|
||||
const add = target.addEventListener.bind(target);
|
||||
target.addEventListener = (...args) => {
|
||||
counts[name] += 1;
|
||||
return add(...args);
|
||||
};
|
||||
}
|
||||
Object.assign(win, {
|
||||
sessionStorage: storage,
|
||||
localStorage: storage,
|
||||
setTimeout,
|
||||
innerWidth: 1500,
|
||||
innerHeight: 900,
|
||||
});
|
||||
globalThis.window = win;
|
||||
globalThis.document = doc;
|
||||
globalThis.sessionStorage = storage;
|
||||
globalThis.localStorage = storage;
|
||||
globalThis.requestAnimationFrame = (fn) => (fn(), 1);
|
||||
|
||||
let guard = null;
|
||||
const STUBS = {
|
||||
"@config/config_frontend": {
|
||||
ROUTES: { B05_PROFILE: "/b05-profile", B06_SECTION: "/b06-section" },
|
||||
},
|
||||
"@ui/ui_template_locale": { t: (key) => key },
|
||||
"@ui/ui_template_leave_dialog": { leaveOrStay: async () => "stay" },
|
||||
"../router": { currentRoute: () => "/b05-profile", setLeaveGuard: (fn) => (guard = fn) },
|
||||
};
|
||||
|
||||
const loaded = new Map();
|
||||
function loadTs(file) {
|
||||
if (loaded.has(file)) return loaded.get(file);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
loaded.set(file, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (request in STUBS) return STUBS[request];
|
||||
if (request.startsWith("@ui/"))
|
||||
return loadTs(path.join(ROOT, "ui_template", `${request.slice(4)}.ts`));
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const drag = loadTs(path.join(ROOT, "ui_template", "ui_template_overlay_drag.ts"));
|
||||
const leave = loadTs(path.join(ROOT, "A00_Common", "design", "design_leave.ts"));
|
||||
|
||||
let settles = 0;
|
||||
function fakeElement() {
|
||||
const node = new EventTarget();
|
||||
node.style = {};
|
||||
node.isConnected = true;
|
||||
node.offsetWidth = 300;
|
||||
node.offsetHeight = 40;
|
||||
node.classList = {
|
||||
contains: () => false,
|
||||
add() {},
|
||||
toggle() {},
|
||||
remove: () => (settles += 1), // detach 가 부름 = 자리 다시 앉힘
|
||||
};
|
||||
node.querySelector = () => null;
|
||||
node.getBoundingClientRect = () => ({ left: 10, top: 80 });
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 페이지 한 벌 — 떠남 확인 + 진행단계 패널 · 옛 페이지 노드의 약한 손잡이를 돌려줌. */
|
||||
function mountPage() {
|
||||
const page = fakeElement();
|
||||
const panel = fakeElement();
|
||||
page.panel = panel; // 페이지 나무 = 패널을 품음
|
||||
leave.guardDesignLeave("p1", page, async () => {});
|
||||
page.dragHandle = drag.makePanelDraggable(panel, fakeElement(), "pos");
|
||||
return { page: new WeakRef(page), panel: new WeakRef(panel), keep: page };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const out = {};
|
||||
let live = mountPage();
|
||||
out.afterFirst = { ...counts };
|
||||
const old = [];
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
live.keep.isConnected = false; // 다시 세움 — 옛 페이지는 화면에서 빠지고 버려짐
|
||||
old.push({ page: live.page, panel: live.panel });
|
||||
live = mountPage();
|
||||
}
|
||||
out.afterFive = { ...counts };
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
globalThis.gc();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
globalThis.gc();
|
||||
out.oldAlive = old.filter((pair) => pair.page.deref() || pair.panel.deref()).length;
|
||||
out.liveAlive = Boolean(live.page.deref() && live.panel.deref());
|
||||
// 살아 있는 패널은 창 크기 바뀜을 여전히 들음 · 버려진 패널은 안 부름
|
||||
settles = 0;
|
||||
win.dispatchEvent(new Event("resize"));
|
||||
out.resizeSettles = settles;
|
||||
// 떠남 확인 — 살아 있는 페이지만(고침 없음 = 안 막음)
|
||||
const event = new Event("beforeunload", { cancelable: true });
|
||||
win.dispatchEvent(event);
|
||||
out.unloadBlocked = event.defaultPrevented;
|
||||
out.guardSet = typeof guard === "function";
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})();
|
||||
@@ -66,6 +66,8 @@ function loadTs(file) {
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => {
|
||||
if (request in STUBS) return STUBS[request];
|
||||
if (request === "@ui/ui_template_window_watch")
|
||||
return loadTs(path.join(ROOT, "ui_template", "ui_template_window_watch.ts"));
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""298-3 노선 [저장] 뒤 옛 페이지 풀림 — 페이지를 다시 세워도 창 · 문서 듣개 수 그대로 · 코리도 캐시 한 칸.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_298_3_page_dispose.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).resolve().parent / "helper_298_3_page_dispose.cjs"
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
run = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", "--expose-gc", str(HELPER)], capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
def test_다시_세워도_창_듣개_수_그대로(out) -> None:
|
||||
assert (
|
||||
out["afterFirst"]["window"] == 5
|
||||
) # beforeunload · pointermove · pointerup · pointercancel · resize
|
||||
assert out["afterFive"] == out["afterFirst"]
|
||||
|
||||
|
||||
def test_옛_페이지_풀림_지금_페이지는_들음(out) -> None:
|
||||
assert out["oldAlive"] == 0
|
||||
assert out["liveAlive"] is True
|
||||
assert out["resizeSettles"] == 1 # 살아 있는 패널 하나만 다시 앉힘
|
||||
assert out["unloadBlocked"] is False and out["guardSet"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rel", "types"),
|
||||
[
|
||||
("A00_Common/design/design_leave.ts", "beforeunload"),
|
||||
("ui_template/ui_template_overlay_drag.ts", "pointermove|pointerup|pointercancel|resize"),
|
||||
(
|
||||
"ui_template/ui_template_side_panels.ts",
|
||||
"scroll|resize",
|
||||
), # 끌기 동안 pointer 듣기는 끝에 뗌
|
||||
("B05_Profile/B05_Profile_UI_Panel_Settings.ts", "pointerup|keyup"),
|
||||
],
|
||||
)
|
||||
def test_전역_듣기는_노드_수명으로만(rel: str, types: str) -> None:
|
||||
code = _read(rel)
|
||||
assert not re.search(rf'\b(window|document)\.addEventListener\(\s*"({types})"', code)
|
||||
assert "listenWhileAlive(" in code
|
||||
|
||||
|
||||
def test_옆_패널_크기_감시는_노드를_안_붙듦() -> None:
|
||||
side = _read("ui_template/ui_template_side_panels.ts")
|
||||
assert "new ResizeObserver(() => ref.deref()?.())" in side
|
||||
assert "const ref = new WeakRef(place);" in side
|
||||
|
||||
|
||||
def test_코리도_캐시는_지금_노선_한_칸() -> None:
|
||||
code = _read("B05_Profile/B05_Profile_UI_Corridor.ts")
|
||||
assert (
|
||||
"function remember(key: string, entry: CacheEntry): void {\n cache.clear();\n cache.set(key, entry);\n}"
|
||||
in code
|
||||
)
|
||||
assert code.count("cache.set(") == 1
|
||||
@@ -1,7 +1,7 @@
|
||||
"""490-1 B05 종단 — 임시 횡단이 열리는 자리 가까이 표시 = 누름과 같은 판정(`crossPickAt`).
|
||||
|
||||
판정 함수 = 옛 누름 판정(원지반선 위아래 12 px · 500-1 8 → 12 · 정수 m · 추가 측점 선 = 그 측점 · 측점선 · 단추 = null)과
|
||||
같은 값 · 허용 밖 null. TS 쪽 = `helper_490_1_cross_pick.cjs`(Node). 화면 조작은 ORCA 로 확인.
|
||||
판정 함수 = 옛 누름 판정(원지반선 위아래 12 px · 500-1 8 → 12 · 정수 m · 추가 측점 선 = 그 측점 · 단추 = null)과
|
||||
같은 값 · 허용 밖 null. 500-2 = 다른 측점선("station")은 띠 안이면 임시 횡단 · 띠 밖이면 null(측점 고름 몫). TS 쪽 = `helper_490_1_cross_pick.cjs`(Node). 화면 조작은 ORCA 로 확인.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -30,7 +30,7 @@ def _old_pick(hit, chainage: float, pointer_y: float) -> int | None:
|
||||
"""옛 `groundPickAt` + 누름 처리(Render.ts 329~346) 그대로."""
|
||||
if hit == "other":
|
||||
return None
|
||||
if hit:
|
||||
if isinstance(hit, dict):
|
||||
return round(hit["plusM"])
|
||||
valid = [s for s in SAMPLES if s["elevation_m"] is not None]
|
||||
after = next((i for i, s in enumerate(valid) if s["chainage_m"] >= chainage), -1)
|
||||
@@ -51,7 +51,11 @@ CASES = [
|
||||
{"hit": None, "chainageM": 0.0, "pointerY": 80.0}, # 첫 표본 = null(옛 판정 그대로)
|
||||
{"hit": None, "chainageM": 70.0, "pointerY": 100.0}, # 끝 뒤
|
||||
{"hit": {"plusM": 33.6}, "chainageM": 10.0, "pointerY": 0.0}, # 추가 측점 선
|
||||
{"hit": "other", "chainageM": 10.4, "pointerY": 70.0}, # 측점선 · 단추
|
||||
{"hit": "other", "chainageM": 10.4, "pointerY": 70.0}, # 단추
|
||||
{"hit": "station", "chainageM": 10.4, "pointerY": 69.0}, # 측점선 · 띠 안 = 임시 횡단
|
||||
{"hit": "station", "chainageM": 10.4, "pointerY": 81.0}, # 측점선 · 띠 끝 안
|
||||
{"hit": "station", "chainageM": 10.4, "pointerY": 90.0}, # 측점선 · 띠 밖 = 측점 고름
|
||||
{"hit": "station", "chainageM": 0.0, "pointerY": 80.0}, # 측점선 · 첫 표본 = null
|
||||
]
|
||||
|
||||
|
||||
@@ -75,7 +79,7 @@ def test_판정_함수_옛_누름과_같은_값() -> None:
|
||||
assert result["slack"] == 12
|
||||
picks = [row["pick"] for row in result["out"]]
|
||||
assert picks == [_old_pick(c["hit"], c["chainageM"], c["pointerY"]) for c in CASES]
|
||||
assert picks == [10, 10, None, None, 50, None, None, 34, None]
|
||||
assert picks == [10, 10, None, None, 50, None, None, 34, None, 10, 10, None, None]
|
||||
|
||||
|
||||
def test_누름_표시_같은_길() -> None:
|
||||
|
||||
@@ -78,3 +78,18 @@ def test_제목_줄_끌기_화면_안() -> None:
|
||||
assert "innerWidth" in drag and "innerHeight" in drag # 화면 밖 안 나감
|
||||
assert "root.parentElement!.getBoundingClientRect()" in drag # 종단 칸 안
|
||||
assert "refit();" in modal # 다시 열 때 가둠
|
||||
|
||||
|
||||
def test_500_2_측점선_띠_안_임시_횡단_먼저() -> None:
|
||||
modal = _read("B05_Profile", "B05_Profile_UI_Profile_CrossModal.ts")
|
||||
# 단추 = other 먼저 · 추가 측점선 = plusM · 그 밖 측점선 = station(띠 안이면 임시 횡단)
|
||||
flat = " ".join(modal.split())
|
||||
assert 'target.closest("button") ? "other" : marker ? "station" : null' in flat
|
||||
logic = _read("B05_Profile", "logic", "B05_Profile_Profile_CrossPick.ts")
|
||||
assert '"station"' in logic and 'input.hit === "other"' in logic
|
||||
# 누름 = 잡는 단계에서 띠 안 측점선의 자기 고름을 막고 임시 횡단을 엶
|
||||
render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts")
|
||||
start = render.index("// 잡는 단계(capture)")
|
||||
click = render[start : render.index("const derived = deriveProfile", start)]
|
||||
assert "event.stopPropagation()" in click and "openCrossModal(" in click
|
||||
assert " ".join(click.split()).endswith("}, true, );")
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
|
||||
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import { listenWhileAlive } from "./ui_template_window_watch";
|
||||
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
/** 상단 공용 헤더 높이(--spacing-64) — 그 위로는 못 올라간다. */
|
||||
@@ -165,7 +166,8 @@ export function makePanelDraggable(
|
||||
// 포인터 이동은 **창 전체**에서 듣는다. 헤더에서만 들으면 커서가 패널 밖으로
|
||||
// 나가는 순간 이동이 끊기고, 헤더에 포인터를 잡아 두면(setPointerCapture) 뒤따르는
|
||||
// click 의 대상이 제목에서 헤더로 바뀌어 접기/펴기 토글이 죽는다(2026-09-04 실측).
|
||||
window.addEventListener("pointermove", (event: PointerEvent) => {
|
||||
// 창 듣기는 패널 수명 — 페이지를 다시 세워도 옛 패널을 붙들지 않는다(298-3).
|
||||
listenWhileAlive(window, "pointermove", root, (event: PointerEvent) => {
|
||||
if (pointerId !== event.pointerId) return;
|
||||
const dx = event.clientX - startX;
|
||||
const dy = event.clientY - startY;
|
||||
@@ -197,15 +199,15 @@ export function makePanelDraggable(
|
||||
swallowNextClick();
|
||||
}
|
||||
|
||||
window.addEventListener("pointerup", endDrag);
|
||||
window.addEventListener("pointercancel", endDrag);
|
||||
listenWhileAlive(window, "pointerup", root, endDrag);
|
||||
listenWhileAlive(window, "pointercancel", root, endDrag);
|
||||
|
||||
// 만들어진 직후에는 아직 DOM에 붙기 전이라 실측 크기가 0이다 — 한 프레임 뒤에 앉힌다.
|
||||
requestAnimationFrame(settle);
|
||||
|
||||
// 창 폭이 바뀌면 다시 판정한다 — 좁아지면 물러나고 넓어지면 끌어 둔 자리를 되찾는다.
|
||||
let pending = 0;
|
||||
window.addEventListener("resize", () => {
|
||||
listenWhileAlive(window, "resize", root, () => {
|
||||
if (pending) return;
|
||||
pending = requestAnimationFrame(() => {
|
||||
pending = 0;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import { el } from "./ui_template_elements";
|
||||
import { currentLanguageIndex } from "./ui_template_locale";
|
||||
import { createWorkflowPanelHandle } from "./ui_template_overlay";
|
||||
import { listenWhileAlive } from "./ui_template_window_watch";
|
||||
import "./ui_template_side_panels.css";
|
||||
|
||||
const TEXT = {
|
||||
@@ -191,14 +192,11 @@ function scroller(node: HTMLElement): HTMLElement | null {
|
||||
* 패널 묶음을 보이는 칸 안에 붙임 — 높이 = 보이는 창 높이 − 머리띠(칸이 아래에 있으면 칸 위부터) · 본문을 굴려도 그 자리(몸만 따로 스크롤)
|
||||
* CSS sticky 는 위 칸의 overflow 가 막아 JS 로 — 어느 칸이 굴러도(capture) 다시 잼
|
||||
*/
|
||||
function followView(node: HTMLElement): void {
|
||||
const ref = new WeakRef(node); // 떼었다 다시 붙이는 화면(M01)도 있어 떼어져도 듣기는 남김 · 버려지면 그침
|
||||
function followView(root: HTMLElement): void {
|
||||
// 떼었다 다시 붙이는 화면(M01)도 있어 떼어져도 듣기는 남김 · 창 듣기 = 노드 수명(버려지면 그침 · 298-3)
|
||||
// 다시 붙음 · 칸 내용이 늦게 차 칸 크기가 바뀜(스크롤 없이) → 다시 잼
|
||||
const sized = new ResizeObserver(() => place());
|
||||
let watched: Element | null = null;
|
||||
const place = (): void => {
|
||||
const root = ref.deref();
|
||||
if (!root) return stop();
|
||||
if (!root.isConnected) return;
|
||||
if (root.parentElement && root.parentElement !== watched) {
|
||||
if (watched) sized.unobserve(watched);
|
||||
@@ -219,13 +217,15 @@ function followView(node: HTMLElement): void {
|
||||
const shift = Math.min(Math.max(0, view - row.top), Math.max(0, row.height + 48 - height));
|
||||
root.style.transform = shift ? `translateY(${Math.round(shift)}px)` : "";
|
||||
};
|
||||
const stop = (): void => {
|
||||
window.removeEventListener("scroll", place, true);
|
||||
window.removeEventListener("resize", place);
|
||||
sized.disconnect();
|
||||
};
|
||||
window.addEventListener("scroll", place, true);
|
||||
window.addEventListener("resize", place);
|
||||
sized.observe(node);
|
||||
const sized = sizeWatch(place);
|
||||
listenWhileAlive(window, "scroll", root, place, true);
|
||||
listenWhileAlive(window, "resize", root, place);
|
||||
sized.observe(root);
|
||||
setTimeout(place); // 붙은 뒤 한 번
|
||||
}
|
||||
|
||||
/** 크기 감시는 `place` 를 약하게 쥠 — 감시가 노드를 붙들지 않게(따로 함수: 닫힘이 `root` 를 안 쥠). */
|
||||
function sizeWatch(place: () => void): ResizeObserver {
|
||||
const ref = new WeakRef(place);
|
||||
return new ResizeObserver(() => ref.deref()?.());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* =============================================================================
|
||||
* ui_template/ui_template_window_watch.ts
|
||||
* 창 · 문서 듣기를 화면 노드 수명에 묶음(298-3) — 종류마다 듣개 하나만 걸고 노드는 약하게 쥠.
|
||||
* 페이지를 다시 세워도(노선 [저장]) 옛 페이지를 붙들지 않고 창 듣개 수가 늘지 않음.
|
||||
* 노드가 살아 있는 동안은 떼었다 다시 붙여도(M01) 계속 들음 · 버려지면 저절로 빠짐.
|
||||
* ========================================================================== */
|
||||
|
||||
type Call = (event: Event) => void;
|
||||
|
||||
interface Hub {
|
||||
nodes: Set<WeakRef<object>>;
|
||||
/** 노드 → 부를 것 — 값이 노드를 쥐어도 노드와 함께 풀림(WeakMap). */
|
||||
calls: WeakMap<object, Call[]>;
|
||||
}
|
||||
|
||||
const hubs = new WeakMap<EventTarget, Map<string, Hub>>();
|
||||
|
||||
function hubOf(target: EventTarget, type: string, capture: boolean): Hub {
|
||||
let byType = hubs.get(target);
|
||||
if (!byType) hubs.set(target, (byType = new Map()));
|
||||
const key = `${type}:${capture}`;
|
||||
const found = byType.get(key);
|
||||
if (found) return found;
|
||||
const hub: Hub = { nodes: new Set(), calls: new WeakMap() };
|
||||
target.addEventListener(
|
||||
type,
|
||||
(event) =>
|
||||
hub.nodes.forEach((ref) => {
|
||||
const node = ref.deref();
|
||||
if (!node) hub.nodes.delete(ref);
|
||||
else hub.calls.get(node)?.forEach((call) => call(event));
|
||||
}),
|
||||
capture,
|
||||
);
|
||||
byType.set(key, hub);
|
||||
return hub;
|
||||
}
|
||||
|
||||
/** `target` 의 `type` 을 `node` 가 살아 있는 동안 들음. */
|
||||
export function listenWhileAlive<E extends Event = Event>(
|
||||
target: EventTarget,
|
||||
type: string,
|
||||
node: object,
|
||||
call: (event: E) => void,
|
||||
capture = false,
|
||||
): void {
|
||||
const hub = hubOf(target, type, capture);
|
||||
const calls = hub.calls.get(node);
|
||||
if (calls) calls.push(call as Call);
|
||||
else {
|
||||
hub.calls.set(node, [call as Call]);
|
||||
hub.nodes.add(new WeakRef(node));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user