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:
@@ -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);
|
||||
|
||||
@@ -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 막음
|
||||
Reference in New Issue
Block a user