feat(382-2): B05 평면 공용 고름 연동(일반측점 = 횡단 판 · 법선 · 구조물 = 마름모 · 눈금 클릭 = 고름 적음) · 법선 = 측점 열쇠 · 발자국 비탈 끝 좌우 · 절성토 색

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-17 14:50:16 +09:00
co-authored by Claude Opus 5.5
parent 0800796998
commit 05d4d7dd36
5 changed files with 296 additions and 43 deletions
+132 -23
View File
@@ -12,12 +12,27 @@
* 값 = 계산 묶음 그 측점(365-3) — 측점 · 지반 표본 = ground 단계 · 계획고 · 설계선 = 종단 → cross 단계
* (B06 카드 · 새 노선 상세와 같은 함수 `stationSection`) · 노선 · 계획고가 바뀔 때마다(cross 차례) 다시 그림 ·
* 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
*
* 고름 연동(382-2) — 공용 고름 한 칸(`design_selection`) 구독: 일반측점 고름 = 그 측점 횡단 · 평면 법선 ·
* 구조물 고름 = 평면 그 자리 마름모만 · 평면 눈금 클릭 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름).
* 보는 측점 = 측점 열쇠(grid:k) — 묶음 차례마다 열쇠로 다시 짚음(노선을 고쳐도 같은 측점).
* 파란 법선 폭 = 그 측점 발자국 기록(가게 `footprint.records`) 비탈 끝 좌 · 우 · 깎기 여부(색).
* ========================================================================== */
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
import { pagePipeline } from "../A00_Common/design/design_pipeline_page";
import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline";
import type {
PipelineStation,
PlanFootprint,
} from "../A00_Common/design/logic/design_pipeline_types";
import {
nearestWithin,
selectDesign,
subscribeSelection,
type DesignSelection,
} from "../A00_Common/design/design_selection";
import { useDesignStore } from "../A00_Common/design/design_store";
import { stationSection } from "./logic/B05_Profile_Pipeline_Detail";
import {
@@ -27,6 +42,8 @@ import {
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
import { readByKey, stateKey } from "../A00_Common/b_page_state";
import { stationLabel as formatStation } from "../common_util/common_util_station";
import { sidePanelsOf } from "./B05_Profile_UI_RouteEdit_Chrome";
import type { FootprintRecord } from "./logic/B05_Profile_PlanFootprint";
export interface CrossPreviewParams {
projectId: string;
@@ -43,19 +60,31 @@ export interface CrossPreviewParams {
onShown?: () => void;
}
/** 위 판에 떠 있는 측점과 그 좌·우 횡단 반폭(m) — 평면도가 반폭 선을 그리는 재료. */
/** 고른 측점과 그 좌·우 폭(m · 좌 + 방향 거리) — 평면도가 법선 · 마름모를 그리는 재료. */
export interface CrossWatch {
chainageM: number;
leftM: number;
rightM: number;
/** 비탈 끝 깎기(절토) 여부 — 발자국 기록이 없으면 없음(옛 파랑 · 반폭). */
leftCut?: boolean;
rightCut?: boolean;
/** 구조물 · 관 자리 — 법선 없이 마름모만. */
structure?: true;
}
/** 공용 고름에 적는 이름 — 평면 눈금 클릭(제 알림은 거름). */
export const PLAN_PICK_FROM = "b05-plan";
/** 법선을 긋는 측점 갈래 — 일반측점(시점 · 종점 포함). */
const isGridStation = (station: PipelineStation): boolean =>
station.kind === "grid" || station.kind === "end";
export interface CrossPreviewWindow {
/** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */
open: (chainageM: number) => Promise<void>;
/** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */
refresh: () => Promise<void>;
/** 위 판에 떠 있는 측점과 반폭. 떠 있지 않으면 null. */
/** 고른 측점과 폭(발자국 비탈 끝 · 없으면 반폭) · 구조물이면 마름모. 고른 것 없으면 null. */
watched: () => CrossWatch | null;
}
@@ -193,18 +222,33 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
params.side.append(current.root, previous.root);
const interval = (): number => params.request().station_interval_m;
/** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */
let watching: number | null = null;
/** 지금 보는 측점 — 열쇠(묶음 전 null) · 누가거리(열쇠를 못 찾을 때) · 구조물 고름(마름모만). */
let watching: { key: string | null; chainageM: number; structure: boolean } | null = null;
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */
let shown: CrossPreviewResponse | null = null;
let drawnRoute: unknown = null;
const stationsNow = (): PipelineStation[] => pipe?.output("ground")?.stations ?? [];
/** 열쇠로 다시 짚은 지금 측점 — 열쇠가 없으면 들고 있던 누가거리 근처. */
function located(): PipelineStation | null {
if (!watching) return null;
const list = stationsNow();
const key = watching.key;
const at = watching.chainageM;
return (
(key ? list.find((station) => station.key === key) : undefined) ??
nearestWithin(list, at, (station) => station.chainageM) ??
null
);
}
function redraw(): void {
if (watching === null) return;
if (watching === null || watching.structure) return;
const route = pipe?.input("route") ?? null;
if (route !== drawnRoute && shown) previous.show(shown, interval());
drawnRoute = route;
shown = pipelineCrossAt(pipe, watching, interval(), appliedHalfWidth(params.projectId));
const at = located()?.chainageM ?? watching.chainageM;
shown = pipelineCrossAt(pipe, at, interval(), appliedHalfWidth(params.projectId));
if (shown) current.show(shown, interval());
else current.wait("계산 중…");
params.onShown?.();
@@ -230,33 +274,98 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
const stop = (): void => {
stopStore();
stopPipe();
stopPick();
};
/** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄. */
function showStation(key: string | null, chainageM: number): void {
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
previous.show(null, interval());
watching = { key, chainageM, structure: false };
drawnRoute = pipe?.input("route") ?? null;
shown = null;
redraw();
const panels = sidePanelsOf.get(params.side);
if (panels && panels.current() === null) panels.open("cross");
}
/** 다른 보기(종단 · 3D · 목록 · 횡단 카드) 고름 → 일반측점이면 그 측점 횡단 · 구조물이면 마름모. */
function follow(selection: DesignSelection): void {
attach();
const at = selection.chainageM;
if (at === null) {
watching = null;
shown = null;
current.show(null, interval());
previous.show(null, interval());
params.onShown?.();
return;
}
const station = nearestWithin(stationsNow(), at, (entry) => entry.chainageM);
if (!selection.structureId && (!station || isGridStation(station)))
return showStation(station?.key ?? null, station?.chainageM ?? at);
watching = { key: station?.key ?? null, chainageM: station?.chainageM ?? at, structure: true };
params.onShown?.();
}
const stopPick = subscribeSelection(params.projectId, (selection, from) => {
if (from !== PLAN_PICK_FROM) follow(selection);
});
attach();
/** 그 측점 발자국 기록(열쇠 · 없으면 누가거리) — 발자국 단계 전이면 없음. */
const footprintAt = (key: string | null, chainageM: number): FootprintRecord | undefined => {
const footprint = useDesignStore(params.projectId, null).get("footprint") as
PlanFootprint | null | undefined;
const records = (footprint?.records ?? []) as FootprintRecord[];
return (
(key ? records.find((record) => record.key === key) : undefined) ??
nearestWithin(records, chainageM, (record) => record.chainage_m)
);
};
return {
async open(chainageM) {
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
previous.show(null, interval());
watching = chainageM;
attach();
drawnRoute = pipe?.input("route") ?? null;
shown = null;
redraw();
const list = stationsNow();
const station = list.length
? list.reduce((best, next) =>
Math.abs(next.chainageM - chainageM) < Math.abs(best.chainageM - chainageM)
? next
: best,
)
: null;
const at = station?.chainageM ?? chainageM;
showStation(station?.key ?? null, at);
selectDesign(
params.projectId,
{ chainageM: at, structureId: null },
{ from: PLAN_PICK_FROM },
);
},
async refresh() {
attach();
redraw();
},
// 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다.
// 반폭 = 적용해 둔 「횡단 반폭」 · 없으면 그린 원지반 선 범위 — 좌·우 같음.
watched: () =>
watching === null || !shown
? null
: {
chainageM: watching,
leftM: shown.cross_half_width_m,
rightM: shown.cross_half_width_m,
},
// 자리 = 열쇠로 다시 짚은 측점 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다.
// 폭 = 그 측점 발자국 비탈 끝 좌 · 우 · 기록이 없으면 「횡단 반폭」(없으면 원지반 선 범위) 양쪽.
watched: () => {
if (!watching) return null;
const station = located();
const chainageM = station?.chainageM ?? watching.chainageM;
if (watching.structure || (station && !isGridStation(station)))
return { chainageM, leftM: 0, rightM: 0, structure: true };
const record = footprintAt(station?.key ?? watching.key, chainageM);
if (record)
return {
chainageM,
leftM: record.left_end_m,
rightM: -record.right_end_m,
leftCut: record.left_cut,
rightCut: record.right_cut,
};
if (!shown) return null;
return { chainageM, leftM: shown.cross_half_width_m, rightM: shown.cross_half_width_m };
},
};
}
+35 -9
View File
@@ -390,8 +390,9 @@ function drawMeasureMarks(
}
/**
* 횡단이 떠 있는 측점의 **좌·우 반폭 선** — 측점에서 노선에 직각, 길이 = 우측 패널 「횡단 반폭」
* (평면 거리 · 횡단 미리보기의 원지반 선 범위와 같다).
* 고른 측점의 **좌·우 법선**(382-2) — 측점에서 노선에 직각, 길이 = 그 측점 발자국 비탈 끝 좌 · 우 ·
* 색 = 깎기(절토) · 쌓기(성토) 토큰(`_Footprint` 와 같은 것) · 기록이 없으면 「횡단 반폭」 양쪽 파랑 ·
* 측점 자리에 고리(눈금 강조). 구조물 · 관 고름 = 그 자리 마름모(배수유역도 `markStation` 꼴)만.
*
* 자리·방향은 **지금 그려진 노선**으로 매 프레임 다시 짚는다(`stationFrameAt` — 서버
* `generate_sections` 짝) — 노드를 끄는 동안 선이 네트워크 없이 바로 따라간다.
@@ -407,17 +408,37 @@ function drawCrossWidth(
if (!frame) return;
const [ox, oy] = frame.origin;
const [lx, ly] = frame.left;
const ends: Vertex[] = [
[ox + lx * watch.leftM, oy + ly * watch.leftM],
[ox - lx * watch.rightM, oy - ly * watch.rightM],
];
const [cx, cy] = scene.toScreen(frame.origin);
context.save();
context.strokeStyle = CROSS_WIDTH_COLOR;
context.fillStyle = CROSS_WIDTH_COLOR;
context.setLineDash([]);
if (watch.structure) {
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();
return;
}
const style = getComputedStyle(document.documentElement);
const tone = (cut: boolean | undefined): string =>
cut === undefined
? CROSS_WIDTH_COLOR
: style.getPropertyValue(cut ? "--b05-footprint-cut" : "--b05-footprint-fill").trim() ||
(cut ? "#dc2626" : "#2563eb");
const ends: Array<[Vertex, string]> = [
[[ox + lx * watch.leftM, oy + ly * watch.leftM], tone(watch.leftCut)],
[[ox - lx * watch.rightM, oy - ly * watch.rightM], tone(watch.rightCut)],
];
context.lineWidth = 2.4;
ends.forEach((end) => {
ends.forEach(([end, color]) => {
const [x, y] = scene.toScreen(end);
context.strokeStyle = color;
context.fillStyle = color;
context.beginPath();
context.moveTo(cx, cy);
context.lineTo(x, y);
@@ -426,6 +447,11 @@ function drawCrossWidth(
context.arc(x, y, 3, 0, Math.PI * 2);
context.fill();
});
context.strokeStyle = CROSS_WIDTH_COLOR;
context.lineWidth = 2;
context.beginPath();
context.arc(cx, cy, 6, 0, Math.PI * 2);
context.stroke();
context.restore();
}
@@ -180,6 +180,7 @@ const store = load("A00_Common", "design", "design_store.ts");
// B05 오른쪽 횡단 판(365-3) — 그림 조각만 가짜 · 값 고르기는 진짜
STUBS["b_svg_zoom_pan"] = { attachSvgZoomPan: () => undefined };
STUBS["B05_Profile_UI_RouteEdit_Cross_Draw"] = { CROSS_PLOT_PAD: {}, summarizeCross: () => "" };
STUBS["B05_Profile_UI_RouteEdit_Chrome"] = { sidePanelsOf: new WeakMap() }; // 판 양식(382-1) — 화면 밖
const pane = load("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts");
const r3 = (value) => (typeof value === "number" ? Math.round(value * 1000) / 1000 : null);
@@ -1,7 +1,7 @@
/* 계획노선 편집 창 **측점 반폭 선 길이**(계획서 22-1) 단위검증 헬퍼 —
/* 계획노선 편집 창 **측점 법선 · 고름 연동**(계획서 22-1 · 382-2) 단위검증 헬퍼 —
* `B05_Profile_UI_RouteEdit_Cross.ts` 를 트랜스파일해 Node 로 돌린다.
* 계산 묶음 · 그림 조각은 가짜로 채운다 — 「무슨 반폭으로 선을 잡나」만 본다(365-3 · 서버 길 없음 ·
* 묶음 그 측점 원지반 표본 = 좌우 20 m).
* 계산 묶음 · 가게 · 그림 조각은 가짜로 채운다 — 공용 고름(`design_selection`)은 진짜.
* 묶음 측점 = 일반측점 grid:2(40) · grid:3(60) · grid:5(100) · 관 pipe:a(80) · 원지반 표본 좌우 20 m.
*/
const fs = require("fs");
const path = require("path");
@@ -57,6 +57,19 @@ globalThis.ResizeObserver = class {
disconnect() {}
};
const grid = (k, chainageM) => ({ key: `grid:${k}`, kind: "grid", chainageM });
let stations = [
grid(2, 40),
grid(3, 60),
{ key: "pipe:a", kind: "pipe", chainageM: 80 },
grid(5, 100),
];
let footprint = null;
let route = { v: 1 };
// 접힌 판 펴기 — 몸(side)에 묶인 패널 묶음 흉내
const panels = { now: null, current: () => panels.now, open: (id) => (panels.now = id) };
const side = { append() {} };
const STUBS = {
"../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan: () => {} },
"../common_util/common_util_station": { stationLabel: (m) => String(m) },
@@ -65,17 +78,19 @@ const STUBS = {
CROSS_PLOT_PAD: 0,
summarizeCross: () => "",
},
// 계산 묶음 흉내 — 측점 하나(100 m) · 원지반 표본 좌우 20 m
"./B05_Profile_UI_RouteEdit_Chrome": { sidePanelsOf: new WeakMap([[side, panels]]) },
"../A00_Common/design/design_pipeline_page": {
pagePipeline: () => ({
output: (name) =>
name === "ground" ? { stations: [{ key: "grid:5", chainageM: 100 }] } : null,
input: () => null,
output: (name) => (name === "ground" ? { stations } : null),
input: () => route,
subscribe: () => () => undefined,
}),
},
"../A00_Common/design/design_store": {
useDesignStore: () => ({ subscribe: () => () => undefined }),
useDesignStore: () => ({
subscribe: () => () => undefined,
get: (name) => (name === "footprint" ? footprint : null),
}),
},
"./logic/B05_Profile_Pipeline_Detail": {
stationSection: (station) => ({
@@ -108,12 +123,26 @@ function loadTs(file) {
const { createCrossPreview } = loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts"),
);
const pick = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"));
const record = (key, chainage_m, left, right, leftCut, rightCut) => ({
key,
chainage_m,
left_edge_m: 2.5,
left_end_m: left,
right_edge_m: -2.5,
right_end_m: right,
left_cut: leftCut,
right_cut: rightCut,
});
async function run() {
let shownCount = 0;
const cross = createCrossPreview({
projectId: "p-1",
side: { append() {} },
side,
request: () => ({ vertices: [], min_radius_m: 12, station_interval_m: 20 }),
onShown: () => (shownCount += 1),
});
const out = {};
out.before = cross.watched();
@@ -129,6 +158,47 @@ async function run() {
session.setItem("aislo:draft:cross-display:p-1:12", "abc");
await cross.refresh();
out.badValue = { watched: cross.watched() };
// 382-2 발자국 기록 = 비탈 끝 좌 · 우 · 깎기 여부
footprint = { records: [record("grid:5", 100, 7.5, -6, true, false)] };
out.footprint = cross.watched();
// 노선 고침 — 같은 열쇠 측점이 102 로 밀리고 발자국도 새로(열쇠로 다시 짚음)
stations = [
grid(2, 40),
grid(3, 60),
{ key: "pipe:a", kind: "pipe", chainageM: 80 },
grid(5, 102),
];
footprint = { records: [record("grid:5", 102, 9, -4, false, true)] };
route = { v: 2 };
await cross.refresh();
out.afterEdit = cross.watched();
// 평면 눈금 클릭 = 공용 고름에 적음 · 판이 접혀 있으면 폄
const heard = [];
pick.subscribeSelection("p-1", (selection, from) =>
heard.push({ at: selection.chainageM, from }),
);
panels.now = null;
await cross.open(41);
out.planClick = {
selection: pick.readSelection("p-1"),
heard: heard.slice(),
panel: panels.now,
watched: cross.watched(),
};
// 다른 보기 고름 — 일반측점 = 그 측점(판 폄) · 관 측점 · 구조물 id = 마름모 · 풂 = 없음
panels.now = null;
pick.selectDesign("p-1", { chainageM: 60, structureId: null }, { from: "b06-card" });
out.otherGrid = { watched: cross.watched(), panel: panels.now };
pick.selectDesign("p-1", { chainageM: 80, structureId: null }, { from: "profile" });
out.otherPipe = cross.watched();
pick.selectDesign("p-1", { chainageM: 60, structureId: "s-1" }, { from: "list" });
out.otherStructure = cross.watched();
const before = shownCount;
pick.selectDesign("p-1", { chainageM: null, structureId: null }, { from: "escape" });
out.cleared = { watched: cross.watched(), redrawn: shownCount > before };
return out;
}
run().then((out) => console.log(JSON.stringify(out)));
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""계획노선 편집 창 — **측점 반폭 선 길이**(계획서 22-1) 회귀검증.
"""계획노선 편집 창 — **측점 법선 길이 · 고름 연동**(계획서 22-1 · 382-2) 회귀검증.
파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 원지반 선 범위
법선 = 그 측점 발자국 비탈 끝 좌 · 우(382-2) · 기록이 없으면 「횡단 반폭(m)」 적용값 양쪽 · 그것도 없으면 원지반 선 범위
(계산 묶음 그 측점 표본 · 365-3).
Node 헬퍼(`helper_b05_routeedit_cross_width.cjs`)가 TS 를 트랜스파일해 돌린다.
"""
@@ -45,3 +45,50 @@ def test_노선이_여럿이면_최신_노선_값_다른_프로젝트는_무시(
def test_망가진_값은_기본값으로(results):
assert results["badValue"]["watched"]["rightM"] == 20
# ── 382-2 법선 = 발자국 비탈 끝 · 측점 열쇠 · 공용 고름 연동 ──────────────────────
def test_발자국_기록이면_비탈_끝_좌우와_깎기(results):
assert results["footprint"] == {
"chainageM": 100,
"leftM": 7.5,
"rightM": 6,
"leftCut": True,
"rightCut": False,
}
def test_노선_고친_뒤_같은_열쇠_측점의_새_발자국(results):
assert results["afterEdit"] == {
"chainageM": 102,
"leftM": 9,
"rightM": 4,
"leftCut": False,
"rightCut": True,
}
def test_평면_눈금_클릭은_공용_고름에_적고_판을_편다(results):
click = results["planClick"]
assert click["selection"] == {"chainageM": 40, "structureId": None}
assert click["heard"] == [{"at": 40, "from": "b05-plan"}]
assert click["panel"] == "cross"
assert click["watched"]["chainageM"] == 40
def test_다른_보기_일반측점_고름은_평면_판(results):
assert results["otherGrid"]["watched"]["chainageM"] == 60
assert "structure" not in results["otherGrid"]["watched"]
assert results["otherGrid"]["panel"] == "cross"
def test_관_구조물_고름은_마름모만(results):
assert results["otherPipe"] == {"chainageM": 80, "leftM": 0, "rightM": 0, "structure": True}
assert results["otherStructure"]["structure"] is True
assert results["otherStructure"]["chainageM"] == 60
def test_고름을_풀면_법선도_없다(results):
assert results["cleared"] == {"watched": None, "redrawn": True}