feat(B05 종단): 원지반 점 임시 횡단 작은 창 · [측점 추가] · 점선 · 우클릭 측점 삭제 (368-3)

- 도구 꺼진 때 원지반선 근처 누름(정수 m) · 추가 측점 선 누름 = 종단 칸 안 작은 창
- 내용 = 서버 미리 뜬 횡단(저장 계획선 기준) · 평면 우측 횡단과 같은 그림 · 확대 · 이동
- [측점 추가] · [삭제] · [닫기] · 같은 m 측점 있으면 막음 · 우클릭 「측점 삭제」
- 추가 측점 세로선 = 일반측점 색 점선(--plus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
2026-10-17 12:09:07 +09:00
co-authored by Claude Opus 5.5
parent ae515ee778
commit c8be383428
7 changed files with 366 additions and 6 deletions
@@ -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);
}
@@ -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<SVGSVGElement>(`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<HTMLElement, CrossModal>();
function createCrossModal(host: HTMLElement): CrossModal {
const root = document.createElement("section");
root.className = "b05-crossmodal";
root.setAttribute("role", "dialog");
root.hidden = true;
root.innerHTML = `
<div class="b05-crossmodal__head">
<strong>임시 횡단</strong>
<span class="b05-crossmodal__station"></span>
</div>
<div class="b05-crossmodal__box"></div>
<div class="b05-crossmodal__foot"></div>
<div class="b05-crossmodal__actions">
<button type="button" class="ui-button" data-act="add">측점 추가</button>
<button type="button" class="ui-button" data-act="remove">삭제</button>
<button type="button" class="ui-button" data-act="close">닫기</button>
</div>`;
const $ = <T extends HTMLElement>(selector: string): T => root.querySelector<T>(selector)!;
const station = $(".b05-crossmodal__station");
const box = $(".b05-crossmodal__box");
const foot = $(".b05-crossmodal__foot");
const add = $<HTMLButtonElement>('[data-act="add"]');
const remove = $<HTMLButtonElement>('[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<void> {
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)),
);
}
@@ -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 — 패널이 가진 목록).
@@ -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),
]);
@@ -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);
+26 -2
View File
@@ -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);
@@ -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 막음