diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css index ee0e52e5a..b78246e7c 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css @@ -50,3 +50,54 @@ justify-content: flex-end; gap: var(--spacing-8, 8px); } + +/* 임시 횡단이 열리는 자리 가까이 표시(490-1) — 누름은 아래 그래프 몫(통과) · 세로 축(5) 밑 */ +.b05-crosshint { + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: 4; + width: 0; + pointer-events: none; +} + +.b05-crosshint[hidden], +.b05-crosshint__dot[hidden] { + display: none; +} + +.b05-crosshint__line { + position: absolute; + top: 0; + bottom: 0; + left: 0; + border-left: 1px dashed var(--color-accent); +} + +.b05-crosshint__dot { + position: absolute; + top: var(--crosshint-y); + left: 0; + width: 10px; + height: 10px; + border: 2px solid var(--color-accent); + border-radius: 50%; + background: var(--color-surface); + transform: translate(-50%, -50%); + box-sizing: border-box; +} + +.b05-crosshint__label { + position: absolute; + top: var(--crosshint-y); + left: 10px; + transform: translateY(-50%); + padding: 1px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-4, 4px); + background: var(--color-surface-raised); + color: var(--color-text-body); + font-size: var(--text-caption); + white-space: nowrap; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts index 045bf2238..b89a16345 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts @@ -7,7 +7,7 @@ * `pipelineCrossAtChainage` · 368-4) — 격자 없음 · 분석 지형 다름이면 서버 미리 뜬 횡단(`preview-stations` · * 저장 계획선 기준 · 글 한 줄) · 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시. * [측점 추가] = 추가 측점 초안(일반측점 · [저장] 한 번) · 같은 m 에 측점이 있으면 막음 · [삭제] = 추가 - * 측점일 때만 · [닫기]. + * 측점일 때만 · [닫기]. 열리는 자리 가까이 = 원 · 세로 점선 · 측점 글(`mountCrossHint` · 490-1). * ========================================================================== */ import "./B05_Profile_UI_Profile_CrossModal.css"; @@ -30,9 +30,13 @@ import { CROSS_PLOT_PAD, summarizeCross, } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; +import { + crossPickAt, + groundElevationAt, + type CrossPickHit, + type GroundSample, +} from "./logic/B05_Profile_Profile_CrossPick"; -/** 원지반선을 눌렀다고 볼 위아래 여유(px). */ -const GROUND_SLACK_PX = 8; /** 같은 측점으로 볼 거리(m) — 정수 m 측점끼리. */ const SAME_M = 0.5; @@ -43,42 +47,87 @@ export interface CrossModalOpen { intervalM: number; } -/** 그래프 누름 → 원지반선 근처면 그 누가거리(정수 m) · 아니면 null. */ -export function groundPickAt( +/** 그래프 위 포인터 → 임시 횡단 자리(정수 m · 그 자리 원지반선 y px) · 아니면 null. + * 누름(작은 창) · 가까이 표시가 같이 씀(490-1) — 판정은 `crossPickAt` 한 벌. */ +export function crossPickFromEvent( chart: HTMLElement, event: MouseEvent, chainageAtPx: (px: number) => number, - samples: ReadonlyArray<{ chainage_m?: number; elevation_m?: number | null }>, -): number | null { + samples: readonly GroundSample[], +): { chainageM: number; groundY: number | null } | null { + const target = event.target as Element; + const marker = target.closest(".b06-chart__station"); + const plusRaw = marker?.querySelector(".b06-chart__station-line--plus") + ? Number(marker.getAttribute("data-chainage")) + : null; + const hit: CrossPickHit = + plusRaw !== null ? { plusM: plusRaw } : marker || target.closest("button") ? "other" : null; const svg = chart.querySelector(`svg[${Y_WINDOW_ATTRS.auto}="1"]`); - if (!svg) return null; - const read = (name: string): number => Number(svg.getAttribute(name)); + const read = (name: string): number => Number(svg?.getAttribute(name)); const view: ChartWindow = { center: read(Y_WINDOW_ATTRS.center), pxPerM: read(Y_WINDOW_ATTRS.pxPerM), zero: read(Y_WINDOW_ATTRS.zero), ...parseWindowTransform( - svg.querySelector(`.${Y_WINDOW_CLASS}`)?.getAttribute("transform") ?? null, + svg?.querySelector(`.${Y_WINDOW_CLASS}`)?.getAttribute("transform") ?? null, ), }; - if (![view.center, view.pxPerM, view.zero].every(Number.isFinite) || view.pxPerM <= 0) - return null; const rect = chart.getBoundingClientRect(); - const chainage = chainageAtPx(event.clientX - rect.left); - const valid = samples.filter( - (s): s is { chainage_m: number; elevation_m: number } => - Number.isFinite(s.chainage_m ?? NaN) && Number.isFinite(s.elevation_m ?? NaN), - ); - const after = valid.findIndex((s) => s.chainage_m >= chainage); - if (!Number.isFinite(chainage) || after <= 0) return null; - const [a, b] = [valid[after - 1], valid[after]]; - const t = (chainage - a.chainage_m) / (b.chainage_m - a.chainage_m || 1); - const ground = a.elevation_m + (b.elevation_m - a.elevation_m) * t; - const top = svg.getBoundingClientRect().top - rect.top; - const groundY = chartYAtElevation(view, ground) + top; - return Math.abs(event.clientY - rect.top - groundY) <= GROUND_SLACK_PX - ? Math.round(chainage) - : null; + const top = svg ? svg.getBoundingClientRect().top - rect.top : 0; + const viewOk = + !!svg && [view.center, view.pxPerM, view.zero].every(Number.isFinite) && view.pxPerM > 0; + const groundY = (elevation: number): number => + viewOk ? chartYAtElevation(view, elevation) + top : NaN; + // 포인터 이동은 소수 px · 누름은 정수 px — 내려 맞춰 같은 자리 = 같은 m(0.5 m 경계에서 1 m 어긋남 막음). + const chainageM = crossPickAt({ + hit, + chainageM: chainageAtPx(Math.floor(event.clientX) - rect.left), + pointerY: Math.floor(event.clientY) - rect.top, + samples, + groundY, + }); + if (chainageM === null || !Number.isFinite(chainageM)) return null; + const ground = groundElevationAt(samples, chainageM); + const y = ground === null ? NaN : groundY(ground); + return { chainageM, groundY: Number.isFinite(y) ? y : null }; +} + +/** 임시 횡단이 열리는 자리 가까이 = 작은 원 · 세로 점선 · 측점 글 · 손가락 커서(490-1). + * 멀어짐 · 측점선 · 단추 · 도구 중 · 끌기 · 스크롤 = 안 그림. */ +export function mountCrossHint( + chart: HTMLElement, + scroller: HTMLElement, + options: { + pick: (event: MouseEvent) => { chainageM: number; groundY: number | null } | null; + x: (chainageM: number) => number; + intervalM: number; + blocked: () => boolean; + }, +): void { + const hint = document.createElement("div"); + hint.className = "b05-crosshint"; + hint.hidden = true; + hint.innerHTML = ``; + const dot = hint.querySelector(".b05-crosshint__dot")!; + const label = hint.querySelector(".b05-crosshint__label")!; + chart.append(hint); + const hide = (): void => { + hint.hidden = true; + chart.style.cursor = ""; + }; + chart.addEventListener("pointermove", (event) => { + const at = event.buttons || options.blocked() ? null : options.pick(event); + if (!at) return hide(); + hint.hidden = false; + hint.style.left = `${options.x(at.chainageM)}px`; + dot.hidden = at.groundY === null; + // 원 · 글 높이 = 원지반선(없으면 위쪽) — 글이 위 ▲ 단추를 가리지 않게 원 옆에 둠. + hint.style.setProperty("--crosshint-y", `${at.groundY ?? 12}px`); + label.textContent = `${stationLabel(at.chainageM, options.intervalM)} · 눌러 임시 횡단`; + chart.style.cursor = "pointer"; + scroller.addEventListener("scroll", hide, { once: true, passive: true }); + }); + chart.addEventListener("pointerleave", hide); } interface CrossModal { diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 1e8fca7c4..5d8ece274 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -53,7 +53,8 @@ import { import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures"; import { - groundPickAt, + crossPickFromEvent, + mountCrossHint, openCrossModal, removePlusStation, } from "./B05_Profile_UI_Profile_CrossModal"; @@ -328,19 +329,11 @@ export function renderProfile(ctx: ProfileRenderContext): void { } // 원지반선 근처 · 추가 측점 선 누름 = 그 자리 임시 횡단 작은 창(368-3) // 다른 측점선 · 단추 누름은 그 몫(측점 고름 · ▲▼) - const target = event.target as Element; - const marker = target.closest(".b06-chart__station"); - const plusAt = marker?.querySelector(".b06-chart__station-line--plus") - ? marker.getAttribute("data-chainage") - : null; - if ((marker && !plusAt) || target.closest("button")) return; const toChainageAt = chainageInverter(longitudinal, width, layout.originOffset); - const at = plusAt - ? Math.round(Number(plusAt)) - : groundPickAt(chartWrap, event, toChainageAt, graphData.samples); + const at = crossPickFromEvent(chartWrap, event, toChainageAt, graphData.samples); if (at === null) return; openCrossModal(body.parentElement ?? body, { - chainageM: at, + chainageM: at.chainageM, stations: graphLongitudinal.stations.map((station) => station.chainage_m), intervalM: stationIntervalM ?? 20, }); @@ -495,6 +488,13 @@ export function renderProfile(ctx: ProfileRenderContext): void { }), ); } + // 임시 횡단이 열리는 자리 가까이 = 원 · 세로 점선 · 측점 글(490-1 · 누름과 같은 판정). + mountCrossHint(chartWrap, body, { + pick: (event) => crossPickFromEvent(chartWrap, event, toChainage, graphData.samples), + x, + intervalM: stationIntervalM ?? 20, + blocked: ctx.toolActive, + }); // 캔버스 높이 = 종단도 영역만. 서브패널이 차지한 아래 공간을 덮지 않는다(밀어올리기). // 구조물 알약 레인이 그래프 밑에 한 줄 붙으므로 그만큼 캔버스가 커진다(2026-08-17 지시 6). canvas.style.height = `${chartHeight + STRUCTURE_LANE_HEIGHT_PX}px`; diff --git a/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts b/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts new file mode 100644 index 000000000..1e51415c5 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts @@ -0,0 +1,53 @@ +/* ============================================================================= + * logic/B05_Profile_Profile_CrossPick.ts + * 종단 그래프 한 점 → 임시 횡단을 열 누가거리(정수 m) · 아니면 null(368-3 · 490-1). + * + * 누름(작은 창 열기)과 가까이 표시가 같은 판정을 씀 — 추가 측점 선 = 그 측점 · 다른 측점선 · 단추 = + * 그 몫(null) · 빈 곳 = 원지반선 위아래 `GROUND_SLACK_PX` 안. + * ========================================================================== */ + +/** 원지반선을 눌렀다고 볼 위아래 여유(px). */ +export const GROUND_SLACK_PX = 8; + +/** 포인터 아래 = 추가 측점 선(그 누가거리) · 다른 측점선 · 단추("other") · 빈 곳(null). */ +export type CrossPickHit = { plusM: number } | "other" | null; + +export type GroundSample = { chainage_m?: number; elevation_m?: number | null }; + +export interface CrossPickInput { + hit: CrossPickHit; + /** 포인터 x 의 누가거리. */ + chainageM: number; + /** 포인터 y(그래프 px). */ + pointerY: number; + samples: readonly GroundSample[]; + /** 표고 → 그래프 y px(세로 창 없으면 NaN). */ + groundY: (elevationM: number) => number; +} + +/** 누가거리의 원지반 표고(앞뒤 표본 직선 보간) · 첫 표본 앞 · 끝 뒤 · 빈 표본 = null. */ +export function groundElevationAt( + samples: readonly GroundSample[], + chainageM: number, +): number | null { + const valid = samples.filter( + (s): s is { chainage_m: number; elevation_m: number } => + Number.isFinite(s.chainage_m ?? NaN) && Number.isFinite(s.elevation_m ?? NaN), + ); + const after = valid.findIndex((s) => s.chainage_m >= chainageM); + if (!Number.isFinite(chainageM) || after <= 0) return null; + const [a, b] = [valid[after - 1], valid[after]]; + const t = (chainageM - a.chainage_m) / (b.chainage_m - a.chainage_m || 1); + return a.elevation_m + (b.elevation_m - a.elevation_m) * t; +} + +/** 임시 횡단을 열 누가거리(정수 m) · 아니면 null. */ +export function crossPickAt(input: CrossPickInput): number | null { + if (input.hit === "other") return null; + if (input.hit) return Math.round(input.hit.plusM); + const ground = groundElevationAt(input.samples, input.chainageM); + if (ground === null) return null; + return Math.abs(input.pointerY - input.groundY(ground)) <= GROUND_SLACK_PX + ? Math.round(input.chainageM) + : null; +} diff --git a/resources/tester/helper_490_1_cross_pick.cjs b/resources/tester/helper_490_1_cross_pick.cjs new file mode 100644 index 000000000..52dc5abc2 --- /dev/null +++ b/resources/tester/helper_490_1_cross_pick.cjs @@ -0,0 +1,29 @@ +/* 490-1 임시 횡단 판정 — 진짜 `logic/B05_Profile_Profile_CrossPick.ts` 를 Node 로 돌려 냄. + * 입력 argv[2] JSON {samples, cases: [{hit, chainageM, pointerY}]} · groundY = 100 - 표고 × 2 px. + * 짝 = test_490_1_cross_pick.py. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join( + __dirname, + "..", + "..", + "B05_Profile", + "logic", + "B05_Profile_Profile_CrossPick.ts", +); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const logic = box.exports; + +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const groundY = (elevation) => 100 - elevation * 2; +const out = job.cases.map((c) => ({ + pick: logic.crossPickAt({ ...c, samples: job.samples, groundY }), + ground: logic.groundElevationAt(job.samples, c.chainageM), +})); +process.stdout.write(JSON.stringify({ slack: logic.GROUND_SLACK_PX, out })); diff --git a/resources/tester/test_368_3_cross_modal.py b/resources/tester/test_368_3_cross_modal.py index 23b1f1a4e..3ea4faadc 100644 --- a/resources/tester/test_368_3_cross_modal.py +++ b/resources/tester/test_368_3_cross_modal.py @@ -24,7 +24,7 @@ def test_추가_측점_선_점선_끌기_없음() -> None: def test_누름_작은_창_우클릭_삭제() -> None: render = _read("B05_Profile/B05_Profile_UI_Profile_Render.ts") - assert "groundPickAt(chartWrap, event" in render and "openCrossModal(" in render + assert "crossPickFromEvent(chartWrap, event" in render and "openCrossModal(" in render assert "onRemovePlus: removePlusStation" in render menu = _read("B05_Profile/B05_Profile_UI_Profile_Structures.ts") assert '"측점 삭제", () => options.onRemovePlus?.(near.station.chainage_m)' in menu diff --git a/resources/tester/test_490_1_cross_pick.py b/resources/tester/test_490_1_cross_pick.py new file mode 100644 index 000000000..05d2c783d --- /dev/null +++ b/resources/tester/test_490_1_cross_pick.py @@ -0,0 +1,87 @@ +"""490-1 B05 종단 — 임시 횡단이 열리는 자리 가까이 표시 = 누름과 같은 판정(`crossPickAt`). + +판정 함수 = 옛 누름 판정(원지반선 위아래 8 px · 정수 m · 추가 측점 선 = 그 측점 · 측점선 · 단추 = null)과 +같은 값 · 허용 밖 null. TS 쪽 = `helper_490_1_cross_pick.cjs`(Node). 화면 조작은 ORCA 로 확인. +""" + +from __future__ import annotations + +import json +import subprocess +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +SAMPLES = [ + {"chainage_m": 0.0, "elevation_m": 10.0}, + {"chainage_m": 20.0, "elevation_m": 20.0}, + {"chainage_m": 40.0, "elevation_m": None}, # 빈 표고는 건너뜀 + {"chainage_m": 60.0, "elevation_m": 0.0}, +] + + +def _ground_y(elevation: float) -> float: + return 100 - elevation * 2 + + +def _old_pick(hit, chainage: float, pointer_y: float) -> int | None: + """옛 `groundPickAt` + 누름 처리(Render.ts 329~346) 그대로.""" + if hit == "other": + return None + if hit: + 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) + if after <= 0: + return None + a, b = valid[after - 1], valid[after] + t = (chainage - a["chainage_m"]) / (b["chainage_m"] - a["chainage_m"] or 1) + ground = a["elevation_m"] + (b["elevation_m"] - a["elevation_m"]) * t + return round(chainage) if abs(pointer_y - _ground_y(ground)) <= 8 else None + + +CASES = [ + {"hit": None, "chainageM": 10.2, "pointerY": 69.8}, # 지반 y = 69.8 · 딱 위 + {"hit": None, "chainageM": 10.2, "pointerY": 77.8}, # 8 px 끝 + {"hit": None, "chainageM": 10.2, "pointerY": 78.3}, # 허용 밖 + {"hit": None, "chainageM": 10.2, "pointerY": 61.3}, # 위쪽 허용 밖 + {"hit": None, "chainageM": 50.0, "pointerY": 93.0}, # 빈 표고 건너 20→60 보간 + {"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}, # 측점선 · 단추 +] + + +def _run() -> dict: + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "job.json" + path.write_text(json.dumps({"samples": SAMPLES, "cases": CASES}), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_490_1_cross_pick.cjs"), str(path)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_판정_함수_옛_누름과_같은_값() -> None: + result = _run() + assert result["slack"] == 8 + 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] + + +def test_누름_표시_같은_길() -> None: + render = (ROOT / "B05_Profile/B05_Profile_UI_Profile_Render.ts").read_text(encoding="utf-8") + assert render.count("crossPickFromEvent(chartWrap, event") == 2 # 누름 · 표시 + assert "mountCrossHint(chartWrap, body" in render and "blocked: ctx.toolActive" in render + modal = (ROOT / "B05_Profile/B05_Profile_UI_Profile_CrossModal.ts").read_text(encoding="utf-8") + assert "crossPickAt({" in modal and "event.buttons || options.blocked()" in modal + assert 'scroller.addEventListener("scroll", hide' in modal