From c8be38342865b7a0df8831f274122257ef0f2c61 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 12:09:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=20=EC=A2=85=EB=8B=A8):=20=EC=9B=90?= =?UTF-8?q?=EC=A7=80=EB=B0=98=20=EC=A0=90=20=EC=9E=84=EC=8B=9C=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EC=9E=91=EC=9D=80=20=EC=B0=BD=20=C2=B7=20[?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EC=B6=94=EA=B0=80]=20=C2=B7=20=EC=A0=90?= =?UTF-8?q?=EC=84=A0=20=C2=B7=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=82=AD=EC=A0=9C=20(368-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도구 꺼진 때 원지반선 근처 누름(정수 m) · 추가 측점 선 누름 = 종단 칸 안 작은 창 - 내용 = 서버 미리 뜬 횡단(저장 계획선 기준) · 평면 우측 횡단과 같은 그림 · 확대 · 이동 - [측점 추가] · [삭제] · [닫기] · 같은 m 측점 있으면 막음 · 우클릭 「측점 삭제」 - 추가 측점 세로선 = 일반측점 색 점선(--plus) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../B05_Profile_UI_Profile_CrossModal.css | 52 +++++ .../B05_Profile_UI_Profile_CrossModal.ts | 214 ++++++++++++++++++ B05_Profile/B05_Profile_UI_Profile_Render.ts | 25 ++ .../B05_Profile_UI_Profile_Structures.ts | 14 +- B06_Section/B06_Section_UI_Style_Cross.css | 6 + resources/tester/helper_233_4_empty_click.cjs | 28 ++- resources/tester/test_368_3_cross_modal.py | 33 +++ 7 files changed, 366 insertions(+), 6 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Profile_CrossModal.css create mode 100644 B05_Profile/B05_Profile_UI_Profile_CrossModal.ts create mode 100644 resources/tester/test_368_3_cross_modal.py diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css new file mode 100644 index 000000000..ee0e52e5a --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css @@ -0,0 +1,52 @@ +/* 종단 원지반 점 임시 횡단 작은 창(368-3) — 종단 칸 오른쪽 위 겹침 · 유토 배지(7) 위. */ +.b05-crossmodal { + position: absolute; + top: 52px; + right: var(--spacing-12, 12px); + z-index: 8; + display: flex; + flex-direction: column; + gap: var(--spacing-8, 8px); + width: min(380px, calc(100% - 24px)); + padding: var(--spacing-12, 12px); + border: 1px solid var(--color-border); + border-radius: var(--radius-8, 6px); + background: var(--color-surface-raised); + color: var(--color-text-body); + box-shadow: 0 4px 16px color-mix(in srgb, var(--color-text-body) 18%, transparent); +} + +.b05-crossmodal[hidden] { + display: none; +} + +.b05-crossmodal__head { + display: flex; + align-items: baseline; + gap: var(--spacing-8, 8px); +} + +.b05-crossmodal__station { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b05-crossmodal__box { + height: 200px; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-8, 6px); + background: var(--color-surface); +} + +.b05-crossmodal__foot { + color: var(--color-text-secondary); + font-size: var(--text-caption); + line-height: 1.5; +} + +.b05-crossmodal__actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8, 8px); +} diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts new file mode 100644 index 000000000..e399fb585 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts @@ -0,0 +1,214 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_CrossModal.ts + * 종단 원지반선 점 → 그 자리 임시 횡단 작은 창(368-3 · 요구 240-21). + * + * 열기 = 도구가 꺼진 때 원지반선 근처 누름(정수 m) · 추가 측점 세로선 누름. 창은 종단 칸 안에 겹침 + * (유토 배지처럼 · [창 분할]에 따라감 · 다시 그려도 남음). 내용 = 서버 미리 뜬 횡단(`preview-stations` · + * 저장 계획선 기준 · 파일에 안 씀) + 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동. + * [측점 추가] = 추가 측점 초안(일반측점 · [저장] 한 번) · 같은 m 에 측점이 있으면 막음 · [삭제] = 추가 + * 측점일 때만 · [닫기]. + * ========================================================================== */ + +import "./B05_Profile_UI_Profile_CrossModal.css"; +import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan"; +import { draftProjectId } from "../A00_Common/design/design_store"; +import { + chartYAtElevation, + parseWindowTransform, + type ChartWindow, +} from "../A00_Common/design/logic/design_profile_free"; +import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../B06_Section/B06_Section_UI_Longitudinal"; +import { previewStructureSections } from "../B06_Section/B06_Section_Api_Fetch"; +import { stationLabel } from "../common_util/common_util_station"; +import { plusStationsOf, writePlusDraft } from "./B05_Profile_Api_PlusStations"; +import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan"; +import { + buildCrossSvg, + CROSS_PLOT_PAD, + summarizeCross, +} from "./B05_Profile_UI_RouteEdit_Cross_Draw"; + +/** 원지반선을 눌렀다고 볼 위아래 여유(px). */ +const GROUND_SLACK_PX = 8; +/** 같은 측점으로 볼 거리(m) — 정수 m 측점끼리. */ +const SAME_M = 0.5; + +export interface CrossModalOpen { + chainageM: number; + /** 지금 종단 측점 누가거리(일반 · 구조물 · 추가) — 같은 m 막음. */ + stations: readonly number[]; + intervalM: number; +} + +/** 그래프 누름 → 원지반선 근처면 그 누가거리(정수 m) · 아니면 null. */ +export function groundPickAt( + chart: HTMLElement, + event: MouseEvent, + chainageAtPx: (px: number) => number, + samples: ReadonlyArray<{ chainage_m?: number; elevation_m?: number | null }>, +): number | 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 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, + ), + }; + 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; +} + +interface CrossModal { + open(request: CrossModalOpen): void; +} + +const modals = new WeakMap(); + +function createCrossModal(host: HTMLElement): CrossModal { + const root = document.createElement("section"); + root.className = "b05-crossmodal"; + root.setAttribute("role", "dialog"); + root.hidden = true; + root.innerHTML = ` +
+ 임시 횡단 + +
+
+
+
+ + + +
`; + const $ = (selector: string): T => root.querySelector(selector)!; + const station = $(".b05-crossmodal__station"); + const box = $(".b05-crossmodal__box"); + const foot = $(".b05-crossmodal__foot"); + const add = $('[data-act="add"]'); + const remove = $('[data-act="remove"]'); + // 종단 그래프 누름 · 우클릭으로 새지 않게(창 안 조작은 창 몫) + ["click", "pointerdown", "contextmenu"].forEach((type) => + root.addEventListener(type, (event) => event.stopPropagation()), + ); + host.append(root); + + let current: CrossModalOpen | null = null; + let turn = 0; + const projectId = (): string => draftProjectId(); + const isPlus = (m: number): boolean => plusStationsOf(projectId()).includes(m); + + function syncButtons(): void { + if (!current) return; + const m = current.chainageM; + const plus = isPlus(m); + const taken = !plus && current.stations.some((at) => Math.abs(at - m) < SAME_M); + add.hidden = plus; + add.disabled = taken; + add.title = taken ? "이미 측점이 있는 자리입니다." : ""; + remove.hidden = !plus; + } + + async function draw(m: number): Promise { + const mine = (turn += 1); + box.replaceChildren(); + foot.textContent = "횡단을 뜨는 중…"; + try { + const [cross] = await previewStructureSections(projectId(), [m]); + if (mine !== turn) return; + if (!cross) { + foot.textContent = "이 자리의 횡단을 뜨지 못했습니다."; + return; + } + const preview = cross as unknown as CrossPreviewResponse; + const rect = box.getBoundingClientRect(); + const plot = buildCrossSvg( + preview, + Math.round(rect.width) || 320, + Math.round(rect.height) || 180, + ); + box.replaceChildren(plot.svg); + attachSvgZoomPan({ + svg: plot.svg, + layer: plot.layer, + widthPx: plot.width, + heightPx: plot.height, + pad: CROSS_PLOT_PAD, + content: plot.content, + wheelZoom: true, + }); + foot.textContent = `${summarizeCross(preview)} · 저장된 계획선 기준`; + } catch (error) { + if (mine === turn) + foot.textContent = error instanceof Error ? error.message : "횡단을 뜨지 못했습니다."; + } + } + + function close(): void { + root.hidden = true; + current = null; + turn += 1; + } + + add.addEventListener("click", () => { + if (!current || add.disabled) return; + writePlusDraft(projectId(), [...plusStationsOf(projectId()), current.chainageM]); + syncButtons(); + }); + remove.addEventListener("click", () => { + if (!current) return; + const m = current.chainageM; + writePlusDraft( + projectId(), + plusStationsOf(projectId()).filter((at) => at !== m), + ); + syncButtons(); + }); + $('[data-act="close"]').addEventListener("click", close); + + return { + open(request) { + current = request; + station.textContent = stationLabel(request.chainageM, request.intervalM); + root.hidden = false; + syncButtons(); + void draw(request.chainageM); + }, + }; +} + +/** 종단 칸(`host` · 다시 그려도 남는 칸)에 작은 창을 열고 그 자리 임시 횡단을 그린다. */ +export function openCrossModal(host: HTMLElement, request: CrossModalOpen): void { + let modal = modals.get(host); + if (!modal) modals.set(host, (modal = createCrossModal(host))); + modal.open(request); +} + +/** 추가 측점 지움 — 우클릭 「측점 삭제」(368-3). */ +export function removePlusStation(chainageM: number): void { + const projectId = draftProjectId(); + writePlusDraft( + projectId, + plusStationsOf(projectId).filter((at) => at !== Math.round(chainageM)), + ); +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index d0a4fad59..ad3b09227 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -52,6 +52,11 @@ import { } from "./B05_Profile_UI_Profile_Layout"; import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures"; +import { + groundPickAt, + openCrossModal, + removePlusStation, +} from "./B05_Profile_UI_Profile_CrossModal"; import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc"; import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; @@ -225,7 +230,26 @@ export function renderProfile(ctx: ProfileRenderContext): void { )(event.clientX - rect.left + chartWrap.scrollLeft); if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return; } + return; } + // 원지반선 근처 · 추가 측점 선 누름 = 그 자리 임시 횡단 작은 창(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); + if (at === null) return; + openCrossModal(body.parentElement ?? body, { + chainageM: at, + stations: graphLongitudinal.stations.map((station) => station.chainage_m), + intervalM: stationIntervalM ?? 20, + }); }); const designProfiles = alignment ? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])] @@ -301,6 +325,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { chainageAt: chainageInverter(longitudinal, width, layout.originOffset), maxChainageM: maxChainageOf(longitudinal), onRemove: (station) => callbacks?.onStructureRemove?.(station), + onRemovePlus: removePlusStation, onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage), onAddStructure: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type), // 사이드 「구조물 배치」 군 · 종류 드롭다운 그대로(129-1 — 패널이 가진 목록). diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 1a6c3bc4d..0a777853b 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -25,6 +25,7 @@ import type { PickGroup } from "./B05_Profile_UI_Structures_Panel_Types"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { pipeStationMarks, + PLUS_OWNER_PREFIX, structureStationMarks, } from "../common_util/common_util_structure_station"; @@ -59,6 +60,8 @@ export interface StructureLineOptions { maxChainageM: number; /** 선을 지울 때. */ onRemove: (station: IrregularStation) => void; + /** 추가 측점을 지울 때(368-3 · 우클릭 「측점 삭제」). */ + onRemovePlus?: (chainageM: number) => void; /** 빈 자리에서 우클릭해 배관을 넣을 때. */ onAddPipe: (chainageM: number) => void; /** 빈 자리에서 우클릭해 배관 외 구조물(기성막이/대피로/기타)을 넣을 때. */ @@ -109,13 +112,16 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti () => options.onAddStructure?.(at, type), ]), ]; + const plus = near?.station.id.startsWith(PLUS_OWNER_PREFIX); menu.open(localX, event.clientY - rect.top, [ ...(near ? [ - [ - isPipeStation(near.station) ? "배관 삭제" : "구조물 삭제", - () => options.onRemove(near.station), - ] as [string, () => void], + (plus + ? ["측점 삭제", () => options.onRemovePlus?.(near.station.chainage_m)] + : [ + isPipeStation(near.station) ? "배관 삭제" : "구조물 삭제", + () => options.onRemove(near.station), + ]) as [string, () => void], ] : addItems), ]); diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index a141ae442..6db6dc2e9 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -499,6 +499,12 @@ html.is-bm-route .b06-section__notice-bar { stroke-dasharray: 5 3; } +/* 추가 측점(368-3) = 일반측점 색 점선 */ +.b06-chart__station-line--plus { + stroke: var(--color-warning); + stroke-dasharray: 5 3; +} + .b06-chart__station-line--selected, .b06-chart__center-marker { stroke: var(--color-danger); diff --git a/resources/tester/helper_233_4_empty_click.cjs b/resources/tester/helper_233_4_empty_click.cjs index b474e6ed5..a9afb744a 100644 --- a/resources/tester/helper_233_4_empty_click.cjs +++ b/resources/tester/helper_233_4_empty_click.cjs @@ -52,10 +52,34 @@ const chartWrap = { getBoundingClientRect: () => ({ left: 0 }), scrollLeft: 0, }; +// 368-3 원지반선 근처 누름 = 작은 창 — 여기 빈 곳은 원지반선에서 멂(groundPickAt = null) +const groundCalls = []; run( chartClick, - ["ctx", "callbacks", "chartWrap", "chainageInverter", "longitudinal", "width", "layout"], - [ctx, callbacks, chartWrap, () => (x) => x, null, 100, { originOffset: 0 }], + [ + "ctx", + "callbacks", + "chartWrap", + "chainageInverter", + "longitudinal", + "width", + "layout", + "groundPickAt", + "openCrossModal", + "graphData", + ], + [ + ctx, + callbacks, + chartWrap, + () => (x) => x, + null, + 100, + { originOffset: 0 }, + () => null, + () => groundCalls.push("modal"), + { samples: [] }, + ], ); handler({ target: target(), clientX: 42 }); out.profileEmpty = calls.splice(0); diff --git a/resources/tester/test_368_3_cross_modal.py b/resources/tester/test_368_3_cross_modal.py new file mode 100644 index 000000000..23b1f1a4e --- /dev/null +++ b/resources/tester/test_368_3_cross_modal.py @@ -0,0 +1,33 @@ +"""368-3 원지반 점 임시 횡단 작은 창 · 추가 측점 점선 · 우클릭 「측점 삭제」 — 연결 자리를 소스로 봄. + +화면 동작(누름 → 창 → [측점 추가] → B06 카드 → 삭제)은 ORCA 로 확인. +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def test_추가_측점_선_점선_끌기_없음() -> None: + graph = _read("B06_Section/B06_Section_UI_Longitudinal.ts") + assert 'station.kind === "irregular" && !station.plus' in graph + assert 'station.plus ? "plus" : station.kind' in graph + css = _read("B06_Section/B06_Section_UI_Style_Cross.css") + rule = css[css.index(".b06-chart__station-line--plus") :].split("}")[0] + assert "var(--color-warning)" in rule and "stroke-dasharray: 5 3" in rule + assert "PLUS_OWNER_PREFIX" in _read("B05_Profile/B05_Profile_UI_Profile_Layout.ts") + + +def test_누름_작은_창_우클릭_삭제() -> None: + render = _read("B05_Profile/B05_Profile_UI_Profile_Render.ts") + assert "groundPickAt(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 + modal = _read("B05_Profile/B05_Profile_UI_Profile_CrossModal.ts") + assert "previewStructureSections(projectId(), [m])" in modal + assert "add.disabled = taken" in modal # 같은 m 막음