feat(B05 평면 구조물 마커): 구조물 측점 마커 · 고름 정보 창 · 20 m 법선 · 끌기 · 방향키 1 m (399)

- 마커 = 종단 알약과 같은 목록(laneStructures) · 노선 위 알약 꼴(종류 색) · 스냅 · 간격과 무관
- 고름 = 알약 고름과 같은 길(onStructureSelect · 공용 고름) · 알약 벌룬 그대로 · 좌우 20 m 법선
- 끌기 · 방향키 = 노선 따라 정수 m 1 m 씩 · 시점 · 종점 밖 안 감 · 놓을 때 알약 끌기와 같은 길로 한 번
- 집기 차례 = 교각점 · 곡선 손잡이 다음 · 옛 마름모 지움
- 시험 test_399_plan_structure_marks · test_391 마름모 줄 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
2026-10-17 21:43:51 +09:00
co-authored by Claude Opus 5.5
parent 614f12bb88
commit c3efa6dd7e
10 changed files with 938 additions and 56 deletions
+35 -22
View File
@@ -84,6 +84,7 @@ import {
} from "../../B05_Profile/B05_Profile_UI_Page_Helpers";
import { createProfileSections, type ProfileViewState } from "./design_view_profile_sections";
import { createProfile3d } from "./design_view_profile_3d";
import { setPlanMarkSource } from "../../B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks";
type PipeReport = Parameters<NonNullable<RouteProfilePanelCallbacks["onPipesChanged"]>>[0];
@@ -159,6 +160,28 @@ export async function mountProfileView(
profilePanel.setSelectedStation(target.stationId);
if (id === null && target.stationId !== null) viewer.markers.selectStation(target.stationId);
};
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). 평면 구조물 마커(399)도 이 길.
const onStructureSelect = (structureId: string | null): void => {
const chainage = bridge.pipeMarkChainage(structureId);
// 측점 없는 관(물넘이포장)은 알약 id 도 남김 — 같은 m 일반 측점선을 안 켬(요구 178)
const keepId = chainage === null || bridge.stationlessPipeMark(structureId);
rememberPick(bridge.markChainage(structureId), keepId ? structureId : null);
if (chainage !== null) {
panel.structures.selectPipeByChainage(chainage);
selectStationOfPipe(chainage);
return;
}
panel.structures.selectById(structureId);
};
const onStructureMarkMove = (structureId: string, toChainage: number): void => {
const chainage = bridge.pipeMarkChainage(structureId);
if (chainage !== null) {
profilePanel.drainage.movePipe(chainage, toChainage);
return;
}
panel.structures.moveById(structureId, toChainage);
};
// 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b).
const profilePanel = createRouteProfilePanel(
shared,
@@ -192,28 +215,8 @@ export async function mountProfileView(
// 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다.
onBasinSelected: (chainageM) => pickFromMap(chainageM),
onPipeSelected: (chainageM) => pickFromMap(chainageM),
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
onStructureSelect: (structureId) => {
const chainage = bridge.pipeMarkChainage(structureId);
// 측점 없는 관(물넘이포장)은 알약 id 도 남김 — 같은 m 일반 측점선을 안 켬(요구 178)
const keepId = chainage === null || bridge.stationlessPipeMark(structureId);
rememberPick(bridge.markChainage(structureId), keepId ? structureId : null);
if (chainage !== null) {
panel.structures.selectPipeByChainage(chainage);
selectStationOfPipe(chainage);
return;
}
panel.structures.selectById(structureId);
},
onStructureMarkMove: (structureId, toChainage) => {
const chainage = bridge.pipeMarkChainage(structureId);
if (chainage !== null) {
profilePanel.drainage.movePipe(chainage, toChainage);
return;
}
panel.structures.moveById(structureId, toChainage);
},
onStructureSelect,
onStructureMarkMove,
onStructureTypeAdd: (chainage, typeId) => panel.structures.addAt(chainage, typeId),
},
);
@@ -376,6 +379,16 @@ export async function mountProfileView(
renderStationLines: () => sections.redrawStationLines(),
isRestoring: () => state.restoring,
});
// 평면 구조물 측점 마커(399) — 알약과 같은 목록 · 고름 · 옮김
host.onDispose(
setPlanMarkSource(activeProjectId, {
marks: bridge.marks,
types: bridge.types,
intervalM: () => panel.values().stationInterval || 20,
select: onStructureSelect,
move: onStructureMarkMove,
}),
);
// 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2)
const profilePick = bindProfilePick(
activeProjectId,
@@ -37,6 +37,7 @@ import { planStations } from "../common_util/common_util_structure_station";
import { plannedIrregular } from "../A00_Common/design/design_structure_status";
import { pipeChainageOf, pipeIdAt, withPipeIds } from "../A00_Common/design/design_pipe_ids";
import { pageStructures, pageTypesChecked } from "../A00_Common/design/design_page_data";
import { planMarksChanged } from "./B05_Profile_UI_RouteEdit_StructureMarks";
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
export interface PipeProjection {
@@ -155,6 +156,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
function syncGraphStructures(): void {
deps.profilePanel().setStructures([...ownStructures, ...pipeMarks]);
planMarksChanged(deps.projectId); // 평면 구조물 마커 = 알약과 같은 목록(399)
}
/** 관 지점을 알약 레인용 가상 구조물로 만든다(정본은 pipe_points, 저장하지 않는다). */
@@ -337,6 +339,9 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
},
/** 측점 계획을 다시 맞춤 — 추가 측점이 바뀜(368-2). */
resyncStations: () => syncCrossDrainStations(),
/** 알약 레인 목록 · 종류 — 평면 구조물 마커가 같은 것을 씀(399). */
marks: (): StructureInstance[] => [...ownStructures, ...pipeMarks],
types: (): StructureType[] => planTypes,
/** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */
irregularStations: () => irregularStations,
/** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */
@@ -12,6 +12,7 @@ import { pickPx } from "../A00_Common/touch_bridge";
import { hitPreparedLayer, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { bindRouteEditKeys } from "./B05_Profile_UI_RouteEdit_Keys";
import { bindStructureMarks } from "./B05_Profile_UI_RouteEdit_StructureMarks";
import {
dragHandleTo as curveDragTo,
type EditedCurve,
@@ -191,6 +192,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
let dragMoved = false;
/** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격/2 밂). */
let dragRaw: Vertex[] | null = null;
/** 구조물 측점 마커(399) — 집기 차례 = 교각점 · 손잡이 다음 · 끌기 · 방향키 1 m. */
const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null);
canvas.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
@@ -236,6 +239,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
})()
) {
/* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */
} else if (marks.press(px, py)) {
/* 구조물 마커를 골랐다 — 끌면 노선 따라 1 m 씩(399). */
} else if (
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
(() => {
@@ -288,6 +293,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
const rect = canvas.getBoundingClientRect();
const [px, py] = params.unrotate(event.clientX - rect.left, event.clientY - rect.top);
const state = params.state();
if (marks.move(px, py)) return;
if (dragHandle) {
// 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정).
const node = dragHandle.node;
@@ -361,7 +367,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
? state.apexLock[overNode]
? "not-allowed"
: "grab"
: handleAt(px, py)
: handleAt(px, py) || marks.over(px, py)
? "grab"
: over
? "pointer"
@@ -373,6 +379,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
const endDrag = (event: PointerEvent): void => {
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
marks.release();
// 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번
// 눌러야 한다. 놓는 순간에만 쌓는다.
if (dragMoved) {
+4 -22
View File
@@ -29,12 +29,12 @@ import { contourBandRect, setStationLabelBoxes } from "./B05_Profile_UI_RouteEdi
import { drawPickHover, drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover";
import { drawPickedCurve, traceNodeTriangle } from "./B05_Profile_UI_RouteEdit_Curve_Pick";
import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross";
import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame";
import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure";
import { stationLabel as formatStation } from "../common_util/common_util_station";
import { arcLocked, straightLocked } from "./B05_Profile_UI_RouteEdit_Lock";
import { drawFootprint } from "./B05_Profile_UI_RouteEdit_Footprint";
import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
import { drawStructureMarks } from "./B05_Profile_UI_RouteEdit_StructureMarks";
/** 노드 반지름(px) — 요구 241-14 로 4 → 6(고르면 +2). */
const NODE_R = 6;
@@ -255,6 +255,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
// 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저
// 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다.
drawContourLabels(context, scene, band, style);
drawStructureMarks(context, scene, line, style); // 구조물 측점 마커(399) — 라벨 위 · 교각점 아래
// 노드 · 손잡이는 라벨 뒤 맨 위(381-3) — 측점 · 기울기 · 높이 글자에 묻혀 안 집히던 자리.
context.save();
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
@@ -396,33 +397,14 @@ function drawMeasureMarks(
}
/** 고른 측점 — 맴돔과 같은 좌우 20 m 법선(391-1 · `drawStationNormal`) · 구조물 · 관 고름 =
* 그 자리 마름모(배수유역도 `markStation` 꼴)만. 절성토 색은 발자국 선이 보여 줌. */
* 구조물 측점 마커가 법선 · 정보 창까지(399 · `drawStructureMarks`). 절성토 색은 발자국 선이 보여 줌. */
function drawCrossWidth(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
): void {
const watch = scene.crossWidth;
if (!watch) return;
if (!watch.structure) {
drawStationNormal(context, scene, line, watch.chainageM);
return;
}
const frame = stationFrameAt(line, watch.chainageM);
if (!frame) return;
const [cx, cy] = scene.toScreen(frame.origin);
context.save();
context.setLineDash([]);
context.translate(cx, cy);
context.rotate(Math.PI / 4);
context.beginPath();
context.rect(-7, -7, 14, 14);
context.fillStyle = "rgba(255, 196, 0, 0.35)";
context.fill();
context.lineWidth = 2.5;
context.strokeStyle = "#ff9800";
context.stroke();
context.restore();
if (watch && !watch.structure) drawStationNormal(context, scene, line, watch.chainageM);
}
/**
@@ -0,0 +1,386 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_StructureMarks.ts
* 평면 **구조물 측점 마커**(PLAN 399 · 요구 250-2).
*
* · 목록 = 종단 아래 알약과 같은 것(`laneStructures` · 관 · 구조물 · 물넘이 · 사토장 …) · 노선 위 그 측점에
* 알약 꼴(종류 색) · 일반 측점 눈금 · 스냅 · 간격과 무관.
* · 고름 = 알약 고름과 같은 길(`select` · 공용 고름 382-2 — 평면 마커 고름 = 종단 알약 강조 · 반대도).
* 고른 마커 = 알약 벌룬과 같은 창(`balloonElement` · `balloonLines`) + 그 측점 좌우 20 m 법선(391).
* · 끌기 · 방향키(마커 고른 때 · 교각점 안 고른 때) = 노선 따라 정수 m · 1 m 씩 · 시점 · 종점 밖 안 감 ·
* 놓을 때 · 뗄 때 한 번 옮김(`move` = 알약 끌기와 같은 길 · 관 · 구조물 초안 · [저장]이 정본).
* · 집기 차례 = 교각점 · 곡선 손잡이 다음(381-3) — `_Pointer` 가 부름.
* 목록 · 고름 · 옮김은 종단 보기가 `setPlanMarkSource` 로 건넴. 걸음 셈은 `logic/…_StructureMarks`.
* ========================================================================== */
import { pickPx } from "../A00_Common/touch_bridge";
import { draftProjectId } from "../A00_Common/design/design_store";
import { PIPE_MATCH_M, readSelection } from "../A00_Common/design/design_selection";
import { lastPointerWithin } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
import {
structureAnchorM,
type StructureInstance,
type StructureType,
} from "./B05_Profile_Api_Structures";
import {
balloonElement,
laneStructures,
positionText,
structurePillTexts,
} from "./B05_Profile_UI_Structures_Marks";
import { balloonLines } from "./logic/B05_Profile_UI_Structures_Balloon";
import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit";
import { drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover";
import { routePointAtScreen } from "./B05_Profile_UI_RouteEdit_Input";
import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer";
import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render";
import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame";
import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import { arrowStep, isArrowKey, type ArrowKey } from "./logic/B05_Profile_RouteEdit_KeyNudge";
import {
arrowSign,
markChainage,
markStep,
pickedMarkId,
} from "./logic/B05_Profile_RouteEdit_StructureMarks";
import { stationLabel as formatStation } from "../common_util/common_util_station";
/** 마커를 잡았다고 볼 거리(px). */
const MARK_HIT_PX = 10;
/** 알약 마커 반 길이 · 굵기(px) — 고르면 조금 크게. */
const MARK_HALF_PX = 5;
const MARK_WIDTH_PX = 8;
/** 고른 마커 바깥 테 — 법선과 같은 하늘색(391). */
const PICKED_RING = "#0ea5e9";
/** 종단 보기가 건네는 것 — 알약 목록 · 종류 · 측점 간격 · 고름 · 옮김(알약과 같은 길). */
export interface PlanMarkSource {
marks: () => ReadonlyArray<StructureInstance>;
types: () => ReadonlyArray<StructureType>;
intervalM: () => number;
select: (structureId: string) => void;
move: (structureId: string, toChainageM: number) => void;
}
const sources = new Map<string, PlanMarkSource>();
/** 목록이 바뀌면 다시 그릴 편집 창(프로젝트마다). */
const redraws = new Map<string, Set<() => void>>();
/** 알약 목록이 바뀜(추가 · 옮김 · 노선 따라감) — 그 프로젝트 편집 창을 다시 그림. */
export function planMarksChanged(projectId: string): void {
redraws.get(projectId)?.forEach((redraw) => redraw());
}
/** 프로젝트의 마커 재료를 건넴 — 돌려준 함수로 뗌(보기를 떠날 때). */
export function setPlanMarkSource(projectId: string, source: PlanMarkSource): () => void {
sources.set(projectId, source);
return () => void (sources.get(projectId) === source && sources.delete(projectId));
}
interface PlanMark {
id: string;
chainageM: number;
structure: StructureInstance;
type: StructureType | undefined;
name: string;
}
/** 옮기는 중(끌기 · 방향키 누름) — 놓을 때 · 뗄 때 `to` 로 한 번 옮김. 창(캔버스)마다 따로. */
interface Moving {
id: string;
name: string;
from: number;
to: number;
totalM: number;
}
const movings = new WeakMap<HTMLCanvasElement, Moving | null>();
const balloons = new WeakMap<HTMLCanvasElement, { key: string; element: HTMLElement }>();
const lengthOf = (line: ReadonlyArray<Vertex>): number =>
line.reduce(
(sum, vertex, index) =>
index ? sum + Math.hypot(vertex[0] - line[index - 1][0], vertex[1] - line[index - 1][1]) : 0,
0,
);
/** 지금 마커 목록(옮기는 것은 그 자리) · 고른 것 — 재료가 없으면 null. */
function planMarks(
canvas: HTMLCanvasElement,
): { source: PlanMarkSource; marks: PlanMark[]; picked: string | null } | null {
const projectId = draftProjectId();
const source = sources.get(projectId);
if (!source) return null;
const list = laneStructures(source.marks());
const types = new Map(source.types().map((type) => [type.type_id, type]));
const texts = structurePillTexts(list, types);
const moving = movings.get(canvas) ?? null;
const marks = list
.filter((structure) => structure.structure_id)
.map((structure): PlanMark => {
const id = structure.structure_id as string;
return {
id,
chainageM: moving?.id === id ? moving.to : structureAnchorM(structure),
structure,
type: types.get(structure.type_id),
name: texts.get(id)?.name ?? structure.type_id,
};
});
// 옮기는 중엔 그것 — 관 고름은 누가거리만 들어 옮긴 자리로는 못 찾음
const picked = moving?.id ?? pickedMarkId(marks, readSelection(projectId), PIPE_MATCH_M);
return { source, marks, picked };
}
/** 알약 꼴 마커 한 개 — 노선 방향으로 누운 둥근 막대(종류 색 · 흰 테 · 고르면 하늘 테). */
function paintMark(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
mark: PlanMark,
picked: boolean,
halo: string,
): void {
const frame = stationFrameAt(line, mark.chainageM);
if (!frame) return;
const [x, y] = scene.toScreen(frame.origin);
const [fx, fy] = scene.toScreen([
frame.origin[0] + frame.left[1],
frame.origin[1] - frame.left[0],
]); // 앞쪽 1 m(왼쪽 축을 시계로 90°)
const half = picked ? MARK_HALF_PX + 2 : MARK_HALF_PX;
context.save();
context.translate(x, y);
context.rotate(Math.atan2(fy - y, fx - x));
context.lineCap = "round";
const stroke = (color: string, width: number): void => {
context.strokeStyle = color;
context.lineWidth = width;
context.beginPath();
context.moveTo(-half, 0);
context.lineTo(half, 0);
context.stroke();
};
if (picked) stroke(PICKED_RING, MARK_WIDTH_PX + 7);
stroke(halo, MARK_WIDTH_PX + 3);
stroke(mark.type?.style?.color ?? "#888", MARK_WIDTH_PX);
context.restore();
}
/** 마커 · 고른 마커 법선 · 정보 창 — `_Render` 가 라벨 뒤 · 교각점 아래에 부름. */
export function drawStructureMarks(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
style: CSSStyleDeclaration,
): void {
const canvas = context.canvas;
const found = line.length >= 2 ? planMarks(canvas) : null;
const halo = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
const picked = found?.marks.find((mark) => mark.id === found.picked) ?? null;
found?.marks.forEach(
(mark) => mark !== picked && paintMark(context, scene, line, mark, false, halo),
);
if (picked) {
drawStationNormal(context, scene, line, picked.chainageM);
paintMark(context, scene, line, picked, true, halo);
}
showBalloon(canvas, scene, line, picked && found ? { mark: picked, source: found.source } : null);
}
/** 고른 마커 위 정보 창 — 알약 벌룬 그대로 · 마커 위(위가 모자라면 아래) · 누름은 지나감. */
function showBalloon(
canvas: HTMLCanvasElement,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
pick: { mark: PlanMark; source: PlanMarkSource } | null,
): void {
const kept = balloons.get(canvas);
const frame = pick && stationFrameAt(line, pick.mark.chainageM);
if (!pick || !frame || !canvas.parentElement) {
kept?.element.remove();
balloons.delete(canvas);
return;
}
const { mark } = pick;
const structure = { ...mark.structure, chainage_m: mark.chainageM };
const position = positionText(structure, pick.source.intervalM());
const lines = balloonLines(structure, mark.type, { name: mark.name, position });
const key = JSON.stringify([lines, mark.type?.style?.color]);
let element = kept?.element;
if (!element || kept?.key !== key) {
const next = balloonElement(lines, mark.type);
next.classList.add("is-center");
next.style.pointerEvents = "none";
if (element) element.replaceWith(next);
else canvas.parentElement.append(next);
element = next;
balloons.set(canvas, { key, element });
}
// 그림은 캔버스 가운데를 축으로 돎 — 창 자리도 같은 만큼 돌림
const [sx, sy] = scene.toScreen(frame.origin);
const cx = scene.view.width / 2;
const cy = scene.view.height / 2;
const cos = Math.cos(scene.rotationRad);
const sin = Math.sin(scene.rotationRad);
const x = cx + (sx - cx) * cos - (sy - cy) * sin;
const y = cy + (sx - cx) * sin + (sy - cy) * cos;
element.style.left = `${x}px`;
element.style.top = `${y}px`;
element.style.bottom = "auto";
element.style.transform =
y < element.offsetHeight + 24 ? "translate(-50%, 14px)" : "translate(-50%, calc(-100% - 14px))";
}
export interface StructureMarkInput {
/** 마커를 눌렀으면 고르고 끌기 시작 · true(교각점 · 손잡이 다음 차례). */
press: (px: number, py: number) => boolean;
/** 끄는 중이면 자리 바꿈 · true. */
move: (px: number, py: number) => boolean;
/** 끄는 중이었으면 놓음(옮겼으면 한 번 옮김) · true. */
release: () => boolean;
/** 마커 위인가 — 커서 몫. */
over: (px: number, py: number) => boolean;
}
/** 끌기 · 방향키 배선 — `_Pointer` 가 부름(같은 재료 `params`). */
export function bindStructureMarks(
params: RouteEditPointerParams,
dragging: () => boolean,
): StructureMarkInput {
const { canvas, status } = params;
const projectId = draftProjectId();
const list = redraws.get(projectId) ?? new Set<() => void>();
redraws.set(projectId, list);
const redraw = (): void => void (canvas.isConnected ? params.draw() : list.delete(redraw));
list.add(redraw);
const lineNow = (): Vertex[] => {
const state = params.state();
return state.plannedLine.length ? state.plannedLine : state.planned;
};
const markAt = (px: number, py: number): PlanMark | null => {
const found = planMarks(canvas);
const line = lineNow();
let best: PlanMark | null = null;
let bestDistance = pickPx(MARK_HIT_PX);
for (const mark of found?.marks ?? []) {
const frame = stationFrameAt(line, mark.chainageM);
if (!frame) continue;
const [x, y] = params.toScreen(frame.origin);
const distance = Math.hypot(x - px, y - py);
if (distance <= bestDistance) [best, bestDistance] = [mark, distance];
}
return best;
};
const say = (moving: Moving, done: boolean): void => {
const interval = sources.get(draftProjectId())?.intervalM() ?? params.stationIntervalM;
const shift = moving.to - moving.from;
status.textContent =
`${params.routeHead()} — 구조물 ${moving.name} ${formatStation(moving.from, interval)} → ` +
`${formatStation(moving.to, interval)}(${shift > 0 ? "+" : ""}${shift} m)` +
(done ? " 옮김 — [저장]에서 정본." : " 옮기는 중.");
};
/** 놓음 · 뗌 — 옮겼으면 알약 끌기와 같은 길로 한 번. */
const finish = (): boolean => {
const moving = movings.get(canvas);
if (!moving) return false;
movings.set(canvas, null);
if (moving.to !== moving.from) {
sources.get(draftProjectId())?.move(moving.id, moving.to);
say(moving, true);
}
params.draw();
return true;
};
const start = (mark: PlanMark): Moving => {
const moving = {
id: mark.id,
name: mark.name,
from: mark.chainageM,
to: mark.chainageM,
totalM: lengthOf(lineNow()),
};
movings.set(canvas, moving);
return moving;
};
// 방향키 — 마커를 골랐고 교각점을 안 골랐을 때 · 누름 ~ 뗌 한 번이 한 옮김(교각점 방향키와 같은 반복)
let holdKey: ArrowKey | null = null;
let timer = 0;
const metricAt = (x: number, y: number): Vertex => params.toMetric(...params.unrotate(x, y));
const step = (moving: Moving, key: ArrowKey): void => {
const frame = stationFrameAt(lineNow(), moving.to);
if (!frame) return;
const tangent: Vertex = [frame.left[1], -frame.left[0]];
moving.to = markStep(
moving.to,
arrowSign(key, arrowStep(key, false, metricAt), tangent),
moving.totalM,
);
say(moving, false);
params.draw();
};
const release = (): void => {
window.clearTimeout(timer);
window.clearInterval(timer);
if (holdKey === null) return;
holdKey = null;
finish();
};
const detach = (): void => {
window.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("keyup", onKeyUp, true);
window.removeEventListener("blur", release);
};
function onKeyDown(event: KeyboardEvent): void {
if (!canvas.isConnected) return detach();
if (!isArrowKey(event.key) || !lastPointerWithin(".b05-routeedit")) return;
const target = event.target as HTMLElement | null;
if (target?.closest?.("input, select, textarea, [contenteditable='true']")) return;
if (event.repeat || holdKey || dragging() || params.measureMode()) return;
if (params.state().picked >= 0 || movings.get(canvas)) return; // 교각점 방향키 몫
const found = planMarks(canvas);
const mark = found?.marks.find((entry) => entry.id === found.picked);
if (!mark) return;
event.preventDefault();
const moving = start(mark);
const key = (holdKey = event.key);
step(moving, key);
timer = window.setTimeout(() => {
timer = window.setInterval(() => holdKey === key && step(moving, key), HOLD_INTERVAL_MS);
}, HOLD_DELAY_MS);
}
function onKeyUp(event: KeyboardEvent): void {
if (!canvas.isConnected) return detach();
if (event.key === holdKey) release();
}
window.addEventListener("keydown", onKeyDown, true);
window.addEventListener("keyup", onKeyUp, true);
window.addEventListener("blur", release);
return {
press(px, py) {
const mark = markAt(px, py);
if (!mark) return false;
release();
planMarks(canvas)?.source.select(mark.id); // 알약 고름과 같은 길(공용 고름)
start(mark);
params.draw();
return true;
},
move(px, py) {
const moving = movings.get(canvas);
if (!moving || holdKey) return false;
const hit = routePointAtScreen(lineNow(), params.toScreen, px, py, Infinity);
const near = hit ? markChainage(hit.chainageM, moving.totalM) : moving.to;
// 옛 소수 자리(80.17)는 반올림 자리(80)에 머무는 동안 그대로 — 눌렀다 떼기만 해도 옮겨지지 않게
const to = near === Math.round(moving.from) ? moving.from : near;
if (to !== moving.to) {
moving.to = to;
say(moving, false);
params.draw();
}
return true;
},
release: () => !holdKey && finish(),
over: (px, py) => markAt(px, py) !== null,
};
}
+22 -10
View File
@@ -78,7 +78,7 @@ export interface StructureMarksOptions {
}
/** 위치 표기 — 측점번호+잔여거리(예: 3+18.0). 누가거리 표기는 쓰지 않는다(2026-08-17). */
function positionText(structure: StructureInstance, intervalM: number): string {
export function positionText(structure: StructureInstance, intervalM: number): string {
return structure.placement === "interval"
? `${formatStation(structure.start_m ?? 0, intervalM)} ~ ${formatStation(structure.end_m ?? 0, intervalM)}`
: formatStation(structure.chainage_m ?? 0, intervalM);
@@ -141,6 +141,16 @@ export function structurePillTexts(
);
}
/** 알약 자리 목록 — 노선 초안 동안은 묶음 follow 뒤 자리(평면 구조물 마커 399 도 이 목록). */
export function laneStructures(
structures: ReadonlyArray<StructureInstance>,
): ReadonlyArray<StructureInstance> {
const pipe = pagePipeline(draftProjectId());
return pipe?.input("route")
? followedMarks(structures, pipe.input("structures"), pipe.input("pipes"))
: structures;
}
/**
* 그래프 아래에 구조물 알약 레인을 만들어 돌려준다(호출부가 원하는 위치에 붙인다).
*/
@@ -151,10 +161,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
lane.style.height = `${STRUCTURE_LANE_HEIGHT_PX}px`;
const typeMap = new Map(options.types.map((type) => [type.type_id, type]));
const pipe = pagePipeline(draftProjectId());
const structures = pipe?.input("route")
? followedMarks(options.structures, pipe.input("structures"), pipe.input("pipes"))
: options.structures;
const structures = laneStructures(options.structures);
// 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 알약 글·툴팁·벌룬 제목이
// 모두 이 번호를 따른다.
const texts = structurePillTexts(structures, typeMap);
@@ -266,13 +273,20 @@ function buildBalloon(
anchorPx: number,
row: number | null,
): HTMLElement {
const balloon = document.createElement("div");
balloon.className = "b05-structure__balloon";
const balloon = balloonElement(lines, type);
balloon.classList.add(row === 0 ? "is-left" : row === 1 ? "is-right" : "is-center");
balloon.style.left = `${anchorPx}px`;
balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX}px`;
balloon.style.borderColor = type?.style?.color ?? "#888";
// 붙은 뒤 그 창의 틀에서 잼 — 만들 때는 아직 메인 문서 소속이라 새 창 최대화 때 멈춤(230-2).
queueMicrotask(() => frameOf(balloon, () => flipIfClipped(balloon)));
return balloon;
}
/** 벌룬 몸(제목 + 줄 · 테 색) — 자리는 부르는 쪽(알약 레인 · 평면 구조물 마커 399). */
export function balloonElement(lines: string[], type: StructureType | undefined): HTMLElement {
const balloon = document.createElement("div");
balloon.className = "b05-structure__balloon";
balloon.style.borderColor = type?.style?.color ?? "#888";
const [name, ...rest] = lines;
const title = document.createElement("strong");
title.textContent = name;
@@ -284,8 +298,6 @@ function buildBalloon(
return span;
}),
);
// 붙은 뒤 그 창의 틀에서 잼 — 만들 때는 아직 메인 문서 소속이라 새 창 최대화 때 멈춤(230-2).
queueMicrotask(() => frameOf(balloon, () => flipIfClipped(balloon)));
return balloon;
}
@@ -0,0 +1,47 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_RouteEdit_StructureMarks.ts
* 평면 구조물 측점 마커(399 · 요구 250-2) — 노선 따라 정수 m 자리 · 방향키 부호 · 고른 마커.
*
* · 끌기 · 방향키 = 노선 위 정수 m(시점 0 ~ 종점 내림 · 밖은 끝에 붙음) · 방향키 한 번 1 m(Shift 없음).
* · 방향키 부호 = 누른 화면 방향이 노선 앞쪽과 같은 쪽이면 + · 거의 직각이면 → · ↑ = +.
* · 고른 마커 = 공용 고름 구조물 id 와 같은 것 · id 없으면(관 고름) 그 누가거리 가까운 것.
* 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_StructureMarks`.
* ========================================================================== */
import type { ArrowKey } from "./B05_Profile_RouteEdit_KeyNudge";
import type { Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
/** 노선 위 정수 m 자리 — 0 ~ 종점(내림) 밖은 끝에 붙임. */
export function markChainage(rawM: number, totalM: number): number {
return Math.min(Math.max(Math.round(rawM), 0), Math.max(0, Math.floor(totalM)));
}
/** 방향키 한 번 — 지금 자리(정수로) ± 1 m. */
export const markStep = (chainageM: number, sign: 1 | -1, totalM: number): number =>
markChainage(Math.round(chainageM) + sign, totalM);
/** 방향키 부호 — `keyDir`(누른 화면 방향의 도면 벡터) · `tangent`(노선 앞쪽) 같은 쪽이면 +1. */
export function arrowSign(key: ArrowKey, keyDir: Vertex, tangent: Vertex): 1 | -1 {
const dot = keyDir[0] * tangent[0] + keyDir[1] * tangent[1];
const size = Math.hypot(keyDir[0], keyDir[1]) * Math.hypot(tangent[0], tangent[1]);
if (Math.abs(dot) > size * 0.2) return dot > 0 ? 1 : -1;
return key === "ArrowRight" || key === "ArrowUp" ? 1 : -1;
}
/** 고른 마커 id — 고름 구조물 id 와 같은 것 · id 없으면 그 누가거리 `tolM` 안 가장 가까운 것 · 없으면 null. */
export function pickedMarkId(
marks: ReadonlyArray<{ id: string; chainageM: number }>,
selection: { chainageM: number | null; structureId: string | null } | null,
tolM: number,
): string | null {
if (!selection || selection.chainageM === null) return null;
if (selection.structureId)
return marks.some((mark) => mark.id === selection.structureId) ? selection.structureId : null;
let best: string | null = null;
let bestGap = tolM;
for (const mark of marks) {
const gap = Math.abs(mark.chainageM - selection.chainageM);
if (gap < bestGap) [best, bestGap] = [mark.id, gap];
}
return best;
}
@@ -0,0 +1,301 @@
/* 399 B05 평면 구조물 측점 마커(요구 250-2) — `_RouteEdit_StructureMarks` 를 Node 로 돌려 JSON 한 줄
* (`test_399_plan_structure_marks.py` 가 판정). 알약 목록 · 벌룬 · 고름 · 가게는 가짜 · 노선 셈 · 법선 ·
* 걸음 · 방향키 부호는 진짜(`_Input` · `_Hover` · `logic/_Frame` · `logic/_KeyNudge` · `logic/_StructureMarks`).
* node helper_399_plan_structure_marks.cjs
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const B05 = path.join(__dirname, "..", "..", "B05_Profile");
const out = {};
let selection = null;
const laneInputs = [];
const balloonsMade = [];
/* 가짜 DOM — 벌룬 요소 · 창 열쇠 듣기 */
function fakeElement() {
const element = {
style: {},
classList: { add: (...names) => element.classes.push(...names) },
classes: [],
offsetHeight: 30,
removed: false,
remove: () => (element.removed = true),
replaceWith: (next) => ((element.removed = true), parent.children.push(next)),
};
return element;
}
const parent = { children: [], append: (child) => parent.children.push(child) };
const listeners = {};
global.window = {
addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn),
removeEventListener: () => {},
setTimeout: () => 0,
setInterval: () => 0,
clearTimeout: () => {},
clearInterval: () => {},
};
const key = (type, name) =>
(listeners[type] || []).forEach((fn) =>
fn({ key: name, repeat: false, target: null, preventDefault() {}, stopPropagation() {} }),
);
const STUBS = {
"../A00_Common/touch_bridge": {
pickPx: (v) => v,
isPinchWheel: () => false,
wheelZoomsIn: () => true,
},
"../A00_Common/input_prefs": { isTrackpadPinch: () => false },
"../B04_PreProcess/B04_PreProcess_UI_MapRender": { computeMaxScale: () => 16 },
"../A00_Common/design/design_store": { draftProjectId: () => "p1" },
"../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51, readSelection: () => selection },
"../B06_Section/B06_Section_UI_Cross_Panel_Base": { lastPointerWithin: () => true },
"./B05_Profile_UI_Profile_Edit": { HOLD_DELAY_MS: 500, HOLD_INTERVAL_MS: 100 },
"./B05_Profile_Api_Structures": {
structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0,
},
"./B05_Profile_UI_Structures_Marks": {
laneStructures: (list) => (laneInputs.push(list), list),
structurePillTexts: (list) =>
new Map(list.map((s) => [s.structure_id, { name: `이름 ${s.type_id}`, pill: s.type_id }])),
positionText: (s) => `pos ${s.chainage_m}`,
balloonElement: (lines, type) => {
const element = fakeElement();
element.lines = lines;
element.color = type?.style?.color;
balloonsMade.push(element);
return element;
},
},
"./logic/B05_Profile_UI_Structures_Balloon": {
balloonLines: (structure, type, head) => [
`${head.name} ${head.position}`,
`옵션 ${type?.name}`,
],
},
};
const cache = new Map();
function loadTs(full) {
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
/** 그린 것만 적는 가짜 캔버스 — 획 = 색 · 굵기 · 그때 옮긴 자리(translate) · 선 점. */
function fakeContext(canvas) {
const log = [];
let at = [0, 0];
let points = [];
const ctx = {
canvas,
save() {},
restore: () => (at = [0, 0]),
setLineDash() {},
translate: (x, y) => (at = [at[0] + x, at[1] + y]),
rotate() {},
beginPath: () => (points = []),
moveTo: (x, y) => points.push([x, y]),
lineTo: (x, y) => points.push([x, y]),
arc() {},
fill() {},
stroke: () => log.push({ color: ctx.strokeStyle, width: ctx.lineWidth, at: [...at], points }),
};
return { ctx, log };
}
const marks = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_StructureMarks.ts"));
const logic = loadTs(path.join(B05, "logic", "B05_Profile_RouteEdit_StructureMarks.ts"));
// 노선 = 동서 200 m · 화면 2 px/m(위 = 북) · 구조물 A 40 m(색 #e11d48) · 관 80.17 m(옛 소수)
const EAST = [
[0, 0],
[200, 0],
];
const toScreen = ([x, y]) => [x * 2, -y * 2];
const toMetric = (px, py) => [px / 2, -py / 2];
const structures = [
{ structure_id: "A", type_id: "box", chainage_m: 40, start_m: null, end_m: null },
{ structure_id: "pipe-80.17", type_id: "pipe", chainage_m: 80.17, start_m: null, end_m: null },
];
const types = [
{ type_id: "box", name: "BOX", style: { color: "#e11d48" } },
{ type_id: "pipe", name: "배수관", style: { color: "#2563eb" } },
];
const selects = [];
const moves = [];
let redrawn = 0;
marks.setPlanMarkSource("p1", {
marks: () => structures,
types: () => types,
intervalM: () => 20,
select: (id) => selects.push(id),
move: (id, to) => moves.push([id, to]),
});
const canvas = { isConnected: true, parentElement: parent };
let line = EAST;
let picked = -1;
const status = { textContent: "" };
const scene = () => ({ toScreen, view: { width: 400, height: 300 }, rotationRad: 0 });
const style = { getPropertyValue: () => "" };
const draw = () => {
const fake = fakeContext(canvas);
marks.drawStructureMarks(fake.ctx, scene(), line, style);
return fake.log;
};
const input = marks.bindStructureMarks(
{
canvas,
status,
stationIntervalM: 20,
state: () => ({ planned: line, plannedLine: line, picked }),
toScreen,
toMetric,
unrotate: (x, y) => [x, y],
draw: () => (redrawn += 1),
routeHead: () => "노선",
measureMode: () => false,
},
() => false,
);
// ① 그리기 — 알약 목록 그대로 · 종류 색 · 고름 없으면 법선 · 창 없음
const plain = draw();
out.laneGotSource = laneInputs[laneInputs.length - 1] === structures;
const fills = (log) => log.filter((s) => s.width === 8).map((s) => ({ color: s.color, at: s.at }));
out.plain = { marks: fills(plain), normals: plain.filter((s) => s.color === "#0ea5e9").length };
out.balloonsAfterPlain = parent.children.filter((c) => !c.removed).length;
// ② 공용 고름 = 관(누가거리만 · 382-2) → 그 마커 하늘 테 · 법선 · 창(알약 벌룬 줄)
selection = { chainageM: 80.17, structureId: null };
const pickedPipe = draw();
const ring = pickedPipe.filter((s) => s.color === "#0ea5e9" && s.width === 15);
const normal = pickedPipe.find((s) => s.color === "#0ea5e9" && s.width === 2.4);
const shown = parent.children.filter((c) => !c.removed);
out.pickedPipe = {
ringAt: ring.map((s) => s.at),
normal: normal && normal.points,
balloon: shown.length === 1 && {
lines: shown[0].lines,
left: shown[0].style.left,
top: shown[0].style.top,
color: shown[0].color,
},
};
selection = { chainageM: 40, structureId: "A" };
draw();
const shownA = parent.children.filter((c) => !c.removed);
out.pickedStructureBalloon = shownA.length === 1 && shownA[0].lines;
selection = null;
draw();
out.balloonsAfterRelease = parent.children.filter((c) => !c.removed).length;
// ③ 끌기 — A(화면 80, 0) 누름 = 고름 · 3 m 끌기(86 px) = 43 · 놓을 때 한 번
out.pressMiss = input.press(300, 100);
out.pressA = input.press(80, 3);
out.selects = [...selects];
input.move(83, 10); // 41.5 → 42
input.move(86, -20); // 43
out.dragStatus = status.textContent;
out.movesBeforeRelease = moves.length;
input.release();
out.dragMove = moves[moves.length - 1];
out.dragDoneStatus = status.textContent;
// 노선 밖 — 끝(200) · 시점(0)에 붙음
input.press(80, 0);
input.move(9999, 0);
input.release();
out.pastEnd = moves[moves.length - 1];
input.press(80, 0);
input.move(-500, 0);
input.release();
out.beforeStart = moves[moves.length - 1];
// 옛 소수 관(80.17) — 눌렀다 반올림 자리에서 떼면 안 옮김 · 1 m 끌면 정수
const count = moves.length;
input.press(160.34, 0);
input.move(160.5, 1);
input.release();
out.oldDecimalStay = moves.length === count;
input.press(160.34, 0);
input.move(162.2, 0);
input.release();
out.oldDecimalMoved = moves[moves.length - 1];
// ④ 방향키 — 고른 마커(A) · 교각점 안 고름 = 노선 따라 1 m · 뗄 때 한 번
selection = { chainageM: 40, structureId: "A" };
key("keydown", "ArrowRight");
key("keyup", "ArrowRight");
out.keyRight = moves[moves.length - 1];
key("keydown", "ArrowLeft");
key("keyup", "ArrowLeft");
out.keyLeft = moves[moves.length - 1];
key("keydown", "ArrowUp"); // 노선과 직각 = → · ↑ 는 +
key("keyup", "ArrowUp");
out.keyUpPerpendicular = moves[moves.length - 1];
// 서→동이 아니라 동→서 노선 — 오른쪽(동) = 뒤로
line = [
[200, 0],
[0, 0],
];
key("keydown", "ArrowRight");
key("keyup", "ArrowRight");
out.keyRightWestward = moves[moves.length - 1];
line = EAST;
// 교각점 고름 = 교각점 방향키 몫(마커 안 옮김)
picked = 2;
const before = moves.length;
key("keydown", "ArrowRight");
key("keyup", "ArrowRight");
out.keyWithNodePicked = moves.length - before;
picked = -1;
// 고름 없음 = 안 옮김
selection = null;
key("keydown", "ArrowRight");
key("keyup", "ArrowRight");
out.keyNoPick = moves.length - before;
// ⑤ 목록 바뀜 알림 = 그 편집 창 다시 그림
const was = redrawn;
marks.planMarksChanged("p1");
out.redrawnOnChange = redrawn - was;
// ⑥ 순수 셈
out.logic = {
round: [logic.markChainage(41.4, 200), logic.markChainage(41.6, 200)],
clamp: [logic.markChainage(-3, 200), logic.markChainage(250, 199.6)],
step: [logic.markStep(80.17, 1, 200), logic.markStep(0, -1, 200), logic.markStep(199, 1, 199.6)],
pickId: logic.pickedMarkId(
[{ id: "A", chainageM: 40 }],
{ chainageM: 1, structureId: "A" },
0.51,
),
pickNear: logic.pickedMarkId(
[{ id: "A", chainageM: 40 }],
{ chainageM: 40.3, structureId: null },
0.51,
),
pickFar: logic.pickedMarkId(
[{ id: "A", chainageM: 40 }],
{ chainageM: 41, structureId: null },
0.51,
),
pickOther: logic.pickedMarkId(
[{ id: "A", chainageM: 40 }],
{ chainageM: 40, structureId: "Z" },
0.51,
),
};
console.log(JSON.stringify(out));
@@ -46,7 +46,8 @@ def test_맴돔과_고름이_한_함수():
render = _read("B05_Profile_UI_RouteEdit_Render.ts")
assert "drawStationNormal(context, scene, line, watch.chainageM);" in render
assert "leftCut" not in render and "--b05-footprint-cut" not in render
assert 'context.strokeStyle = "#ff9800";' in render # 구조물 마름모 그대로
assert 'context.strokeStyle = "#ff9800";' not in render # 구조물 고름 = 마커 법선 · 창(399)
assert "drawStructureMarks(context, scene, line, style);" in render
def test_MC_는_호_가운데(results):
@@ -0,0 +1,128 @@
"""399 B05 평면 구조물 측점 마커(요구 250-2) — 알약과 같은 목록 · 고름 = 공용 고름 · 정보 창(알약 벌룬) ·
20 m 법선 · 끌기 · 방향키 = 노선 따라 정수 m 1 m 씩 · 시점 · 종점 밖 안 감 · 놓을 때 한 번 옮김(알약과 같은 길).
TS 셈은 `helper_399_plan_structure_marks.cjs`(Node · 노선 셈 · 법선 · 걸음은 진짜).
./venv/Scripts/python.exe -m pytest resources/tester/test_399_plan_structure_marks.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).with_name("helper_399_plan_structure_marks.cjs")
def _read(*parts: str) -> str:
return (ROOT.joinpath(*parts)).read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_마커_목록은_알약_목록(got) -> None:
assert got["laneGotSource"] is True # 알약 레인과 같은 `laneStructures`
assert got["plain"]["marks"] == [
{"color": "#e11d48", "at": [80, 0]}, # 40 m × 2 px · 종류 색
{"color": "#2563eb", "at": [160.34, 0]},
]
assert got["plain"]["normals"] == 0 and got["balloonsAfterPlain"] == 0
lane = _read("B05_Profile", "B05_Profile_UI_Structures_Marks.ts")
assert "const structures = laneStructures(options.structures);" in lane
marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts")
assert "laneStructures(source.marks())" in marks
bridge = _read("B05_Profile", "B05_Profile_UI_Page_Structures.ts")
assert "setStructures([...ownStructures, ...pipeMarks]);" in bridge
assert "marks: (): StructureInstance[] => [...ownStructures, ...pipeMarks]," in bridge
assert "planMarksChanged(deps.projectId);" in bridge
def test_고름_정보_창_법선(got) -> None:
pipe = got["pickedPipe"]
assert pipe["ringAt"] == [[160.34, 0]] # 관 고름(누가거리만)도 그 마커
assert pipe["normal"] == [[160.34, -40], [160.34, 40]] # 좌우 20 m
assert pipe["balloon"]["lines"] == ["이름 pipe pos 80.17", "옵션 배수관"] # 알약 벌룬 줄
assert pipe["balloon"]["color"] == "#2563eb"
assert got["pickedStructureBalloon"] == ["이름 box pos 40", "옵션 BOX"]
assert got["balloonsAfterRelease"] == 0
def test_공용_고름과_같은_길() -> None:
view = _read("A00_Common", "design", "design_view_profile.ts")
assert " onStructureSelect,\n onStructureMarkMove,\n" in view # 알약 콜백 = 같은 함수
assert "select: onStructureSelect," in view and "move: onStructureMarkMove," in view
render = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Render.ts")
labels = render.index("drawContourLabels(context, scene, band, style);\n drawStructureMarks")
nodes = render.index("scene.planned.forEach((vertex, index) => {")
assert labels < nodes # 라벨 위 · 교각점 아래
def test_집기_차례는_교각점_손잡이_다음() -> None:
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
node = pointer.index("if (hitNode >= 0) {")
handle = pointer.index("} else if (dragHandle) {")
mark = pointer.index("} else if (marks.press(px, py)) {")
tick = pointer.index('if (!pick || !("chainage" in pick)) return false;')
assert node < handle < mark < tick
def test_끌기_1m_정수_놓을_때_한_번(got) -> None:
assert got["pressMiss"] is False and got["pressA"] is True
assert got["selects"] == ["A"]
assert got["movesBeforeRelease"] == 0 # 끄는 동안은 미리보기만
assert got["dragMove"] == ["A", 43] # 3 m
assert "2+0 → 2+3(+3 m) 옮김" in got["dragDoneStatus"]
assert got["oldDecimalStay"] is True # 옛 소수 자리 눌렀다 떼기 = 안 옮김
assert got["oldDecimalMoved"] == ["pipe-80.17", 81]
def test_노선_밖_안_감(got) -> None:
assert got["pastEnd"] == ["A", 200]
assert got["beforeStart"] == ["A", 0]
assert got["logic"]["clamp"] == [0, 199] # 종점 199.6 → 199(정수 m 안쪽)
assert got["logic"]["step"] == [81, 0, 199]
def test_방향키_노선_따라_1m(got) -> None:
assert got["keyRight"] == ["A", 41]
assert got["keyLeft"] == ["A", 39]
assert got["keyUpPerpendicular"] == ["A", 41] # 직각 = → · ↑ 가 +
assert got["keyRightWestward"] == ["A", 39] # 노선이 서쪽으로 가면 → = 뒤로
assert got["keyWithNodePicked"] == 0 # 교각점 고름 = 교각점 방향키 몫
assert got["keyNoPick"] == 0
def test_고른_마커_찾기(got) -> None:
logic = got["logic"]
assert logic["round"] == [41, 42]
assert (logic["pickId"], logic["pickNear"], logic["pickFar"], logic["pickOther"]) == (
"A",
"A",
None,
None,
)
assert got["redrawnOnChange"] == 1
def test_편집기_줄_수_그대로() -> None:
edit = _read("B05_Profile", "B05_Profile_UI_RouteEdit.ts")
assert edit.count("\n") <= 695
for name in (
"B05_Profile_UI_RouteEdit_StructureMarks.ts",
"B05_Profile_UI_RouteEdit_Render.ts",
"B05_Profile_UI_RouteEdit_Pointer.ts",
):
assert _read("B05_Profile", name).count("\n") <= 700