Merge remote-tracking branch 'origin/CODEX' into CODEX
This commit is contained in:
@@ -71,6 +71,8 @@ config/formula_node/
|
||||
config/spreadsheet_node/
|
||||
# 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물.
|
||||
config/b07_extent_node/
|
||||
# 횡단도 표 위 CAD 칸 옵션 줄 번들 — `npm run build:b07-note` 산출물(166-1).
|
||||
config/b07_note_node/
|
||||
|
||||
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
|
||||
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
|
||||
|
||||
@@ -57,8 +57,6 @@ export const STATE_REGISTRY = {
|
||||
legacy: () => "b05-route-drainage-collapsed",
|
||||
},
|
||||
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
|
||||
/** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */
|
||||
"drainage-visible": { bucket: "pref", scope: "global" },
|
||||
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
|
||||
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
|
||||
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
||||
@@ -110,6 +108,8 @@ export const STATE_REGISTRY = {
|
||||
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
|
||||
/** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */
|
||||
"structure-notes": { bucket: "draft", scope: "route" },
|
||||
/** B06 횡단 [상세] 모달 표 위 CAD 칸(260-1) — 측점키(0.01 m) → CAD 엔티티 묶음. [저장]이 내보냄. */
|
||||
"structure-note-cads": { bucket: "draft", scope: "route" },
|
||||
/** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */
|
||||
"detail-table": { bucket: "draft", scope: "route" },
|
||||
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/b_project_switch.ts
|
||||
* 다른 프로젝트에 들어갈 때 **그 밖 프로젝트의 캐시**를 지운다(계획서 290-1 · 요구 183-1).
|
||||
*
|
||||
* 지우는 것 — 이 탭 세션의 다른 프로젝트 칸(초안 · 결과 · 고름 · 옛 키 · 페이지별 칸)과
|
||||
* 3D 지형 보관함(IndexedDB). 남기는 것 — 지금 프로젝트 것 · 계정 취향(pref, localStorage).
|
||||
* 다른 프로젝트에 저장 안 한 고침(초안)이 있으면 확인창 — 「남기기」면 그 초안만 둔다.
|
||||
* 같은 프로젝트로 다시 들어오면(새로고침 포함) 아무것도 안 지운다(탭마다 들어온 표식).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import { purgeOtherProjects } from "./b_asset_cache";
|
||||
import { STATE_REGISTRY, type StateEntry } from "./b_page_state";
|
||||
|
||||
/** 이 탭이 마지막으로 들어온 프로젝트. */
|
||||
const ENTERED_KEY = "aislo:entered-project";
|
||||
|
||||
const MARK_P = "\u0001";
|
||||
const MARK_R = "\u0002";
|
||||
|
||||
/** 프로젝트를 가르는 키 머리 — 등록표 키(`aislo:{통}:{이름}:`)와 옛 키 머리. 초안 여부를 함께. */
|
||||
function projectPrefixes(): Array<{ prefix: string; draft: boolean }> {
|
||||
const out: Array<{ prefix: string; draft: boolean }> = [];
|
||||
Object.entries(STATE_REGISTRY as Record<string, StateEntry>).forEach(([name, entry]) => {
|
||||
if (entry.bucket === "pref" || entry.scope === "global") return;
|
||||
const draft = entry.bucket === "draft";
|
||||
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
|
||||
out.push({ prefix: `aislo:${entry.bucket}:${name}${suffix}:`, draft });
|
||||
const legacy = entry.legacy?.(MARK_P, MARK_R);
|
||||
const at = legacy?.indexOf(MARK_P) ?? -1;
|
||||
if (legacy && at > 0) out.push({ prefix: legacy.slice(0, at), draft });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const filled = (value: string | null): boolean =>
|
||||
value !== null && value !== "" && value !== "{}" && value !== "[]";
|
||||
|
||||
export interface ProjectPurgePlan {
|
||||
/** 저장 안 한 고침이 남은 다른 프로젝트. */
|
||||
draftProjects: string[];
|
||||
/** 지울 키 — `keep` 프로젝트의 초안은 뺀다. */
|
||||
doomed: (keep: ReadonlySet<string>) => string[];
|
||||
}
|
||||
|
||||
/** 세션 칸(키 → 값)에서 다른 프로젝트 몫을 가른다 — 저장소를 만지지 않는다. */
|
||||
export function planProjectPurge(
|
||||
session: Record<string, string>,
|
||||
projectId: string,
|
||||
): ProjectPurgePlan {
|
||||
const prefixes = projectPrefixes();
|
||||
const owners = new Map<string, { project: string; draft: boolean }>();
|
||||
Object.keys(session).forEach((key) => {
|
||||
const hit = prefixes.find((item) => key.startsWith(item.prefix));
|
||||
if (!hit) return;
|
||||
const project = key.slice(hit.prefix.length).split(":")[0];
|
||||
if (project) owners.set(key, { project, draft: hit.draft });
|
||||
});
|
||||
const others = new Set(
|
||||
[...owners.values()].map((owner) => owner.project).filter((id) => id !== projectId),
|
||||
);
|
||||
const draftProjects = [
|
||||
...new Set(
|
||||
[...owners.entries()]
|
||||
.filter(([key, owner]) => owner.draft && others.has(owner.project) && filled(session[key]))
|
||||
.map(([, owner]) => owner.project),
|
||||
),
|
||||
];
|
||||
return {
|
||||
draftProjects,
|
||||
// 등록표 밖 칸도 키 마디에 다른 프로젝트 번호가 있으면 그 프로젝트 몫으로 본다.
|
||||
doomed: (keep) =>
|
||||
Object.keys(session).filter((key) => {
|
||||
const owner = owners.get(key);
|
||||
if (owner) return others.has(owner.project) && !(owner.draft && keep.has(owner.project));
|
||||
return key.split(":").some((part) => others.has(part) && !keep.has(part));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function sessionEntries(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
try {
|
||||
for (let i = 0; i < window.sessionStorage.length; i += 1) {
|
||||
const key = window.sessionStorage.key(i);
|
||||
const value = key === null ? null : window.sessionStorage.getItem(key);
|
||||
if (key !== null && value !== null) out[key] = value;
|
||||
}
|
||||
} catch {
|
||||
/* 저장소가 막힌 브라우저 — 지울 것이 없다. */
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트에 들어감 — 들어온 표식이 다르면 그 밖 프로젝트 캐시를 지운다. 실패해도 이동은 막지 않는다.
|
||||
* `nameOf` 는 확인창에 적을 프로젝트 이름.
|
||||
*/
|
||||
export async function enterProject(
|
||||
projectId: string,
|
||||
nameOf: (projectId: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (window.sessionStorage.getItem(ENTERED_KEY) === projectId) return;
|
||||
const plan = planProjectPurge(sessionEntries(), projectId);
|
||||
const keep = new Set<string>();
|
||||
for (const other of plan.draftProjects) {
|
||||
const name = await nameOf(other).catch(() => other);
|
||||
const drop = await showConfirmDialog(
|
||||
`「${name}」에 저장 안 한 고침이 있음 — 지우고 열기 / 남기기`,
|
||||
"지우고 열기",
|
||||
"남기기",
|
||||
);
|
||||
if (!drop) keep.add(other);
|
||||
}
|
||||
plan.doomed(keep).forEach((key) => window.sessionStorage.removeItem(key));
|
||||
window.sessionStorage.setItem(ENTERED_KEY, projectId);
|
||||
await purgeOtherProjects(projectId);
|
||||
} catch {
|
||||
/* 저장소가 막힌 브라우저 — 캐시를 못 지워도 프로젝트는 연다. */
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,10 @@ export interface SvgZoomPanOptions {
|
||||
maxScale?: number;
|
||||
/** 이전 상태 — 다시 그려도 배율이 살아남게. */
|
||||
initial?: ZoomPanState;
|
||||
/** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */
|
||||
homeScale?: number;
|
||||
/** 처음 · 원복 상태를 통째로(295-2 B06 상세 — 중심점 가운데) · 주면 `homeScale` 대신 · 1 보다 작아도 됨. */
|
||||
home?: ZoomPanState;
|
||||
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
|
||||
onChange?: (state: ZoomPanState) => void;
|
||||
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
|
||||
@@ -85,9 +89,17 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
height: Math.max(heightPx - pad.top - pad.bottom, 1),
|
||||
};
|
||||
const maxScale = options.maxScale ?? 8;
|
||||
let scale = initial?.scale ?? 1;
|
||||
let tx = initial?.tx ?? 0;
|
||||
let ty = initial?.ty ?? 0;
|
||||
const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale);
|
||||
const home: ZoomPanState = options.home ?? {
|
||||
scale: homeScale,
|
||||
tx: (plot.x + plot.width / 2) * (1 - homeScale),
|
||||
ty: (plot.y + plot.height / 2) * (1 - homeScale),
|
||||
};
|
||||
/** 축소 한계 — 1배 · 처음 보기가 더 작으면 그만큼. */
|
||||
const minScale = Math.min(1, home.scale);
|
||||
let scale = initial?.scale ?? home.scale;
|
||||
let tx = initial?.tx ?? home.tx;
|
||||
let ty = initial?.ty ?? home.ty;
|
||||
const applyTransform = (): void => {
|
||||
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
|
||||
onChange?.({ scale, tx, ty });
|
||||
@@ -124,7 +136,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
|
||||
/** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */
|
||||
const zoomAt = (factor: number, cx: number, cy: number): void => {
|
||||
const next = Math.min(maxScale, Math.max(1, scale * factor));
|
||||
const next = Math.min(maxScale, Math.max(minScale, scale * factor));
|
||||
tx = cx - ((cx - tx) / scale) * next;
|
||||
ty = cy - ((cy - ty) / scale) * next;
|
||||
scale = next;
|
||||
@@ -148,6 +160,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
if (event.button === 1) event.preventDefault();
|
||||
});
|
||||
svg.addEventListener("pointerdown", (event) => {
|
||||
// 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1)
|
||||
moved = false;
|
||||
// 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다.
|
||||
if (!(options.panStart ? options.panStart(event) : event.button === 1)) return;
|
||||
event.preventDefault();
|
||||
@@ -180,7 +194,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
/* 이미 해제됨 */
|
||||
}
|
||||
};
|
||||
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
|
||||
if (scale !== 1 || tx !== 0 || ty !== 0) {
|
||||
clampPan();
|
||||
applyTransform();
|
||||
}
|
||||
@@ -207,9 +221,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
}
|
||||
const currentScale = (): number => scale;
|
||||
const reset = (): void => {
|
||||
scale = 1;
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
({ scale, tx, ty } = home);
|
||||
applyTransform();
|
||||
};
|
||||
// 더블클릭 원복.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
fetchSurfaceStatus,
|
||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
|
||||
import { enterProject } from "./b_project_switch";
|
||||
import { navigateTo } from "./router";
|
||||
|
||||
export interface WorkflowState {
|
||||
@@ -93,14 +94,20 @@ export async function dropGoneProject(): Promise<boolean> {
|
||||
const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE];
|
||||
|
||||
export function goToWorkflowStage(projectId: string, route: RoutePath): void {
|
||||
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
|
||||
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
|
||||
clearWorkflowStateCache();
|
||||
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
|
||||
navigateTo(route);
|
||||
return;
|
||||
}
|
||||
void routeAfterPreloadCheck(projectId, route);
|
||||
// 다른 프로젝트에 들어가면 그 밖 프로젝트 캐시를 먼저 지운다(290-1).
|
||||
void enterProject(
|
||||
projectId,
|
||||
async (id) => (await fetchWorkflowState(id)).project_name || id,
|
||||
).then(() => {
|
||||
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
|
||||
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
|
||||
clearWorkflowStateCache();
|
||||
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
|
||||
navigateTo(route);
|
||||
return;
|
||||
}
|
||||
void routeAfterPreloadCheck(projectId, route);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-route-profile__trailing > .b05-route-profile__popout {
|
||||
.b05-route-profile__tools > .b05-route-profile__popout {
|
||||
padding: var(--btn-bar-padding);
|
||||
font-size: var(--btn-bar-font-size);
|
||||
}
|
||||
|
||||
@@ -82,15 +82,16 @@ export function createSectionFrame(
|
||||
heading.textContent = title;
|
||||
if (view === "profile") {
|
||||
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
|
||||
// ui-panel-form: 좌측 패널 컨테이너 공용 양식(269 · 「구조물 배치」 기준 칸 · 라벨 · 단추) — 두 보기 모두.
|
||||
// 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫.
|
||||
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
||||
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`;
|
||||
heading.className = "ui-collapsible__title";
|
||||
const body = document.createElement("div");
|
||||
body.className = "b05-route__panel-body";
|
||||
root.append(heading, body);
|
||||
return { root, body };
|
||||
}
|
||||
root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
||||
root.className = `b06-profile__group ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`;
|
||||
heading.className = "b06-profile__group-legend ui-collapsible__title";
|
||||
root.append(heading);
|
||||
return { root, body: root };
|
||||
|
||||
@@ -46,7 +46,8 @@ const sameAt = (a: number, b: number): boolean => Math.abs(a - b) < 1e-6;
|
||||
|
||||
/**
|
||||
* 고름 값 → 종단도가 켤 것(B05 규칙). ① 구조물 id = 그 알약(측점선은 제 측점이 있을 때만 · 189-1)
|
||||
* ② 고른 측점 그대로(일반 카드 → 구조물 측점) ③ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1).
|
||||
* ② 그 자리 구조물 측점(같은 m 면 측점 하나 · 구조물이 위 · 272 — 일반 카드 id 는 종단도가 숨긴 측점)
|
||||
* ③ 고른 측점 그대로(일반 카드) ④ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1).
|
||||
*/
|
||||
export function profilePickOf(
|
||||
selection: DesignSelection | null,
|
||||
@@ -55,16 +56,16 @@ export function profilePickOf(
|
||||
if (!selection || selection.chainageM === null) return { stationId: null };
|
||||
if (selection.structureId) return { structureId: selection.structureId };
|
||||
const at = selection.chainageM;
|
||||
const irregular = source.irregular();
|
||||
const exact = irregular.find((entry) => sameAt(entry.chainage_m, at));
|
||||
if (exact) return { stationId: irregularStationId(exact.id) };
|
||||
const card = source.sections().find((entry) => sameAt(entry.chainage_m, at));
|
||||
if (card) return { stationId: card.station_id };
|
||||
const irregular = source.irregular();
|
||||
const own =
|
||||
irregular.find((entry) => sameAt(entry.chainage_m, at)) ??
|
||||
nearestWithin(
|
||||
irregular.filter((entry) => entry.origin === "pipe"),
|
||||
at,
|
||||
(entry) => entry.chainage_m,
|
||||
);
|
||||
const own = nearestWithin(
|
||||
irregular.filter((entry) => entry.origin === "pipe"),
|
||||
at,
|
||||
(entry) => entry.chainage_m,
|
||||
);
|
||||
if (own) return { stationId: irregularStationId(own.id) };
|
||||
const target = graphTargetAt(null, at, source.pipes(), source.sections());
|
||||
return "pipeChainageM" in target
|
||||
|
||||
@@ -42,7 +42,6 @@ import type {
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import {
|
||||
buildAlignment,
|
||||
chainageKey,
|
||||
emptyEdits,
|
||||
toAlignmentBase,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
@@ -70,18 +69,19 @@ import {
|
||||
createElevationHold,
|
||||
createProfileDrag,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
|
||||
import { planCurveWarning, sampleAt } from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
|
||||
import {
|
||||
planCurveWarning,
|
||||
sampleAt,
|
||||
snapToMasterStations,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
|
||||
irregularGraphStations,
|
||||
maxChainageOf,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
|
||||
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
|
||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||
import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||
import { frameOf } from "@util/common_util_view_window";
|
||||
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
|
||||
import { noticeDroppedEdits } from "./design_save";
|
||||
import { createProfileOverlays } from "./design_profile_overlays";
|
||||
import { bindProfileViewport } from "./design_profile_viewport";
|
||||
import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport";
|
||||
import "../../B06_Section/B06_Section_UI_Style.css";
|
||||
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
|
||||
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
|
||||
@@ -103,8 +103,8 @@ export interface DesignProfileContext {
|
||||
view: DesignView;
|
||||
/** 오른쪽 판 — 없으면 판 없이 종단 열만. */
|
||||
side?: DesignSideFactory;
|
||||
/** 오른쪽 판을 켜고 끄는 단추 — 도구줄 오른쪽에 붙는다(판이 있을 때만). */
|
||||
sideTool?: (board: DesignSideBoard) => HTMLElement;
|
||||
/** 도구줄 [초기화] 바로 왼쪽에 놓는 단추([새 창]) — 다시 그려도 그 자리. */
|
||||
toolLead?: HTMLElement;
|
||||
/** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */
|
||||
onSelectStation: (stationId: string | null) => void;
|
||||
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */
|
||||
@@ -219,6 +219,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
|
||||
let stationDisplay = { station: 0, cumulative: 0 };
|
||||
let base: AlignmentBase | null = null;
|
||||
/** 서버 저장분 그대로의 기준 — `base` 는 늘 여기서 측점 한 벌로 다시 셈(`listed`). */
|
||||
let storedBase: AlignmentBase | null = null;
|
||||
let alignment: ProfileAlignment | null = null;
|
||||
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
||||
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
|
||||
@@ -231,9 +233,16 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
/** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로
|
||||
* 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */
|
||||
const structureIdAtStation = (stationId: string | null): string | null =>
|
||||
structureIdKeeping(stationId, selectedStructureId, irregularStations, structures, detail);
|
||||
structureIdKeeping(
|
||||
stationId,
|
||||
selectedStructureId,
|
||||
irregularStations,
|
||||
structures,
|
||||
detail,
|
||||
structureTypes,
|
||||
);
|
||||
const stationIdAtStructure = (structureId: string | null): string | null =>
|
||||
stationIdAtStructureOf(structureId, irregularStations, structures);
|
||||
stationIdAtStructureOf(structureId, irregularStations, structures, structureTypes);
|
||||
|
||||
/**
|
||||
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
|
||||
@@ -258,7 +267,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
planCurve: planCurveWarning(alignment, detail),
|
||||
minCoverViolations,
|
||||
balanceBar,
|
||||
tools: tools.render(),
|
||||
tools: withToolLead(tools.render()),
|
||||
trailing,
|
||||
alignment,
|
||||
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
|
||||
@@ -280,11 +289,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
|
||||
/** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */
|
||||
const elevationHold = createElevationHold(body, () => draw());
|
||||
// 오른쪽 판 켬 · 끔 단추 — 가로 배율 조작구 옆, 두 보기 같은 자리.
|
||||
const sideTool = drainagePanel && ctx.sideTool ? ctx.sideTool(drainagePanel) : null;
|
||||
const trailing = document.createElement("div");
|
||||
trailing.className = "b05-route-profile__trailing";
|
||||
trailing.append(profileZoom.bar, ...(sideTool ? [sideTool] : []));
|
||||
trailing.append(profileZoom.bar);
|
||||
/** 도구 묶음은 그릴 때마다 새로 만든다 — 맨 앞([초기화] 왼쪽)에 `toolLead` 를 다시 옮겨 붙인다. */
|
||||
const withToolLead = (wrap: HTMLElement): HTMLElement => {
|
||||
if (ctx.toolLead) wrap.prepend(ctx.toolLead);
|
||||
return wrap;
|
||||
};
|
||||
|
||||
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */
|
||||
const { tools, history, handleToolPick, setListening } = createPanelTools({
|
||||
@@ -330,6 +342,33 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
history.record();
|
||||
}
|
||||
|
||||
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 측점 + 지금 구조물 측점 · 272) — 표 · ▲▼ 가 옛 자리
|
||||
* 구조물 측점(옮긴 관 · 서버가 다시 맞추기 전)을 안 봄 · 그래프 · 3D 와 같은 목록. 사라진 구조물
|
||||
* 측점의 꼭짓점은 [저장] 때 서버와 같게 옮기거나 지움(`followMovedNodes`). 늘 저장분에서 셈. */
|
||||
function listed(): AlignmentBase | null {
|
||||
if (!storedBase || !detail) return storedBase;
|
||||
const server = detail.longitudinal.stations;
|
||||
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
|
||||
const list = oneStationList(server, planted);
|
||||
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
|
||||
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
|
||||
const gone = server
|
||||
.filter((station) => station.kind === "irregular" && !station.grid)
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !after.has(chainage.toFixed(3)));
|
||||
const born = list
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => !before.has(chainage.toFixed(3)));
|
||||
return {
|
||||
...storedBase,
|
||||
basePvi: followMovedNodes(storedBase.basePvi, gone, born),
|
||||
stations: list.map((station) => ({
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
|
||||
* 실제 렌더링하도록 모은다.
|
||||
@@ -432,6 +471,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 다른 영역에서 고른 자리가 화면 밖이면 가운데로(칠한 뒤 · `design_profile_viewport`). */
|
||||
const reveal = createSelectionReveal(body);
|
||||
const revealPick = (): void =>
|
||||
reveal(
|
||||
(selectedStationId ?? selectedStructureId)
|
||||
? `${selectedStationId}|${selectedStructureId}`
|
||||
: null,
|
||||
);
|
||||
// 가로 스크롤 · 휠 · 크기 바뀜은 `design_profile_viewport` 몫(700줄 한계).
|
||||
const unbindViewport = bindProfileViewport({
|
||||
body,
|
||||
@@ -472,7 +519,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
savedEdits = stored?.edits ?? emptyEdits();
|
||||
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
|
||||
}
|
||||
base = stored ? toAlignmentBase(stored) : null;
|
||||
storedBase = stored ? toAlignmentBase(stored) : null;
|
||||
base = listed();
|
||||
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
|
||||
if (carried) store.replace(carried);
|
||||
alignment = base ? buildAlignment(base, store.edits()) : null;
|
||||
@@ -521,6 +569,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
// 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17).
|
||||
selectedStructureId = structureIdAtStation(stationId);
|
||||
draw();
|
||||
revealPick();
|
||||
},
|
||||
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
|
||||
setEnforceMinCover(enforce: boolean) {
|
||||
@@ -535,9 +584,11 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
|
||||
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
|
||||
setGradeLimit(maxGradePct: number) {
|
||||
if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
|
||||
if (!base || !storedBase || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
|
||||
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
|
||||
base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } };
|
||||
const policy = { ...base.policy, max_grade_pct: maxGradePct };
|
||||
storedBase = { ...storedBase, policy };
|
||||
base = { ...base, policy };
|
||||
alignment = buildAlignment(base, store.edits());
|
||||
draw();
|
||||
},
|
||||
@@ -547,7 +598,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
},
|
||||
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
|
||||
setIrregularStations(stations: IrregularStation[]) {
|
||||
irregularStations = snapToMasterStations(alignment, stations);
|
||||
irregularStations = stations;
|
||||
base = listed();
|
||||
if (base) alignment = buildAlignment(base, store.edits());
|
||||
draw();
|
||||
},
|
||||
/** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */
|
||||
@@ -572,6 +625,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId;
|
||||
selectedStationId = keep ? selectedStationId : own;
|
||||
draw();
|
||||
revealPick();
|
||||
},
|
||||
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
|
||||
setStationDisplay(next: { station: number; cumulative: number }) {
|
||||
@@ -583,10 +637,12 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
|
||||
*/
|
||||
resetStationEdit(chainageM: number, moveTo?: number) {
|
||||
const key = chainageKey(chainageM);
|
||||
// 옛 판 키(서버 측점 값 `264.055` · 관 값 264.06)도 같은 측점 — 0.006m 안 키(213-1 폭 · 272)
|
||||
const edits = store.edits();
|
||||
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return;
|
||||
store.resetStation(chainageM, moveTo);
|
||||
const key = [...Object.keys(edits.station_offsets), ...Object.keys(edits.curve_radii)].find(
|
||||
(entry) => Math.abs(Number(entry) - chainageM) < 0.006,
|
||||
);
|
||||
if (key !== undefined) store.resetStation(Number(key), moveTo);
|
||||
},
|
||||
isDirty: () => store.dirty(),
|
||||
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
|
||||
@@ -596,8 +652,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
noticeDroppedEdits(saved);
|
||||
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
||||
if (next?.base_pvi?.length) {
|
||||
base = toAlignmentBase(next);
|
||||
alignment = next;
|
||||
storedBase = toAlignmentBase(next);
|
||||
base = listed();
|
||||
alignment = buildAlignment(base!, store.edits());
|
||||
if (detail) detail.longitudinal.profile_alignment = next;
|
||||
}
|
||||
store.markSaved();
|
||||
@@ -606,6 +663,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
clear() {
|
||||
detail = null;
|
||||
base = null;
|
||||
storedBase = null;
|
||||
alignment = null;
|
||||
selectedStationId = null;
|
||||
balanceBar.replaceChildren();
|
||||
|
||||
@@ -28,8 +28,8 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) {
|
||||
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
|
||||
* 유토곡선을 접어도 이 값은 남아야 한다. */
|
||||
const massBadge = createMassHaulBadge();
|
||||
// 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
|
||||
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
|
||||
// 그래프 위 도구줄 · 측점 ▲ 줄(바닥 47px) 아래로 내려 겹치지 않게 한다(289).
|
||||
massBadge.root.style.setProperty("--mass-haul-badge-top", "52px");
|
||||
bodyWrap.append(massBadge.root);
|
||||
let massHaulSummary: MassHaulSummaryValues | null = null;
|
||||
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
|
||||
|
||||
@@ -13,27 +13,25 @@ import type { DesignProfile } from "./design_profile";
|
||||
/** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */
|
||||
const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"];
|
||||
|
||||
/** 종단 도구줄 [배수유역도] 단추 뒤에 [새 창] 단추를 붙인다 — 두 보기가 같은 한 벌을 쓴다. */
|
||||
/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */
|
||||
export function attachProfilePopout(
|
||||
profile: DesignProfile,
|
||||
slot: HTMLElement,
|
||||
sideTool: HTMLElement | null,
|
||||
button: HTMLButtonElement,
|
||||
): void {
|
||||
const { root } = profile;
|
||||
let popup: Window | null = null;
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ui-btn ui-btn--ghost b05-route-profile__popout";
|
||||
button.textContent = "새 창";
|
||||
button.title = "종단 하단 패널을 브라우저 새 창으로 띄움";
|
||||
button.textContent = "창 분할";
|
||||
button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합";
|
||||
button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open()));
|
||||
sideTool?.after(button);
|
||||
|
||||
const restore = (): void => {
|
||||
if (!root.classList.contains("is-popout")) return;
|
||||
root.classList.remove("is-popout");
|
||||
button.textContent = "새 창";
|
||||
button.textContent = "창 분할";
|
||||
slot.append(root);
|
||||
popup = null;
|
||||
requestAnimationFrame(profile.redraw);
|
||||
@@ -71,7 +69,7 @@ export function attachProfilePopout(
|
||||
doc.body.className = document.body.className;
|
||||
doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${getComputedStyle(document.body).backgroundColor}`;
|
||||
root.classList.add("is-popout");
|
||||
button.textContent = "제자리로";
|
||||
button.textContent = "창 결합";
|
||||
doc.body.replaceChildren(root);
|
||||
|
||||
// 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정).
|
||||
|
||||
@@ -68,3 +68,33 @@ export function bindProfileViewport(deps: ProfileViewportDeps): () => void {
|
||||
resizeObserver.observe(body);
|
||||
return () => resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
/** 고른 것이 보이는 칸 가장자리에서 이만큼(폭 비율) 안쪽이면 그대로 둔다. */
|
||||
const REVEAL_EDGE = 0.1;
|
||||
|
||||
/**
|
||||
* 고른 측점선(없으면 고른 구조물 알약)이 종단도 보이는 칸 밖이면 가운데로 민다 — 다른 영역(횡단 카드 ·
|
||||
* 3D · 배수유역도 · 좌측 목록)에서 고른 자리도 종단에서 바로 보이게. 칠한 결과(강조된 선 · 알약)를
|
||||
* 보고 옮긴다 · 같은 고름을 다시 칠할 때는 안 밂(사용자가 옮겨 본 자리를 지킴) · 그래프 안에서
|
||||
* 누른 것은 이미 보이는 자리라 안 움직임. `key` = 고름 열쇠(풀면 null).
|
||||
*/
|
||||
export function createSelectionReveal(body: HTMLElement): (key: string | null) => void {
|
||||
let shown: string | null = null;
|
||||
return (key) => {
|
||||
if (key === null || key === shown) {
|
||||
shown = key;
|
||||
return;
|
||||
}
|
||||
const mark =
|
||||
body.querySelector(".b06-chart__station--selected") ??
|
||||
body.querySelector(".b05-structure__mark.is-selected");
|
||||
if (!mark || body.clientWidth <= 0) return; // 아직 안 그려짐 — 다음 칠하기에서
|
||||
shown = key;
|
||||
const box = mark.getBoundingClientRect();
|
||||
const view = body.getBoundingClientRect();
|
||||
const at = box.left + box.width / 2 - view.left;
|
||||
if (at >= view.width * REVEAL_EDGE && at <= view.width * (1 - REVEAL_EDGE)) return;
|
||||
// 바로 옮김 — 고른 뒤 잇따르는 다시 그리기가 부드러운 이동을 끊음(다시 그리기는 지금 자리를 지킴)
|
||||
body.scrollLeft += at - view.width / 2;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { createStructuresSection, type StructuresSection } from "../b_structures_section";
|
||||
import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||
import { drainageSide, drainageSideTool } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
|
||||
import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
|
||||
import { createDesignPanel, type DesignPanel } from "./design_panel";
|
||||
import { createDesignProfile, type DesignProfile } from "./design_profile";
|
||||
import { attachProfilePopout } from "./design_profile_popout";
|
||||
@@ -150,12 +150,12 @@ function toAll<K extends keyof RouteProfilePanelCallbacks>(
|
||||
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
|
||||
export function sharedProfile(shared: DesignPageShared): DesignProfile {
|
||||
if (shared.profile) return shared.profile;
|
||||
let sideTool: HTMLElement | null = null;
|
||||
const popoutButton = document.createElement("button");
|
||||
const profile = createDesignProfile({
|
||||
projectId: shared.projectId,
|
||||
view: shared.view(),
|
||||
side: drainageSide,
|
||||
sideTool: (board) => (sideTool = drainageSideTool(board)),
|
||||
toolLead: popoutButton,
|
||||
onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId),
|
||||
onResetAll: () => bound(shared).profile?.onResetAll?.(),
|
||||
callbacks: {
|
||||
@@ -178,7 +178,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile {
|
||||
},
|
||||
});
|
||||
shared.profileSlot.append(profile.root);
|
||||
attachProfilePopout(profile, shared.profileSlot, sideTool);
|
||||
attachProfilePopout(profile, shared.profileSlot, popoutButton);
|
||||
shared.profile = profile;
|
||||
return profile;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
* (같은 풀이 · n · S 를 받음) · 거울 시험 test_73_11_b08_hydro_box_ford.
|
||||
* ========================================================================== */
|
||||
|
||||
import { FORD_PAVEMENT_DEFAULT_WIDTH_M } from "@config/config_frontend";
|
||||
import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
|
||||
import type { SpecForm } from "./design_structure_form";
|
||||
import { CHUTE_ITEMS } from "./design_structure_guard";
|
||||
import type { SpecContainer, SpecItem } from "./design_structure_spec";
|
||||
|
||||
/** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */
|
||||
export function fordHeightM(designFlowM3s: number | null, widthM: number): number | null {
|
||||
@@ -23,9 +27,93 @@ export function fordHeightM(designFlowM3s: number | null, widthM: number): numbe
|
||||
return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월류 폭 ↔ 높이 연동 — 세월교 · 물넘이 포장 한 벌(52-4 · 65-4 · 65-5 · 287-3).
|
||||
* · 폭을 바꾸면 높이 = 필요 수심 · 높이를 비우거나 그 아래로 넣으면 필요 수심으로.
|
||||
* · 새 시설(`arm(true)`) 빈 높이 = 유량이 오면 필요 수심(사용자가 넣은 것처럼 — [추가] · [수정]이 읽음).
|
||||
* · 상자 끝 안내 — 「필요 수심 n 이상」 · 「설계유량 없음 — 수기 입력」(유량이 없으면 손 입력).
|
||||
* `box` = 폭 · 높이 칸이 든 상자 id(세월교 `ford` · 물넘이 `slope`) · `flow` = 담당 유역 설계유량.
|
||||
*/
|
||||
export function overflowSync(
|
||||
form: () => Pick<SpecForm, "state" | "setValue" | "refresh">,
|
||||
box: string,
|
||||
flow: () => number | null,
|
||||
emit: () => void,
|
||||
) {
|
||||
let pending = false;
|
||||
const values = () => form().state()[box]?.[0] ?? {};
|
||||
const width = () => Number(values().width ?? "");
|
||||
const need = (): number | null => fordHeightM(flow(), width());
|
||||
return {
|
||||
arm: (fresh: boolean): void => {
|
||||
pending = fresh;
|
||||
},
|
||||
onChange: (c: SpecContainer, item: SpecItem): void => {
|
||||
if (c.id === box && item.id === "height") pending = false;
|
||||
const want = c.id === box ? need() : null;
|
||||
const height = values().height ?? "";
|
||||
const low = height === "" || !(Number(height) >= (want ?? 0));
|
||||
if (want !== null && (item.id === "width" || (item.id === "height" && low)))
|
||||
form().setValue(box, "height", want.toFixed(2));
|
||||
emit();
|
||||
},
|
||||
/** 유량이 왔거나 시설을 올림 — 새 시설 빈 높이를 채우고 안내를 다시 그림. */
|
||||
fill: (): void => {
|
||||
const want = pending && !values().height ? need() : null;
|
||||
if (want === null) return form().refresh();
|
||||
form().setValue(box, "height", want.toFixed(2));
|
||||
pending = false;
|
||||
},
|
||||
/** 상자 끝 안내 글(폼 `fields` 훅). */
|
||||
fields: (c: SpecContainer): HTMLElement[] => {
|
||||
if (c.id !== box) return [];
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "b05-structure__flow-hint";
|
||||
hint.textContent = fordHeightHint(flow(), width());
|
||||
return [hint];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 높이 빈 칸 안내. */
|
||||
export function fordHeightHint(designFlowM3s: number | null, widthM: number): string {
|
||||
const need = fordHeightM(designFlowM3s, widthM);
|
||||
if (need !== null) return `필요 수심 ${need.toFixed(2)} 이상`;
|
||||
return designFlowM3s ? "월류 폭을 넣으면 셈" : "설계유량 없음 — 수기 입력";
|
||||
}
|
||||
|
||||
/** 도수로 · 돌붙임 면적(㎡ · 287-1) = 폭 × 길이 · 0.01 반올림. */
|
||||
export function chuteAreaM2(widthM: number, lengthM: number): number {
|
||||
return Math.round(widthM * lengthM * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 물넘이 포장 · 세월교 도수로/돌붙임 길이 ↔ 면적(287-1 · 294-4) — 길이를 넣으면 면적 = 폭 × 길이(폭 = 띠가
|
||||
* 도로 방향으로 걸치는 월류 폭 · 비면 물넘이 기본 폭 · 배관 · 박스는 폭 정의 없어 자동 안 함) · 길이를 비우면 면적 = 칸 기본값 · 월류 폭을 바꾸면 길이가 있을 때만
|
||||
* 다시 셈. 길이는 그림(사면 따라 그 길이) · 면적은 수량(B08 돌붙임)이 씀.
|
||||
*/
|
||||
export function syncChuteArea(
|
||||
form: Pick<SpecForm, "state" | "setValue">,
|
||||
c: SpecContainer,
|
||||
item: SpecItem,
|
||||
/** 도수로 상자 · 월류 폭 상자 — 물넘이 `chute` · `slope` · 세월교 `chute_add` · `ford`(294-4). */
|
||||
chuteBox = "chute",
|
||||
widthBox = "slope",
|
||||
): void {
|
||||
const byLength = c.id === chuteBox && item.id === "length_m";
|
||||
if (!byLength && !(c.id === widthBox && item.id === "width")) return;
|
||||
const chute = form.state()[chuteBox]?.[0];
|
||||
if (!chute) return;
|
||||
const length = Number(chute.length_m ?? "");
|
||||
if (!(chute.length_m ?? "").trim() || !(length > 0)) {
|
||||
if (byLength)
|
||||
form.setValue(chuteBox, "area", CHUTE_ITEMS.find((i) => i.id === "area")?.default ?? "");
|
||||
return;
|
||||
}
|
||||
const width = Number(form.state()[widthBox]?.[0]?.width ?? "");
|
||||
form.setValue(
|
||||
chuteBox,
|
||||
"area",
|
||||
String(chuteAreaM2(width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M, length)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
* design_structure_ford_pavement.ts
|
||||
* 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로.
|
||||
*
|
||||
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 구조 = 도수로 · 돌붙임).
|
||||
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_structure` ·
|
||||
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 다른 구조물과
|
||||
* 같은 칸 `CHUTE_ITEMS` + 맨 아래 길이 · 286-3).
|
||||
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_*` ·
|
||||
* 월류 폭 · 높이 = 세월교와 같은 키(`ford_width_m` · `ford_height_m`) · 같은 계산(`design_structure_ford_calc`).
|
||||
* 경사각도는 옛 「바닥 경사(%)」(`ford_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음.
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* 길이(`ford_chute_{n}_length_m`) = 횡단 · 3D 띠를 사면 따라 그릴 길이 — 그림 전용(수량 · 내역 · 일위대가에 안 씀) ·
|
||||
* 비면 원지반까지.
|
||||
* ========================================================================== */
|
||||
|
||||
import { CHUTE_ITEMS } from "./design_structure_guard";
|
||||
import type { StructureSpec } from "./design_structure_spec";
|
||||
|
||||
export const FORD_PAVEMENT_SPEC: StructureSpec = {
|
||||
@@ -32,20 +35,8 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
|
||||
prefix: "ford_chute",
|
||||
link: "gfordpavechute",
|
||||
max: 1,
|
||||
// 구조(기본 도수로) · 두께(cm · 기본 45 · 173-5) — 배관 · 박스 · 세월교 `CHUTE_ITEMS` 와 같은 칸(90-2)
|
||||
items: [
|
||||
{
|
||||
id: "structure",
|
||||
label: "구조",
|
||||
kind: "select",
|
||||
choices: [
|
||||
{ value: "도수로", label: "도수로" },
|
||||
{ value: "돌붙임", label: "돌붙임" },
|
||||
],
|
||||
default: "도수로",
|
||||
},
|
||||
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
|
||||
],
|
||||
// 배관 · 박스 · 세월교와 같은 칸(구조 · 형태 · 두께 · 면적 · 맨 아래 길이 — 그림 전용 · 286-3 · 294-4)
|
||||
items: CHUTE_ITEMS,
|
||||
},
|
||||
],
|
||||
// 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
|
||||
|
||||
@@ -144,6 +144,8 @@ export const CHUTE_ITEMS: SpecItem[] = [
|
||||
},
|
||||
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
|
||||
{ id: "area", label: "면적 (㎡)", kind: "number", step: 0.1, default: "30" },
|
||||
// 길이(294-4 · `{접두}_{n}_length_m`) — 띠를 사면 따라 그릴 길이 · 그림 전용(수량 · 내역 · 일위대가에 안 씀)
|
||||
{ id: "length_m", label: "길이 (m)", kind: "number", step: 0.1 },
|
||||
];
|
||||
|
||||
/** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Persist";
|
||||
import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
|
||||
import { paintMapPick } from "./design_pick_profile";
|
||||
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
|
||||
import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync";
|
||||
@@ -342,15 +343,15 @@ export async function mountCrossView(
|
||||
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
|
||||
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
|
||||
addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId),
|
||||
removeStructure: (structureId) => {
|
||||
structuresPanel.removeStructureById(structureId);
|
||||
},
|
||||
removeStructure: (structureId) => void structuresPanel.removeStructureById(structureId),
|
||||
movePipe: (fromChainageM, toChainageM) =>
|
||||
structuresPanel.movePipeTo(fromChainageM, toChainageM),
|
||||
moveStructure: (structureId, toChainageM) => {
|
||||
structuresPanel.moveStructureTo(structureId, toChainageM);
|
||||
},
|
||||
selectStructure: (structureId) => structuresPanel.selectStructure(structureId),
|
||||
selectPipe: (chainageM) => structuresPanel.selectPipeAt(chainageM), // 측점 없는 관(요구 178)
|
||||
pipesChanged: () => (structuresPanel.adoptFordHeights(), void refreshDetailForStructures()),
|
||||
});
|
||||
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
|
||||
const pipeOptionsContext: PipeOptionsContext = {
|
||||
@@ -391,9 +392,8 @@ export async function mountCrossView(
|
||||
releaseCross(); // 카드 다시 누름 = 풀기 한 길(범위 테두리 · 형상까지 · 233-2)
|
||||
return;
|
||||
}
|
||||
const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null;
|
||||
const preferred = structuresPanel.preferred() !== null;
|
||||
const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
|
||||
const opened = structuresPanel.showAtChainage(structureSelection.formChainageOf(target));
|
||||
// 카드 하나 고름 = 그 측점(구조물 id 는 목록 · 알약 · 넘김이 고른 것만 · 189-1)
|
||||
const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null };
|
||||
if (!isWallSelecting()) selectDesign(projectId, picked, { from: "b06" });
|
||||
@@ -505,6 +505,7 @@ export async function mountCrossView(
|
||||
appliedPick = null;
|
||||
restoreStructurePick();
|
||||
},
|
||||
paintMap: (selection) => shared.profile?.side && paintMapPick(shared.profile.side, selection),
|
||||
});
|
||||
/** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */
|
||||
function releaseCross(): void {
|
||||
|
||||
@@ -122,6 +122,7 @@ export async function mountProfileView(
|
||||
bridge.setPipes(pipes);
|
||||
panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem));
|
||||
profilePanel.setMinCoverPipes(pipes);
|
||||
snapOld();
|
||||
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
|
||||
};
|
||||
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) ·
|
||||
@@ -174,7 +175,9 @@ export async function mountProfileView(
|
||||
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
|
||||
onStructureSelect: (structureId) => {
|
||||
const chainage = bridge.pipeMarkChainage(structureId);
|
||||
rememberPick(bridge.markChainage(structureId), chainage === null ? structureId : null);
|
||||
// 측점 없는 관(물넘이포장)은 알약 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);
|
||||
@@ -245,6 +248,14 @@ export async function mountProfileView(
|
||||
selectStationOfPipe(chainageM);
|
||||
};
|
||||
|
||||
let oldSnapped = false;
|
||||
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 — 관 · 구조물 · 계획고 편집이 다 실린 뒤 한 번(272-4). */
|
||||
function snapOld(): void {
|
||||
if (oldSnapped || restoring || !lastPipes) return;
|
||||
oldSnapped = true;
|
||||
panel.structures.snapOld();
|
||||
}
|
||||
|
||||
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
|
||||
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
|
||||
function restorePick(): void {
|
||||
@@ -653,6 +664,7 @@ export async function mountProfileView(
|
||||
} finally {
|
||||
progress.remove();
|
||||
restoring = false;
|
||||
snapOld();
|
||||
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
|
||||
}
|
||||
|
||||
|
||||
@@ -58,11 +58,17 @@ export function buildKnowledgeChat(): HTMLElement {
|
||||
input.rows = 2;
|
||||
input.required = true;
|
||||
input.maxLength = 500;
|
||||
input.placeholder = "메시지를 입력하세요";
|
||||
input.placeholder = "메시지를 입력하세요 (Shift+Enter 줄바꿈)";
|
||||
input.setAttribute("aria-label", "질문");
|
||||
const submit = document.createElement("button");
|
||||
submit.type = "submit";
|
||||
submit.textContent = "보내기";
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
submit.click();
|
||||
}
|
||||
});
|
||||
form.append(input, submit);
|
||||
panel.append(header, messages, form);
|
||||
wrap.append(toggle, panel);
|
||||
|
||||
@@ -536,8 +536,8 @@
|
||||
.b01-knowledge-chat__toggle,
|
||||
.b01-knowledge-chat__form button {
|
||||
border: 0;
|
||||
background: var(--color-royal-amethyst, #6843a7);
|
||||
color: #fff;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -565,7 +565,7 @@
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface, #fff);
|
||||
box-shadow: 0 8px 28px #0003;
|
||||
color: var(--color-text-primary, #222);
|
||||
color: var(--color-text-body);
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
@@ -588,8 +588,8 @@
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-royal-amethyst, #6843a7);
|
||||
color: #fff;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__avatar svg {
|
||||
@@ -631,14 +631,15 @@
|
||||
.b01-knowledge-chat__user {
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 4px;
|
||||
background: var(--color-royal-amethyst, #6843a7);
|
||||
color: #fff;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply {
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--color-border);
|
||||
border-bottom-left-radius: 4px;
|
||||
background: var(--color-background, #f4f4f4);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply details {
|
||||
@@ -722,7 +723,7 @@
|
||||
.b01-knowledge-source-modal__content code {
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
background: var(--color-background, #f4f4f4);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content .ui-markdown-table {
|
||||
@@ -746,7 +747,7 @@
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content th {
|
||||
background: var(--color-background, #f4f4f4);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form {
|
||||
@@ -765,11 +766,17 @@
|
||||
padding: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form textarea::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form button {
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -29,7 +29,6 @@ from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
structure_type_map,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PIPE_FACILITY_FORD_PAVEMENT,
|
||||
PIPE_FACILITY_PIPE,
|
||||
PIPE_FACILITY_REVET,
|
||||
PipePoint,
|
||||
@@ -208,9 +207,7 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P
|
||||
|
||||
|
||||
def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None:
|
||||
"""관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖 = 종류 이름 · 물넘이포장 = 없음."""
|
||||
if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT:
|
||||
return None
|
||||
"""관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖(물넘이포장 포함 · 286-1) = 종류 이름."""
|
||||
if pipe.facility == PIPE_FACILITY_PIPE:
|
||||
values = pipe.options or {}
|
||||
kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE)
|
||||
@@ -267,11 +264,12 @@ def resolve_extra_stations(
|
||||
(2026-08-28 사용자 보고). 정본 파일은 초기화에도 살아남으므로 여기서 매번 다시 만든다
|
||||
— B05·B06은 같은 응답 하나를 보는 한 페이지라 공급자도 하나여야 한다.
|
||||
|
||||
물넘이포장은 비정규 측점으로 올리지 않는다 — 종단 그래프 하단 구조물 표시로만 다룬다
|
||||
(2026-08-28 사용자 확정).
|
||||
물넘이포장도 기준 측점 하나를 심는다 — 그 측점 횡단도에 파인 노면을 그린다(286-1).
|
||||
|
||||
라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를
|
||||
되짚기 때문이다(B05_Profile_Structures_Migration).
|
||||
TS 짝 = `common_util_structure_station.ts` `plantedChainages`(화면 측점 한 벌 · 272) ·
|
||||
거울 시험 = `resources/tester/test_272_1_station_list.py`.
|
||||
"""
|
||||
types = structure_type_map()
|
||||
derived: list[tuple[float, str]] = []
|
||||
@@ -549,7 +547,7 @@ def run_section_generation(
|
||||
}
|
||||
|
||||
|
||||
def generate_irregular_sections(
|
||||
def sample_structure_sections(
|
||||
project_root: Path,
|
||||
route_data_path: str,
|
||||
filter_key: str,
|
||||
@@ -559,16 +557,15 @@ def generate_irregular_sections(
|
||||
extra_stations: tuple[tuple[float, str], ...],
|
||||
options: SectionGenerationOptions | None = None,
|
||||
crs: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""비정규 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다.
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""구조물 측점만 샘플링해 (측점 dict, 횡단 dict) 목록을 돌려준다 — 파일은 안 씀.
|
||||
|
||||
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다. 격자 측점과 똑같은 접선·오프셋
|
||||
샘플링을 거치되(`generate_sections` 재사용) kind="irregular"인 것만 골라내 각각
|
||||
`cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
|
||||
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
|
||||
격자 측점과 똑같은 접선·오프셋 샘플링을 거치되(`generate_sections` 재사용) 구조물 측점
|
||||
(`irregular` · 격자 · 시종점에 얹힌 것 `grid` 포함)만 골라낸다. [저장] 때 정본 쓰기
|
||||
(`generate_irregular_sections`)와 B06 미리 뜨기(`preview_structure_sections`)가 같은 길(273-2).
|
||||
"""
|
||||
if not extra_stations:
|
||||
return []
|
||||
return [], []
|
||||
base = options or SectionGenerationOptions()
|
||||
merged_options = SectionGenerationOptions(
|
||||
station_interval_m=base.station_interval_m,
|
||||
@@ -589,18 +586,48 @@ def generate_irregular_sections(
|
||||
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
|
||||
design_curves=_load_design_curves(project_root),
|
||||
)
|
||||
irregular_stations = [
|
||||
station
|
||||
for station in result["longitudinal"]["stations"]
|
||||
if station.get("kind") == "irregular"
|
||||
]
|
||||
|
||||
def structural(entry: dict[str, Any]) -> bool:
|
||||
return entry.get("kind") == "irregular" or bool(entry.get("structure"))
|
||||
|
||||
return (
|
||||
[station for station in result["longitudinal"]["stations"] if structural(station)],
|
||||
[cross for cross in result["cross_sections"] if structural(cross)],
|
||||
)
|
||||
|
||||
|
||||
def generate_irregular_sections(
|
||||
project_root: Path,
|
||||
route_data_path: str,
|
||||
filter_key: str,
|
||||
method: str,
|
||||
smooth: bool,
|
||||
*,
|
||||
extra_stations: tuple[tuple[float, str], ...],
|
||||
options: SectionGenerationOptions | None = None,
|
||||
crs: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""구조물 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다(빈 목록 = 없음).
|
||||
|
||||
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다 — 받는 쪽이 목록 전체로 다시 맞춤(272).
|
||||
각각 `cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
|
||||
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
|
||||
"""
|
||||
irregular_stations, crosses = sample_structure_sections(
|
||||
project_root,
|
||||
route_data_path,
|
||||
filter_key,
|
||||
method,
|
||||
smooth,
|
||||
extra_stations=extra_stations,
|
||||
options=options,
|
||||
crs=crs,
|
||||
)
|
||||
if not irregular_stations:
|
||||
return []
|
||||
cross_dir = project_root / _STAGE_SUBDIR / "cross_sections"
|
||||
cross_dir.mkdir(parents=True, exist_ok=True)
|
||||
for cross_section in result["cross_sections"]:
|
||||
if cross_section.get("kind") != "irregular":
|
||||
continue
|
||||
for cross_section in crosses:
|
||||
cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"]))
|
||||
atomic_write_json(cross_file, cross_section)
|
||||
return irregular_stations
|
||||
|
||||
@@ -415,8 +415,10 @@ def generate_sections(
|
||||
kind = "bp"
|
||||
elif abs(float(value) - total) <= 1e-6:
|
||||
kind = "ep"
|
||||
elif rounded in extras and rounded not in regular_rounded:
|
||||
# 격자와 겹치지 않는 비정규 측점만 irregular. 규칙 격자와 겹치면 규칙 측점 우선.
|
||||
elif rounded in extras:
|
||||
# 구조물 측점이 위(272 · 요구 167) — 격자와 같은 m 여도 구조물 측점(irregular) 하나 ·
|
||||
# 격자 자리는 `grid` 로 남겨 구조물이 떠나면 일반 측점으로 되돌림
|
||||
# (`B05_Profile_Router_Confirm._merge_irregular_into_longitudinal`).
|
||||
kind = "irregular"
|
||||
else:
|
||||
kind = "regular"
|
||||
@@ -466,9 +468,10 @@ def generate_sections(
|
||||
"uphill_side": uphill_side,
|
||||
"frame": frame,
|
||||
}
|
||||
# 구조물은 kind와 무관하게 부착한다(규칙 격자와 겹쳐 regular가 돼도 구조물 정보는 유지).
|
||||
if rounded in extras:
|
||||
station["structure"] = extras[rounded]
|
||||
if rounded in regular_rounded:
|
||||
station["grid"] = True # 숨은 일반 측점 자리(272)
|
||||
stations.append(station)
|
||||
|
||||
samples = []
|
||||
|
||||
@@ -241,6 +241,45 @@ async def update_longitudinal_grade_summary(
|
||||
)
|
||||
|
||||
|
||||
async def sync_cross_section_rows(
|
||||
connection: aiomysql.Connection, route_id: int, chainages: list[float]
|
||||
) -> int:
|
||||
"""종단 정본 측점에 횡단 행을 맞춘다 — 정본에 없는 측점의 행(옮긴 · 지운 구조물 측점의 옛 자리).
|
||||
|
||||
0.5 m 안에 행 없는 새 측점이 있으면 그리로 옮긴다(옛 소수 → 정수 m · 사용자 설계 값 그대로 ·
|
||||
면적은 저장 뒤 서버 재계산) · 나머지는 지운다 — 남기면 수량(B08 토적)이 옛 자리 측점을 그대로
|
||||
셈(272). 같은 측점 = 0.01 m 안(`_rowless_station_chainages` 와 같은 폭). 측점 목록이 비면
|
||||
아무것도 안 건드린다. 옮기거나 지운 행 수.
|
||||
"""
|
||||
if not chainages:
|
||||
return 0
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT id, chainage_m FROM cross_sections WHERE route_id = %s", (route_id,)
|
||||
)
|
||||
rows = [(int(row[0]), float(row[1])) for row in await cursor.fetchall()]
|
||||
orphans = [
|
||||
(row_id, at) for row_id, at in rows if all(abs(at - c) >= 0.01 for c in chainages)
|
||||
]
|
||||
free = [c for c in chainages if all(abs(c - at) >= 0.01 for _, at in rows)]
|
||||
pairs = sorted(
|
||||
(abs(c - at), row_id, c) for row_id, at in orphans for c in free if abs(c - at) <= 0.5
|
||||
)
|
||||
moved: dict[int, float] = {}
|
||||
for _gap, row_id, chainage in pairs:
|
||||
if row_id not in moved and chainage not in moved.values():
|
||||
moved[row_id] = chainage
|
||||
for row_id, chainage in moved.items():
|
||||
await cursor.execute(
|
||||
"UPDATE cross_sections SET chainage_m = %s WHERE id = %s", (chainage, row_id)
|
||||
)
|
||||
dropped = [row_id for row_id, _ in orphans if row_id not in moved]
|
||||
if dropped:
|
||||
marks = ", ".join(["%s"] * len(dropped))
|
||||
await cursor.execute(f"DELETE FROM cross_sections WHERE id IN ({marks})", dropped)
|
||||
return len(orphans)
|
||||
|
||||
|
||||
async def get_surface_crs_epsg(
|
||||
connection: aiomysql.Connection, project_id: UUID, surface_model_id: int
|
||||
) -> int | None:
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -18,11 +19,16 @@ from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profi
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
_load_pipe_points,
|
||||
_load_route_polyline,
|
||||
find_cross_file,
|
||||
generate_irregular_sections,
|
||||
resolve_extra_stations,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg
|
||||
from B05_Profile.B05_Profile_Repository import (
|
||||
get_surface_crs_epsg,
|
||||
sync_cross_section_rows,
|
||||
update_longitudinal_grade_summary,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_latest_section_options,
|
||||
@@ -101,6 +107,7 @@ async def sync_uphill_overrides_into_designs(
|
||||
compute_cross_design,
|
||||
curve_widening_args,
|
||||
stored_shoulder_extend,
|
||||
stored_tilt,
|
||||
)
|
||||
from B06_Section.B06_Section_Repository import get_cross_section_designs
|
||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||
@@ -169,6 +176,7 @@ async def sync_uphill_overrides_into_designs(
|
||||
surface_drop_m=ford_drop_at(float(chainage), ford_drops),
|
||||
**curve_widening_args(cross_record),
|
||||
**stored_shoulder_extend(design), # 노견 확장(139-6)
|
||||
**stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4)
|
||||
)
|
||||
next_design["status"] = design.get("status", "provisional")
|
||||
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
|
||||
@@ -193,12 +201,16 @@ async def sync_uphill_overrides_into_designs(
|
||||
_SAME_STATION_M = 0.006
|
||||
|
||||
|
||||
def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[str, Any] | None:
|
||||
def _follow_moved_stations(
|
||||
data: dict[str, Any], before: list[float], previous: list[float] | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
"""사라진 비정규 측점의 계획선 꼭짓점(base_pvi) · 편집을 새 측점으로 옮기거나 지운다.
|
||||
|
||||
남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에
|
||||
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안)만 옮기고 나머지는 지운다.
|
||||
바뀌었으면 새 등급 요약(DB 몫)을, 아니면 `None`.
|
||||
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안) · 옛 소수 측점의 정수 m 맞춤
|
||||
자리(272)만 옮기고 나머지는 지운다.
|
||||
측점 목록이 계획선 측점 줄과 달라져도 다시 센다(272). 다시 셌으면 새 등급 요약(DB 몫)을,
|
||||
아니면 `None`.
|
||||
"""
|
||||
stored = data.get("profile_alignment")
|
||||
if not isinstance(stored, dict) or not stored.get("base_pvi"):
|
||||
@@ -218,6 +230,11 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
|
||||
),
|
||||
None,
|
||||
)
|
||||
# 옛 소수 측점 → 정수 m 맞춤(272 · 요구 167) — 맞춘 자리가 새 측점이면 꼭짓점 · 편집도
|
||||
# 그리로(지우면 계획선이 그 자리에서 바뀜 · 화면 `followMovedNodes` 와 짝)
|
||||
snapped = chainage_key(math.floor(old + 0.5))
|
||||
if near is None and snapped in after_keys and snapped not in before_keys:
|
||||
near = float(snapped)
|
||||
moves[chainage_key(old)] = None if near is None else chainage_key(near)
|
||||
|
||||
def moved(value: float) -> tuple[bool, str | None]:
|
||||
@@ -254,7 +271,15 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
|
||||
changed = True
|
||||
if new_key is not None:
|
||||
base.append({**node, "chainage_m": float(new_key)})
|
||||
if not changed:
|
||||
# 측점 목록만 바뀌어도 다시 셈 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않게(272) ·
|
||||
# 줄이 없는 판은 맞추기 전 측점(`previous`)과 견줌
|
||||
rows = stored.get("stations")
|
||||
listed = (
|
||||
[round(float(row.get("chainage_m", 0.0)), 6) for row in rows]
|
||||
if isinstance(rows, list)
|
||||
else previous
|
||||
)
|
||||
if not changed and listed in (None, [round(value, 6) for value in after]):
|
||||
return None
|
||||
stored["base_pvi"] = base
|
||||
alignment, profile = rebuild_alignment_profile(data, next_edits)
|
||||
@@ -270,12 +295,17 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
|
||||
def _merge_irregular_into_longitudinal(
|
||||
project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
|
||||
) -> dict[str, Any] | None:
|
||||
"""종단 정본 파일의 stations에 비정규 측점을 병합한다(기존 비정규는 교체·정렬).
|
||||
"""종단 정본 stations 의 구조물 측점을 **지금 목록 전체로 다시 맞춘다**(272 · 요구 167).
|
||||
|
||||
B06 상세는 종단 파일의 stations를 읽고 그에 맞는 cross 파일만 노출하므로, 이 병합과
|
||||
(엔진이 이미 쓴) cross 파일만으로 다음 페이지에 횡단이 나타난다. 규칙 측점·표고 샘플은
|
||||
손대지 않는다. 계획선은 사라진 비정규 측점 자리만 고친다(`_follow_moved_stations` —
|
||||
바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 재확정해도 중복이 생기지 않는다.
|
||||
`irregular_stations` = 지금 관 · 구조물로 새로 뜬 구조물 측점 전부(격자와 같은 m 인 것은
|
||||
`grid` 표시 · `generate_irregular_sections`). 옛 구조물 측점은 다 빼고(빈 목록이어도),
|
||||
구조물이 떠난 격자 자리(`grid`)는 일반 측점으로 되돌리고, 새 목록의 격자 자리 측점은 그
|
||||
격자 측점을 갈음한다(같은 m 측점 하나 · 구조물이 위). 표고 샘플은 손대지 않는다.
|
||||
B06 상세는 종단 파일의 stations 에 맞는 cross 파일만 노출하고 남은 파일을 지운다
|
||||
(`prune_stale_cross_files`). 계획선은 사라진 구조물 측점 자리만 고친다(`_follow_moved_stations`
|
||||
— 바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 다시 맞춰도 중복이 생기지 않는다.
|
||||
TS 짝 = `common_util_structure_station.ts` `oneStationList`(화면 측점 한 벌) · 거울 시험 =
|
||||
`resources/tester/test_272_1_station_list.py`.
|
||||
"""
|
||||
root = project_root.resolve()
|
||||
path = (root / longitudinal_file_path).resolve()
|
||||
@@ -290,15 +320,53 @@ def _merge_irregular_into_longitudinal(
|
||||
for station in stations
|
||||
if station.get("kind") == "irregular"
|
||||
]
|
||||
regular = [station for station in stations if station.get("kind") != "irregular"]
|
||||
merged = regular + list(irregular_stations)
|
||||
merged.sort(key=lambda station: float(station.get("chainage_m", 0.0)))
|
||||
data["stations"] = merged
|
||||
summary = _follow_moved_stations(data, before)
|
||||
fresh = {round(float(s.get("chainage_m", 0.0)), 6): s for s in irregular_stations}
|
||||
kept: dict[float, dict[str, Any]] = {}
|
||||
left: list[dict[str, Any]] = []
|
||||
for station in stations:
|
||||
if station.get("kind") == "irregular" and not station.get("grid"):
|
||||
continue # 옛 구조물 측점 — 새 목록이 갈음
|
||||
key = round(float(station.get("chainage_m", 0.0)), 6)
|
||||
if key not in fresh and ("structure" in station or station.get("grid")):
|
||||
# 구조물이 떠난 격자 · 시종점 자리 — 일반 측점으로(옛 판 「regular + 글」 포함)
|
||||
station = {k: v for k, v in station.items() if k not in ("grid", "structure")}
|
||||
if station.get("kind") == "irregular":
|
||||
station["kind"] = "regular"
|
||||
left.append(station)
|
||||
kept[key] = station
|
||||
kept.update(fresh)
|
||||
data["stations"] = sorted(kept.values(), key=lambda s: float(s.get("chainage_m", 0.0)))
|
||||
previous = [round(float(station.get("chainage_m", 0.0)), 6) for station in stations]
|
||||
summary = _follow_moved_stations(data, before, previous)
|
||||
atomic_write_json(path, data)
|
||||
_relabel_cross_files(path.parent.parent / "cross_sections", left)
|
||||
return summary
|
||||
|
||||
|
||||
async def drop_rows_off_stations(
|
||||
connection: aiomysql.Connection, route_id: int, project_root: Path, longitudinal_path: str
|
||||
) -> None:
|
||||
"""측점을 다시 맞춘 뒤 — 종단 정본에 없는 측점의 횡단 행을 새 측점으로 옮기거나 지운다(272)."""
|
||||
path = (project_root.resolve() / longitudinal_path).resolve()
|
||||
stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or []
|
||||
await sync_cross_section_rows(
|
||||
connection, route_id, [float(station["chainage_m"]) for station in stations]
|
||||
)
|
||||
|
||||
|
||||
def _relabel_cross_files(cross_dir: Path, stations: list[dict[str, Any]]) -> None:
|
||||
"""일반 측점으로 돌아온 자리의 횡단 파일 글(종류 · 구조물)도 맞춘다 — 카드 제목(272)."""
|
||||
for station in stations:
|
||||
cross_path = find_cross_file(cross_dir, float(station.get("chainage_m", 0.0)))
|
||||
if not cross_path.is_file():
|
||||
continue
|
||||
cross = json.loads(cross_path.read_text(encoding="utf-8"))
|
||||
cross.pop("structure", None)
|
||||
cross.pop("grid", None)
|
||||
cross["kind"] = station.get("kind")
|
||||
atomic_write_json(cross_path, cross)
|
||||
|
||||
|
||||
#: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해.
|
||||
#: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다.
|
||||
SAMPLING_SNAPSHOT_NAME = "sampling.json"
|
||||
@@ -373,14 +441,20 @@ async def _append_irregular_cross_sections(
|
||||
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
||||
)
|
||||
|
||||
# 빈 목록이어도 맞춤 — 옛 구조물 측점 · 떠난 격자 자리 글을 지워야 함(272)
|
||||
irregular_stations = await asyncio.to_thread(_generate)
|
||||
if not irregular_stations:
|
||||
return
|
||||
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
|
||||
if longitudinal:
|
||||
await asyncio.to_thread(
|
||||
summary = await asyncio.to_thread(
|
||||
_merge_irregular_into_longitudinal,
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
irregular_stations,
|
||||
)
|
||||
if summary is not None: # 계획선을 다시 셈 — DB 등급 요약도 같이(B06 측점 만들기와 같은 길)
|
||||
await update_longitudinal_grade_summary(
|
||||
connection, route_id=route["id"], grade_summary=summary
|
||||
)
|
||||
await drop_rows_off_stations(
|
||||
connection, route["id"], project_root, str(longitudinal["longitudinal_file_path"])
|
||||
)
|
||||
|
||||
@@ -268,7 +268,8 @@ class RouteConfirmRequest(BaseModel):
|
||||
"""경로 확정 요청.
|
||||
|
||||
확정 시 비정규 측점의 **횡단을 함께 생성**하기 위해, 지표 샘플러 재구성에 필요한 값
|
||||
(solve 때 쓰던 필터/방법/모델)을 함께 받는다. 비정규 측점이 없으면 재생성 없이 확정만 한다.
|
||||
(solve 때 쓰던 필터/방법/모델)을 함께 받는다. 셋이 다 오면 구조물 측점을 서버 정본 목록
|
||||
전체로 다시 맞춘다(빈 목록이어도 · 272).
|
||||
모든 필드가 선택이라 빈 본문(`{}`)이면 기존 확정 동작과 동일하다.
|
||||
"""
|
||||
|
||||
@@ -286,12 +287,8 @@ class RouteConfirmRequest(BaseModel):
|
||||
return tuple((item.chainage_m, item.structure) for item in self.irregular_stations)
|
||||
|
||||
def can_regenerate(self) -> bool:
|
||||
return bool(
|
||||
self.irregular_stations
|
||||
and self.filter_key
|
||||
and self.method
|
||||
and self.surface_model_id is not None
|
||||
)
|
||||
# 화면 목록이 비어도 맞춤 — 측점 목록은 서버 정본에서 뜨고, 옛 구조물 측점을 지워야 함(272)
|
||||
return bool(self.filter_key and self.method and self.surface_model_id is not None)
|
||||
|
||||
|
||||
class RouteConfirmResponse(BaseModel):
|
||||
|
||||
@@ -181,7 +181,8 @@ function fnv1a(text: string): string {
|
||||
// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다.
|
||||
// 93 = 측점 사이 행 자리를 **노선 길이 직선 보간**으로(115-5 P0-2) — 균일 Catmull-Rom 이
|
||||
// 행을 최대 7.2 m 미끄러뜨리고 되접었다. 모든 행 자리가 바뀌어 저장본을 만료한다.
|
||||
const BUILD_VERSION = 93;
|
||||
// 94 = 물넘이포장 노면을 파지 않음(287-4) — 설계 노면이 콘크리트 윗면 · 저장본을 만료한다.
|
||||
const BUILD_VERSION = 94;
|
||||
|
||||
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
|
||||
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* (2026-08-23 사용자 지시)의 핵심.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fordDeckElevationAt } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
|
||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure";
|
||||
|
||||
@@ -227,22 +226,8 @@ export function classifyStation(section: CrossSection): StationPieces | null {
|
||||
if (!design || !line || line.length < 2) return null;
|
||||
const roadL = design.road_edges.left.offset_m;
|
||||
const roadR = design.road_edges.right.offset_m;
|
||||
// 물넘이포장 — 노면(노견 포함)이 파인다. 횡단도와 **같은 산식**으로 표고를 내려
|
||||
// 3D도 같은 자리에 같은 깊이로 팬다(2026-08-28 사용자 지시). 노면 밖 비탈은 그대로라
|
||||
// 노견 끝에 파임 벽(수직 단차)이 선다 — 횡단도 그림과 같은 표현이다.
|
||||
const ford = section.ford_pavement;
|
||||
const carved =
|
||||
ford && ford.depth_m
|
||||
? line.map((point) =>
|
||||
point.offset_m <= roadL + 1e-9 && point.offset_m >= roadR - 1e-9
|
||||
? {
|
||||
...point,
|
||||
elevation_m: fordDeckElevationAt(design, ford, point.offset_m, point.elevation_m),
|
||||
}
|
||||
: point,
|
||||
)
|
||||
: line;
|
||||
const sorted = [...carved].sort((a, b) => a.offset_m - b.offset_m);
|
||||
// 물넘이포장 — 설계 노면이 곧 콘크리트 윗면(287-4 · 서버가 지대 낮은 쪽 한 경사로 냄) · 파지 않음
|
||||
const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m);
|
||||
const cwL = design.carriageway_edges?.left.offset_m ?? roadL;
|
||||
const cwR = design.carriageway_edges?.right.offset_m ?? roadR;
|
||||
|
||||
|
||||
@@ -487,6 +487,8 @@ export function buildCorridorStructures(
|
||||
chuteBands(
|
||||
fordLayout.sides.filter((side) => side.role === "outlet").flatMap((s) => s.fillSegments),
|
||||
section.ford?.chute,
|
||||
undefined,
|
||||
fordLayout.ground,
|
||||
).forEach((band) => pushSwept("chute", band, half, half, null, "chute"));
|
||||
// 날개벽 4매 — BOX암거와 같은 규칙(2026-08-25 사용자). 뿌리는 **벽 외측면**
|
||||
// (성토 경사측면과 마주치는 면) 모서리다(2026-08-25 ⑤ — 종전 바닥판 바깥 끝은
|
||||
@@ -595,7 +597,12 @@ export function buildCorridorStructures(
|
||||
);
|
||||
}
|
||||
// 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 묻힌 끝 3도 절토 조각(152-3) · 유출 벽 길이(135-3).
|
||||
chuteBands(layout.outletFill.segments, section.culvert?.chute, isChuteSegment).forEach((band) =>
|
||||
chuteBands(
|
||||
layout.outletFill.segments,
|
||||
section.culvert?.chute,
|
||||
isChuteSegment,
|
||||
layout.ground,
|
||||
).forEach((band) =>
|
||||
pushSwept("chute", band, outletSpan.beforeM, outletSpan.afterM, null, "chute"),
|
||||
);
|
||||
// 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다).
|
||||
|
||||
@@ -182,6 +182,8 @@ export function boxSolids(
|
||||
const chutes: CorridorStructure[] = chuteBands(
|
||||
layout.sides.filter((side) => side.wingRole === "outlet").flatMap((side) => side.fillSegments),
|
||||
box.chute,
|
||||
undefined,
|
||||
layout.ground,
|
||||
).map((band) => ({
|
||||
chainage_m: chainage,
|
||||
kind: "chute",
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
|
||||
import type { ChuteSpec, CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { OffsetPoint } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
|
||||
import { chuteBandOutline } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch";
|
||||
import {
|
||||
chuteBandOutline,
|
||||
chuteRun,
|
||||
groundLine,
|
||||
} from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch";
|
||||
import { outletFillLine } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
|
||||
|
||||
/** 성토 조각 하나 — B06 오버레이가 띠를 그리는 선(절토 조각은 뺀다). */
|
||||
@@ -25,10 +29,11 @@ export function chuteBands(
|
||||
segments: ReadonlyArray<FillSegment>,
|
||||
chute: ChuteSpec | null | undefined,
|
||||
keep: (segment: FillSegment) => boolean = (segment) => segment.kind !== "cut",
|
||||
/** 원지반 — 길이가 사면보다 길면 이어 그림(294-4 · 횡단 띠와 같은 `chuteRun`). */
|
||||
ground?: readonly OffsetPoint[],
|
||||
): OffsetPoint[][] {
|
||||
if (!chute) return [];
|
||||
return segments.flatMap((segment) => {
|
||||
if (!keep(segment)) return [];
|
||||
return chuteRun(segments.filter(keep), chute.length_m, ground).flatMap((segment) => {
|
||||
const band = chuteBandOutline(segment.points, chute.thickness_m);
|
||||
return band ? [band.outline] : [];
|
||||
});
|
||||
@@ -51,6 +56,12 @@ export function fordPavementChute(
|
||||
);
|
||||
if (owner !== section) return null;
|
||||
}
|
||||
const bands = chuteBands([{ points: outletFillLine(section.design) }], pave.chute);
|
||||
const ground = groundLine(section.samples ?? []);
|
||||
const bands = chuteBands(
|
||||
[{ points: outletFillLine(section.design, ground) }],
|
||||
pave.chute,
|
||||
undefined,
|
||||
ground,
|
||||
);
|
||||
return bands.length ? { bands, halfM: Math.max(pave.span_m, 0.5) / 2 } : null;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ import type {
|
||||
|
||||
/** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */
|
||||
function chuteParts(chute: ChuteSpec | null | undefined): Array<string | number> {
|
||||
return chute ? ["ch", chute.structure, chute.thickness_m] : [];
|
||||
if (!chute) return [];
|
||||
// 길이(294-4)는 넣은 때만 — 옛 저장본 해시 그대로
|
||||
const base = ["ch", chute.structure, chute.thickness_m];
|
||||
return chute.length_m ? [...base, chute.length_m] : base;
|
||||
}
|
||||
|
||||
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
|
||||
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
type SpecForm,
|
||||
type SpecLinks,
|
||||
} from "../A00_Common/design/design_structure_form";
|
||||
import type { SpecContainer, SpecItem } from "../A00_Common/design/design_structure_spec";
|
||||
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
|
||||
import {
|
||||
BOX_SPEC,
|
||||
@@ -65,7 +64,7 @@ import {
|
||||
boxOptionsIn,
|
||||
} from "../A00_Common/design/design_structure_box";
|
||||
import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford";
|
||||
import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
|
||||
import { overflowSync, syncChuteArea } from "../A00_Common/design/design_structure_ford_calc";
|
||||
import {
|
||||
pipeDrawn,
|
||||
pipeExtraCount,
|
||||
@@ -372,38 +371,24 @@ export function createFacilityOptionsForm(
|
||||
// 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`) —
|
||||
// 물넘이 포장도 같은 키 · 같은 계산(65-5) · 새 시설은 유량이 오면 빈 높이를 필요 수심으로(65-4)
|
||||
let fordFlow: number | null = null;
|
||||
let heightPending = false;
|
||||
const overflow = (form: () => SpecForm, box: string) => {
|
||||
const values = () => form().state()[box]?.[0] ?? {};
|
||||
const need = (): number | null => fordHeightM(fordFlow, Number(values().width ?? ""));
|
||||
return {
|
||||
onChange: (c: SpecContainer, item: SpecItem) => {
|
||||
if (c.id === box && item.id === "height") heightPending = false;
|
||||
const want = c.id === box ? need() : null;
|
||||
const height = values().height ?? "";
|
||||
const low = height !== "" && !(Number(height) >= (want ?? 0));
|
||||
if (want !== null && (item.id === "width" || (item.id === "height" && low)))
|
||||
form().setValue(box, "height", want.toFixed(2));
|
||||
emit();
|
||||
},
|
||||
/** 새 시설 빈 높이 = 필요 수심(사용자가 넣은 것처럼 · 알림 없음 — [추가] · [수정]이 읽음). */
|
||||
fill: () => {
|
||||
const want = heightPending && !values().height ? need() : null;
|
||||
if (want === null) return;
|
||||
form().setValue(box, "height", want.toFixed(2));
|
||||
heightPending = false;
|
||||
},
|
||||
};
|
||||
};
|
||||
const fordFlowBox = overflow(() => fordForm, "ford");
|
||||
const fordFlowBox = overflowSync(
|
||||
() => fordForm,
|
||||
"ford",
|
||||
() => fordFlow,
|
||||
emit,
|
||||
);
|
||||
const guardFields = guardChoiceFields(
|
||||
() => chainage,
|
||||
() => fordForm.refresh(),
|
||||
() => fordForm.recount(),
|
||||
);
|
||||
// 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1)
|
||||
const fordForm: SpecForm = createSpecForm(specLinks, {
|
||||
onChange: fordFlowBox.onChange,
|
||||
fields: guardChoiceFields(
|
||||
() => chainage,
|
||||
() => fordForm.refresh(),
|
||||
() => fordForm.recount(),
|
||||
onChange: (c, item) => (
|
||||
syncChuteArea(fordForm, c, item, "chute_add", "ford"),
|
||||
fordFlowBox.onChange(c, item)
|
||||
),
|
||||
fields: (c, values) => [...guardFields(c, values), ...fordFlowBox.fields(c)],
|
||||
count: guardSoilCount(
|
||||
() => chainage,
|
||||
() => fordForm.state().outlet?.[0]?.structure,
|
||||
@@ -485,8 +470,16 @@ export function createFacilityOptionsForm(
|
||||
|
||||
// ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도(+ 월류 폭 · 높이 · 65-5) · 도수로/돌붙임 ·
|
||||
// 옛 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ──
|
||||
const paveFlowBox = overflow(() => paveForm, "slope");
|
||||
const paveForm: SpecForm = createSpecForm(specLinks, { onChange: paveFlowBox.onChange });
|
||||
const paveFlowBox = overflowSync(
|
||||
() => paveForm,
|
||||
"slope",
|
||||
() => fordFlow,
|
||||
emit,
|
||||
);
|
||||
const paveForm: SpecForm = createSpecForm(specLinks, {
|
||||
onChange: (c, item) => (syncChuteArea(paveForm, c, item), paveFlowBox.onChange(c, item)),
|
||||
fields: paveFlowBox.fields,
|
||||
});
|
||||
|
||||
// 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`.
|
||||
|
||||
@@ -603,7 +596,7 @@ export function createFacilityOptionsForm(
|
||||
},
|
||||
setFacility(facility, options = {}, designFlow = null, definition = null, fresh = false) {
|
||||
current = facility;
|
||||
heightPending = fresh;
|
||||
[fordFlowBox, paveFlowBox].forEach((box) => box.arm(fresh));
|
||||
hiddenOptions.remember(options);
|
||||
fordFlow = designFlow ?? null;
|
||||
if (facility === null) {
|
||||
|
||||
@@ -19,6 +19,14 @@ const FORD_KINDS: Record<string, number> = {
|
||||
ford_pavement: FORD_PAVEMENT_DEFAULT_WIDTH_M,
|
||||
};
|
||||
|
||||
/** 프로젝트마다 높이를 기다리는 측점 — 배수유역도 판 · B06 [추가] 가 같은 표를 씀(298-1). */
|
||||
const waits = new Map<string, Set<number>>();
|
||||
export function fordWaitsOf(projectId: string | null): Set<number> {
|
||||
const key = projectId ?? "";
|
||||
if (!waits.has(key)) waits.set(key, new Set());
|
||||
return waits.get(key) as Set<number>;
|
||||
}
|
||||
|
||||
/** 새로 놓으면 높이를 기다리는 종류 — 높이 칸이 빈 세월교 · 물넘이포장. */
|
||||
export function waitsFordHeight(
|
||||
facility: string | undefined,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { pointInRings } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
import { clampWheelScale } from "./B05_Profile_UI_Drainage_ZoomRange";
|
||||
import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
||||
import type { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
|
||||
@@ -33,6 +34,8 @@ export interface DrainageInteractParams {
|
||||
setScale: (value: number) => void;
|
||||
/** 배율 상한을 도엽 실폭으로 계산하기 위한 메타 (2026-09-04). 없으면 종전 고정 상한. */
|
||||
getMeta: () => VWorldMeta | null;
|
||||
/** 줌아웃 한계 배율 — 노선 기준(계획서 281). */
|
||||
getMinScale: () => number;
|
||||
getOffset: () => { x: number; y: number };
|
||||
setOffset: (x: number, y: number) => void;
|
||||
scheduleDraw: () => void;
|
||||
@@ -62,7 +65,13 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void {
|
||||
// 상한은 「화면 폭 20m」로 계산한다 — B04 지도와 같은 규칙(2026-09-04 사용자 지시).
|
||||
const view = currentView();
|
||||
const maxScale = computeMaxScale(params.getMeta(), view.mapRect.width, view.width, 16);
|
||||
const scale = Math.min(maxScale, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87)));
|
||||
// 하한은 노선 기준(계획서 281) — 도엽 전체까지 빠지지 않는다.
|
||||
const scale = clampWheelScale(
|
||||
prevScale * (event.deltaY > 0 ? 1.15 : 0.87),
|
||||
prevScale,
|
||||
params.getMinScale(),
|
||||
maxScale,
|
||||
);
|
||||
params.setScale(scale);
|
||||
// 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작).
|
||||
const ratio = scale / prevScale;
|
||||
|
||||
@@ -36,16 +36,18 @@ import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
|
||||
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
|
||||
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
|
||||
import { fillFordHeights, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill";
|
||||
import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill";
|
||||
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
|
||||
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
|
||||
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
|
||||
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
|
||||
import { computeZoomOutLimit } from "./B05_Profile_UI_Drainage_ZoomRange";
|
||||
import {
|
||||
mountDrainageToggles,
|
||||
DRAINAGE_LAYERS,
|
||||
fetchDrainageLayers,
|
||||
createProgressReporter,
|
||||
analyzeFailed,
|
||||
fitCanvasToViewport,
|
||||
fitViewToRoute,
|
||||
observeViewportSize,
|
||||
@@ -179,8 +181,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
let frameView: Window = window;
|
||||
let loadSequence = 0;
|
||||
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
|
||||
/** 우클릭으로 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1). */
|
||||
const fordWaits = new Set<number>();
|
||||
/** 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1 · B06 [추가]도 298-1). */
|
||||
const fordWaits = () => fordWaitsOf(projectId);
|
||||
let canvasWidth = 0;
|
||||
let canvasHeight = 0;
|
||||
let canvasDpr = 0;
|
||||
@@ -357,7 +359,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
})),
|
||||
);
|
||||
// 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이를 먼저 얹음).
|
||||
const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits);
|
||||
const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits());
|
||||
facilityStore.replaceFromResponse(response.pipe_points);
|
||||
if (fordFilled) rememberPipes();
|
||||
// 재계산 뒤에는 선택을 다시 알려야 한다 — 관을 새로 넣은 직후에는 목록이 아직
|
||||
@@ -390,8 +392,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
} catch (error) {
|
||||
if (seq !== requestSeq) return;
|
||||
status.hidden = false;
|
||||
status.textContent =
|
||||
error instanceof Error ? error.message : L("B05_Drainage_Status_AnalyzeFailed");
|
||||
status.textContent = analyzeFailed(error, L("B05_Drainage_Status_AnalyzeFailed"));
|
||||
} finally {
|
||||
if (seq === requestSeq) {
|
||||
analyzeButton.disabled = false;
|
||||
@@ -461,6 +462,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
offsetY = fitted.offsetY;
|
||||
}
|
||||
|
||||
function zoomOutLimit(): number {
|
||||
const view = currentView();
|
||||
return computeZoomOutLimit(meta, routePoints, basins, view.width, view.height);
|
||||
}
|
||||
|
||||
async function loadLayers(): Promise<void> {
|
||||
if (!projectId) return;
|
||||
const activeProjectId = projectId;
|
||||
@@ -517,6 +523,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
scale = value;
|
||||
},
|
||||
getMeta: () => meta,
|
||||
getMinScale: zoomOutLimit,
|
||||
getOffset: () => ({ x: offsetX, y: offsetY }),
|
||||
setOffset: (x, y) => {
|
||||
offsetX = x;
|
||||
@@ -540,8 +547,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
offsetY = next.offsetY;
|
||||
},
|
||||
redraw: () => {
|
||||
// 배치가 잡히면 미뤄 둔 화면 맞춤을 그때 수행한다.
|
||||
if (fitPending) fitToRoute();
|
||||
// 배치가 잡히면 미뤄 둔 화면 맞춤을 그때 수행한다. 늘려서 줌아웃 한계 밖이면 노선 맞춤(281-2).
|
||||
if (fitPending || scale < zoomOutLimit() * 0.999) fitToRoute();
|
||||
scheduleDraw();
|
||||
},
|
||||
});
|
||||
@@ -612,7 +619,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1)
|
||||
// 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다.
|
||||
facilityStore.set(chainageM, attributes ?? null);
|
||||
if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits.add(chainageM);
|
||||
if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits().add(chainageM);
|
||||
pipeEditor.addAtChainage(chainageM);
|
||||
},
|
||||
updatePipeFacility(fromChainageM, toChainageM, attributes) {
|
||||
@@ -623,7 +630,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
const previous = facilityStore.get(fromChainageM);
|
||||
facilityStore.set(fromChainageM, null);
|
||||
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes));
|
||||
if (fordWaits.delete(fromChainageM)) fordWaits.add(toChainageM);
|
||||
if (fordWaits().delete(fromChainageM)) fordWaits().add(toChainageM);
|
||||
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
|
||||
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
|
||||
// attach가 새 위치로 시설 정보를 승계한다.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { stateKey } from "../A00_Common/b_page_state";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
@@ -154,7 +155,7 @@ export function renderBasinRows(
|
||||
.replace("{relief}", basin.relief_m.toFixed(1))
|
||||
.replace("{flow}", String(Math.round(basin.flow_length_m)))
|
||||
.replace("{pipe}", pipe);
|
||||
row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(1));
|
||||
row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(0)); // 정수 m(167)
|
||||
// 산출 근거(도달시간·강우강도·설계유량)는 툴팁 둘째 줄로 붙인다 — 행이 길어지지 않게.
|
||||
if (basin.tc_minutes != null && basin.design_flow_m3s != null) {
|
||||
row.title +=
|
||||
@@ -515,6 +516,13 @@ export function createProgressReporter(progress: {
|
||||
};
|
||||
}
|
||||
|
||||
/** 세부유역 요청 실패 — 지도 칸 문구를 돌려주고 토스트 한 줄(뒤에서 다시 나눔이 조용히 안 끝나게 · 298-1). */
|
||||
export function analyzeFailed(error: unknown, fallback: string): string {
|
||||
const text = error instanceof Error ? error.message : fallback;
|
||||
showToast(text === fallback ? text : `${fallback} ${text}`, "error");
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */
|
||||
export function pipeMarkerColor(basins: ReadonlyArray<DetailBasin>, chainageM: number): string {
|
||||
const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < PIPE_MATCH_M);
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
* · 관 자리 읽기 · 쓰기(되돌리기) = `pipeChainages()` · `setPipeChainages()`
|
||||
* · 우클릭 메뉴의 구조물 종류 = `host.structureMenuItems`
|
||||
* · 구간 띠 · 노선 = `setIntervalSpans()` · `setRoute()`
|
||||
* 두 보기(종단 · 횡단)에서 같은 판을 끼우고 **켜고 끈다** — 끄면 자리를 비우되 관 · 유역
|
||||
* 상태는 그대로 살아 있다(다시 켜면 이어서 보임). 켬 · 끔을 어디에 적을지는 끼우는 쪽 몫이다.
|
||||
* 두 보기(종단 · 횡단)에서 같은 판을 끼운다 — 판은 늘 보이고 접기 · 펼치기는 판 안 손잡이 하나로.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./B05_Profile_UI_Style_Drainage.css"; // 홀로 서려면 판이 자기 모양을 들고 온다.
|
||||
@@ -23,71 +22,11 @@ import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
|
||||
/** 종단쪽이 넘기는 이음 — 판 바깥 약속(`DrainagePanelCallbacks`)에 projectId · view 를 더한 것. */
|
||||
export type DrainageSideHost = DesignSideHost;
|
||||
|
||||
/** 종단도 side 판 = 배수유역 판 창구 그대로 + 켜고 끔. */
|
||||
export type DrainageSideBoard = DesignSideBoard & {
|
||||
/** 켜고 끈다. 켜면 숨겼던 사이 바뀐 크기 · 그림을 판이 스스로 다시 맞춘다. */
|
||||
setVisible: (visible: boolean) => void;
|
||||
isVisible: () => boolean;
|
||||
/** 켬 · 끔이 바뀔 때마다 부른다(단추 표시 · 저장). 해제 함수를 돌려준다. */
|
||||
onVisibleChange: (listener: (visible: boolean) => void) => () => void;
|
||||
};
|
||||
|
||||
export interface DrainageSideOptions {
|
||||
/** 처음 켤지(기본 켬). */
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
/** `DesignSideFactory` — 종단도를 만들 때 한 번 부른다. */
|
||||
export function createDrainageSide(
|
||||
host: DrainageSideHost,
|
||||
options: DrainageSideOptions = {},
|
||||
): DrainageSideBoard {
|
||||
const panel = createDrainagePanel(host);
|
||||
const listeners = new Set<(visible: boolean) => void>();
|
||||
let visible = true;
|
||||
|
||||
function setVisible(next: boolean): void {
|
||||
if (next === visible) return;
|
||||
visible = next;
|
||||
panel.root.hidden = !visible;
|
||||
listeners.forEach((listener) => listener(visible));
|
||||
}
|
||||
// 처음 상태는 알림 없이 맞춘다.
|
||||
if (options.visible === false) {
|
||||
visible = false;
|
||||
panel.root.hidden = true;
|
||||
}
|
||||
|
||||
/** `DesignSideFactory` — 종단도를 만들 때 한 번 부른다. 접기 · 펼치기는 판 손잡이 몫. */
|
||||
export function createDrainageSide(host: DrainageSideHost): DesignSideBoard {
|
||||
return {
|
||||
...panel,
|
||||
// 보기가 바뀌어도 판은 다시 안 만든다 — 켬 · 끔은 끼우는 쪽이 `setVisible` 로 정한다.
|
||||
...createDrainagePanel(host),
|
||||
// 보기가 바뀌어도 판은 다시 안 만든다.
|
||||
setView: () => {},
|
||||
setVisible,
|
||||
isVisible: () => visible,
|
||||
onVisibleChange(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
dispose() {
|
||||
listeners.clear();
|
||||
panel.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 켜고 끄는 단추 — 어느 보기에서든 같은 모양 · 같은 글. 누르면 판을 뒤집는다. */
|
||||
export function createDrainageToggleButton(side: DrainageSideBoard): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ui-btn ui-btn--ghost b05-drainage__toggle";
|
||||
button.textContent = "배수유역도";
|
||||
button.title = "배수유역도 판을 켜고 끕니다";
|
||||
const sync = (visible: boolean): void => {
|
||||
button.setAttribute("aria-pressed", String(visible));
|
||||
button.classList.toggle("is-active", visible);
|
||||
};
|
||||
sync(side.isVisible());
|
||||
side.onVisibleChange(sync);
|
||||
button.addEventListener("click", () => side.setVisible(!side.isVisible()));
|
||||
return button;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_ZoomRange.ts
|
||||
* 배수유역 지도의 줌아웃 한계(가장 작은 배율) — 도엽 전체가 아니라 **노선 기준**(계획서 281).
|
||||
*
|
||||
* 한계 = 노선 범위를 가운데 두고 ZOOM_OUT_ROUTE_FACTOR 배로 넓힌 범위가 화면에 들어오는 배율.
|
||||
* 유역 폴리곤이 그 밖으로 나가면 유역까지 넓힌다. 처음 보기(노선 + 여유)보다 좁히지 않고,
|
||||
* 도엽 전체(배율 1)보다 멀리는 안 간다. 노선이 없으면 종전 꼴(ZOOM_OUT_FALLBACK_SCALE).
|
||||
* 화면 그리기 없는 순수 셈 — 시험이 Node 로 바로 돌린다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DetailBasin, VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
computeMapRect,
|
||||
computeRouteView,
|
||||
createNormalizer,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
|
||||
/** 노선 범위를 몇 배까지 멀리 볼지 — 키우면 더 멀리 빠진다(사용자가 바꿀 수 있는 값). */
|
||||
export const ZOOM_OUT_ROUTE_FACTOR = 2;
|
||||
|
||||
/** 노선이 없을 때의 줌아웃 한계 — 도엽 전체의 절반까지(종전 값). */
|
||||
export const ZOOM_OUT_FALLBACK_SCALE = 0.5;
|
||||
|
||||
/** 줌아웃 한계 배율. 지도 메타가 없거나 노선이 두 점 미만이면 종전 값. */
|
||||
export function computeZoomOutLimit(
|
||||
meta: VWorldMeta | null,
|
||||
routePoints: ReadonlyArray<{ x: number; y: number }>,
|
||||
basins: ReadonlyArray<Pick<DetailBasin, "polygon_lonlat">>,
|
||||
width: number,
|
||||
height: number,
|
||||
factor: number = ZOOM_OUT_ROUTE_FACTOR,
|
||||
): number {
|
||||
if (!meta || routePoints.length < 2 || width <= 0 || height <= 0) {
|
||||
return ZOOM_OUT_FALLBACK_SCALE;
|
||||
}
|
||||
const route = {
|
||||
x_min: Math.min(...routePoints.map((point) => point.x)),
|
||||
x_max: Math.max(...routePoints.map((point) => point.x)),
|
||||
y_min: Math.min(...routePoints.map((point) => point.y)),
|
||||
y_max: Math.max(...routePoints.map((point) => point.y)),
|
||||
};
|
||||
// 도엽 정규 좌표(0~1, 위가 0)로 모아 잰다 — 노선(m)과 유역(경위도)을 한 자로 본다.
|
||||
const spanX = meta.width_meters || 1;
|
||||
const spanY = meta.height_meters || 1;
|
||||
const centerX = ((route.x_min + route.x_max) / 2 - meta.x_min) / spanX;
|
||||
const centerY = 1 - ((route.y_min + route.y_max) / 2 - meta.y_min) / spanY;
|
||||
const halfW = ((route.x_max - route.x_min) / spanX) * (factor / 2);
|
||||
const halfH = ((route.y_max - route.y_min) / spanY) * (factor / 2);
|
||||
let x0 = centerX - halfW;
|
||||
let x1 = centerX + halfW;
|
||||
let y0 = centerY - halfH;
|
||||
let y1 = centerY + halfH;
|
||||
const normalizer = createNormalizer(meta);
|
||||
basins.forEach((basin) =>
|
||||
basin.polygon_lonlat.forEach(([lon, lat]) => {
|
||||
const nx = (lon - normalizer.lonMin) / normalizer.lonRange;
|
||||
const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange;
|
||||
x0 = Math.min(x0, nx);
|
||||
x1 = Math.max(x1, nx);
|
||||
y0 = Math.min(y0, ny);
|
||||
y1 = Math.max(y1, ny);
|
||||
}),
|
||||
);
|
||||
// 배율 s 에서 화면에 드는 정규 폭 = 화면 폭 / (지도 사각형 폭 · s).
|
||||
const mapRect = computeMapRect(meta, width, height);
|
||||
const fit = Math.min(
|
||||
width / (mapRect.width * Math.max(x1 - x0, 1e-9)),
|
||||
height / (mapRect.height * Math.max(y1 - y0, 1e-9)),
|
||||
);
|
||||
// 처음 보기(노선 + 여유 200m)는 늘 한계 안 — 짧은 노선에서 처음 화면보다 좁아지지 않게.
|
||||
const initial = computeRouteView(meta, route, width, height).scale;
|
||||
return Math.max(1, Math.min(fit, initial));
|
||||
}
|
||||
|
||||
/** 휠 한 번의 새 배율 — 한계 안에서 멈춘다. 이미 한계 밖이면 더 빠지지만 않게(튀지 않게) 둔다. */
|
||||
export function clampWheelScale(
|
||||
next: number,
|
||||
previous: number,
|
||||
minScale: number,
|
||||
maxScale: number,
|
||||
): number {
|
||||
return Math.min(maxScale, Math.max(Math.min(minScale, previous), next));
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* 종단 보기 본체(`design_view_profile`)가 700줄을 넘어 여기로 뗐다(계획서 24-3b).
|
||||
* ========================================================================== */
|
||||
|
||||
import { dropStationsNear } from "./B05_Profile_Util_Station";
|
||||
import { oneStationList } from "../common_util/common_util_structure_station";
|
||||
import { interpolateIrregularStations } from "./B05_Profile_UI_Page_Helpers";
|
||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
@@ -24,7 +24,7 @@ export function stationLinesOf(input: StationLinesInput) {
|
||||
// 확정 때 종단 정본에 병합해 둔 비정규 측점은 그리지 않는다. 화면의 정본은 사이드바 목록이고
|
||||
// 관을 옮기면 그 목록만 따라오므로, 둘을 겹쳐 그리면 옮기기 전 자리가 그대로 남는다
|
||||
// (2026-08-02 사용자 보고). 저장분은 진입 시 `restoreSections`가 목록으로 되살린다.
|
||||
const regular = detail.longitudinal.stations.filter((station) => station.kind !== "irregular");
|
||||
const regular = oneStationList(detail.longitudinal.stations, []);
|
||||
const injected = interpolateIrregularStations(
|
||||
regular,
|
||||
input.irregular,
|
||||
@@ -48,9 +48,8 @@ export function stationLinesOf(input: StationLinesInput) {
|
||||
return best.elevation_m;
|
||||
};
|
||||
// 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용.
|
||||
// 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 3D 측점 띠도 종단 그래프와
|
||||
// 같은 규칙을 써야 두 화면의 측점이 어긋나지 않는다(2026-09-06).
|
||||
return [...dropStationsNear(regular, injected), ...injected].map((station) => {
|
||||
// 측점 한 벌(272) — 같은 m 면 구조물 측점 하나 · 종단 그래프 · 표와 같은 목록(`oneStationList`).
|
||||
return oneStationList(detail.longitudinal.stations, injected).map((station) => {
|
||||
const design = designAt(station.chainage_m);
|
||||
return {
|
||||
...station,
|
||||
|
||||
@@ -34,6 +34,13 @@ import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
||||
import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers";
|
||||
import { followedStation } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { followSnapped } from "./B05_Profile_UI_Structures_Snap";
|
||||
import { plantsStation } from "./B05_Profile_UI_Profile_Structures";
|
||||
import {
|
||||
pipeStationMarks,
|
||||
SAME_STATION_M,
|
||||
structureStationMarks,
|
||||
type StationMark,
|
||||
} from "../common_util/common_util_structure_station";
|
||||
|
||||
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
|
||||
export interface PipeProjection {
|
||||
@@ -41,6 +48,9 @@ export interface PipeProjection {
|
||||
effective_diameter_mm: number | null;
|
||||
facility: PipeFacility;
|
||||
options?: Record<string, string | number>;
|
||||
/** 독립 기슭막이 구간 — 시작 · 끝도 측점을 심는다(272 · 서버와 같은 규칙). */
|
||||
start_m?: number | null;
|
||||
end_m?: number | null;
|
||||
}
|
||||
|
||||
export interface StructuresBridgeDeps {
|
||||
@@ -58,17 +68,28 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
/** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */
|
||||
let irregularStations: IrregularStation[] = [];
|
||||
|
||||
function pipesToStations(
|
||||
pipes: Array<{
|
||||
chainage_m: number;
|
||||
effective_diameter_mm: number | null;
|
||||
facility: PipeFacility;
|
||||
}>,
|
||||
): IrregularStation[] {
|
||||
/** 측점 자리 → 측점번호 · 잔여거리(측점간격 기준). */
|
||||
function stationAt(
|
||||
chainage: number,
|
||||
): Pick<IrregularStation, "station" | "remainder" | "chainage_m"> {
|
||||
const interval = deps.panel().values().stationInterval || 20;
|
||||
return pipes.map((pipe) => {
|
||||
const station = Math.floor(pipe.chainage_m / interval);
|
||||
const remainder = pipe.chainage_m - station * interval;
|
||||
const station = Math.floor(chainage / interval);
|
||||
return { station, remainder: chainage - station * interval, chainage_m: chainage };
|
||||
}
|
||||
|
||||
/** 구간형의 시작 · 끝 측점 — 기준 측점(`id`)과 짝 · 관 · 구조물 행이 아님(옮기기 · 지우기 · 관 대조에서
|
||||
* 빠짐 · `origin` 없음) · id 에 자리 이름을 붙여 옮겨도 계획고 편집이 따라감(272). */
|
||||
const spanStations = (id: string, label: string, marks: StationMark[]): IrregularStation[] =>
|
||||
marks
|
||||
.filter((mark) => mark.role !== "anchor")
|
||||
.map((mark) => ({
|
||||
id: `${id}:${mark.role}`,
|
||||
...stationAt(mark.chainage_m),
|
||||
structure: label,
|
||||
}));
|
||||
|
||||
function pipesToStations(pipes: ReadonlyArray<PipeProjection>): IrregularStation[] {
|
||||
return pipes.flatMap((pipe) => {
|
||||
const label =
|
||||
pipe.facility === "pipe"
|
||||
? structureLabel({
|
||||
@@ -77,24 +98,20 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm),
|
||||
})
|
||||
: FACILITY_NAMES[pipe.facility];
|
||||
return {
|
||||
id: `pipe-${pipe.chainage_m.toFixed(2)}`,
|
||||
station,
|
||||
remainder,
|
||||
chainage_m: pipe.chainage_m,
|
||||
structure: label,
|
||||
structureType: "배관",
|
||||
origin: "pipe",
|
||||
};
|
||||
const id = `pipe-${pipe.chainage_m.toFixed(2)}`;
|
||||
return [
|
||||
{
|
||||
id,
|
||||
...stationAt(pipe.chainage_m),
|
||||
structure: label,
|
||||
structureType: "배관" as const,
|
||||
origin: "pipe" as const,
|
||||
},
|
||||
...spanStations(id, label, pipeStationMarks(pipe)),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** 물넘이포장은 비정규 측점이 아니다 — 횡단도로 표현할 것이 없어 종단 알약으로만
|
||||
* 다룬다(2026-08-28 사용자 확정). 관 정본 대조(setPipeChainages)는 전체 목록을 써야
|
||||
* 물넘이가 지워지지 않으므로, **표시·서버 전송 쪽에서만** 뺀다. */
|
||||
const withoutFordPavement = (stations: IrregularStation[]): IrregularStation[] =>
|
||||
stations.filter((station) => station.structure !== FACILITY_NAMES.ford_pavement);
|
||||
|
||||
/** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */
|
||||
function clearProjectedStations(): void {
|
||||
pipeStations = [];
|
||||
@@ -115,13 +132,13 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
);
|
||||
const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3)));
|
||||
gone.forEach((station) => {
|
||||
// 없으면 맞춘 자리의 새 측점 — [정수 맞춤]이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1)
|
||||
// 없으면 맞춘 자리의 새 측점 — 옛 소수 자동 맞춤이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1 · 272)
|
||||
const same = followedStation(station, gone, born) ?? followSnapped(station, born);
|
||||
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
|
||||
});
|
||||
irregularStations = stations;
|
||||
deps.renderStationLines();
|
||||
deps.profilePanel().setIrregularStations(withoutFordPavement(stations));
|
||||
deps.profilePanel().setIrregularStations(stations);
|
||||
// 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다.
|
||||
// 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다.
|
||||
deps
|
||||
@@ -143,29 +160,40 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
* 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거·
|
||||
* 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */
|
||||
let pipeStations: IrregularStation[] = [];
|
||||
let structureTypeMap = new Map<string, { group: string; name: string }>();
|
||||
let structureTypeMap = new Map<
|
||||
string,
|
||||
{ type_id: string; group: string; name: string; placement: string; managed_by: string | null }
|
||||
>();
|
||||
|
||||
/** A군 수동 구조물을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
|
||||
/** 측점을 심는 수동 구조물(A군 기준 · C · D군 구간형 시작 · 기준 · 끝 — 서버 `resolve_extra_stations`
|
||||
* 와 같은 규칙 · 272)을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
|
||||
function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] {
|
||||
const interval = deps.panel().values().stationInterval || 20;
|
||||
return structures
|
||||
.filter((structure) => structureTypeMap.get(structure.type_id)?.group === "A")
|
||||
.map((structure) => {
|
||||
const chainage = structure.chainage_m ?? structure.start_m ?? 0;
|
||||
const station = Math.floor(chainage / interval);
|
||||
return {
|
||||
id: structure.structure_id ?? `structure-${chainage.toFixed(2)}`,
|
||||
station,
|
||||
remainder: chainage - station * interval,
|
||||
chainage_m: chainage,
|
||||
structure: structureTypeMap.get(structure.type_id)?.name ?? structure.type_id,
|
||||
};
|
||||
});
|
||||
return structures.flatMap((structure) => {
|
||||
const type = structureTypeMap.get(structure.type_id);
|
||||
const marks = structureStationMarks(structure, type);
|
||||
const anchor = marks.find((mark) => mark.role === "anchor");
|
||||
if (!anchor) return [];
|
||||
const id = structure.structure_id ?? `structure-${anchor.chainage_m.toFixed(2)}`;
|
||||
const label = type?.name ?? structure.type_id;
|
||||
return [
|
||||
{ id, ...stationAt(anchor.chainage_m), structure: label },
|
||||
...spanStations(id, label, marks),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** 관 정본 + A군 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다. */
|
||||
/** 관 정본 + 측점 심는 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다 — 같은 m 면 측점 하나(관이
|
||||
* 먼저 · 서버 측점 만들기와 같음). 관 기준 측점은 다 둠(관 정본 대조 `setPipeChainages` 가 관을
|
||||
* 지우지 않게) · 물넘이포장도 측점을 심음(286-1). */
|
||||
function syncCrossDrainStations(): void {
|
||||
applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]);
|
||||
const kept: IrregularStation[] = [];
|
||||
for (const station of [...pipeStations, ...crossDrainStationsOf(ownStructures)]) {
|
||||
const taken =
|
||||
station.origin !== "pipe" &&
|
||||
kept.some((other) => Math.abs(other.chainage_m - station.chainage_m) < SAME_STATION_M);
|
||||
if (!taken) kept.push(station);
|
||||
}
|
||||
applyIrregularStations(kept);
|
||||
}
|
||||
|
||||
/** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null).
|
||||
@@ -326,7 +354,16 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
deps.profilePanel().setStructureTypes(allTypes);
|
||||
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
|
||||
structureTypeMap = new Map(
|
||||
allTypes.map((type) => [type.type_id, { group: type.group, name: type.name }]),
|
||||
allTypes.map((type) => [
|
||||
type.type_id,
|
||||
{
|
||||
type_id: type.type_id,
|
||||
group: type.group,
|
||||
name: type.name,
|
||||
placement: type.placement,
|
||||
managed_by: type.managed_by,
|
||||
},
|
||||
]),
|
||||
);
|
||||
structureRevision = stored.revision;
|
||||
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29).
|
||||
@@ -362,7 +399,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
});
|
||||
},
|
||||
/** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */
|
||||
irregularStations: () => withoutFordPavement(irregularStations),
|
||||
irregularStations: () => irregularStations,
|
||||
/** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */
|
||||
typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group,
|
||||
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */
|
||||
@@ -376,6 +413,11 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
clearProjectedStations,
|
||||
/** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */
|
||||
pipeMarkChainage,
|
||||
/** 측점 없는 관 알약(물넘이포장)인가 — 고름 값에 알약 id 를 남길지 가름(요구 178). */
|
||||
stationlessPipeMark: (structureId: string | null): boolean => {
|
||||
const mark = pipeMarks.find((entry) => entry.structure_id === structureId);
|
||||
return !!mark && !plantsStation(mark, []);
|
||||
},
|
||||
/** 알약 식별자의 누가거리(구조물·관 공통). B06 으로 넘길 선택값을 만들 때 쓴다. */
|
||||
markChainage,
|
||||
/** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Guard.ts
|
||||
* 종단도가 편집 · 경고에 쓰는 **셈만** — 계획선 샘플 보간, 평면 최소곡선반지름 경고,
|
||||
* 구조물 측점을 종단 정본 측점으로 맞추기.
|
||||
* 종단도가 편집 · 경고에 쓰는 **셈만** — 계획선 샘플 보간, 평면 최소곡선반지름 경고
|
||||
* (구조물 측점 맞추기는 측점 한 벌 `oneStationList` 로 갈음 · 272).
|
||||
*
|
||||
* 종단도 한 벌(`A00_Common/design/design_profile.ts`)이 700줄을 넘겨 떼어낸 조각이다
|
||||
* (계획서 24-2c). 본문 로직과 수치는 그대로이고 화면 상태를 인자로 받는다.
|
||||
@@ -9,11 +9,6 @@
|
||||
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
|
||||
/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는
|
||||
* mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */
|
||||
const STATION_SNAP_TOLERANCE_M = 0.1;
|
||||
|
||||
/** 계획선 샘플에서 chainage로 계획고·지반고를 되짚는 보간기(최소고 판정 입력). */
|
||||
export function sampleAt(
|
||||
@@ -64,36 +59,3 @@ export function planCurveWarning(
|
||||
});
|
||||
return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04).
|
||||
*
|
||||
* 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`,
|
||||
* 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든
|
||||
* 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이
|
||||
* 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고).
|
||||
*
|
||||
* 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다.
|
||||
* 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다.
|
||||
*/
|
||||
export function snapToMasterStations(
|
||||
alignment: ProfileAlignment | null,
|
||||
stations: IrregularStation[],
|
||||
): IrregularStation[] {
|
||||
const master = alignment?.stations;
|
||||
if (!master?.length) return stations;
|
||||
return stations.map((station) => {
|
||||
let best: number | null = null;
|
||||
let gap = STATION_SNAP_TOLERANCE_M;
|
||||
for (const node of master) {
|
||||
const distance = Math.abs(node.chainage_m - station.chainage_m);
|
||||
if (distance <= gap) {
|
||||
gap = distance;
|
||||
best = node.chainage_m;
|
||||
}
|
||||
}
|
||||
return best === null || best === station.chainage_m
|
||||
? station
|
||||
: { ...station, chainage_m: best };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 거친다. 그래프·테이블·유토곡선이 같은 X 매핑을 쓰는 규칙은 그대로다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { dropStationsNear } from "./B05_Profile_Util_Station";
|
||||
import { oneStationList } from "../common_util/common_util_structure_station";
|
||||
import {
|
||||
createLongitudinalProfile,
|
||||
longitudinalMinimumWidth,
|
||||
@@ -225,18 +225,12 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
: (longitudinal.design_profiles ?? []);
|
||||
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
|
||||
const graphData = normalizedLongitudinal(longitudinal);
|
||||
// 종단 정본(확정 시 병합분)에 들어 있는 비정규 측점은 걷어낸다 — 화면의 정본은 사이드바
|
||||
// 목록이고, 관을 옮기면 그 목록만 따라온다. 둘을 겹쳐 그리면 옮기기 전 자리의 세로선이
|
||||
// 그대로 남는다(2026-08-02 사용자 보고).
|
||||
const regular = graphData.stations.filter((station) => station.kind !== "irregular");
|
||||
// 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m
|
||||
// 일반 측점은 숨김(구조물이 위) · 표 · ▲▼ · 3D 와 같은 목록(`oneStationList`).
|
||||
const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal));
|
||||
// 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 세로선·라벨이 두 겹으로
|
||||
// 겹쳐 읽히지 않는다(2026-09-06). 남는 쪽은 구조물 측점이다.
|
||||
const graphLongitudinal = {
|
||||
...graphData,
|
||||
stations: [...dropStationsNear(regular, injected), ...injected].sort(
|
||||
(a, b) => a.chainage_m - b.chainage_m,
|
||||
),
|
||||
stations: oneStationList(graphData.stations, injected),
|
||||
};
|
||||
// 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04
|
||||
// 사용자 확정). 가로 스크롤 위치(`scrollLeft`)와 본문 폭이 곧 보이는 구간이다.
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_SideTool.ts
|
||||
* 종단도 오른쪽 판(배수유역도)을 두 보기에 **같은 모양 · 같은 켬끔 값**으로 끼운다
|
||||
* (계획서 24-2c · 24-2e). 판 본체는 `_Drainage_Side`(sub7), 켬 · 끔 값은 `drainage-visible`.
|
||||
* 종단도 오른쪽 판(배수유역도)을 두 보기에 **같은 모양**으로 끼운다(계획서 24-2c · 24-2e).
|
||||
* 판 본체는 `_Drainage_Side`(sub7). 판은 늘 보이고 접기 · 펼치기는 판 안 손잡이 하나로(282-1).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import type { DesignSideBoard, DesignSideFactory } from "../A00_Common/design/design_profile";
|
||||
import {
|
||||
createDrainageSide,
|
||||
createDrainageToggleButton,
|
||||
type DrainageSideBoard,
|
||||
} from "./B05_Profile_UI_Drainage_Side";
|
||||
import type { DesignSideFactory } from "../A00_Common/design/design_profile";
|
||||
import { createDrainageSide } from "./B05_Profile_UI_Drainage_Side";
|
||||
|
||||
/** 오른쪽 판 — 켬 · 끔은 두 보기가 같은 값을 쓴다(기본 켬). */
|
||||
export const drainageSide: DesignSideFactory = (host) =>
|
||||
createDrainageSide(host, { visible: readStateRaw("drainage-visible") !== "false" });
|
||||
|
||||
/** 켬 · 끔 단추 — 누를 때마다 값을 적는다. */
|
||||
export function drainageSideTool(board: DesignSideBoard): HTMLElement {
|
||||
const side = board as DrainageSideBoard;
|
||||
side.onVisibleChange((visible) => writeStateRaw("drainage-visible", String(visible)));
|
||||
return createDrainageToggleButton(side);
|
||||
}
|
||||
/** 오른쪽 판 — 두 보기가 같은 판을 쓴다. */
|
||||
export const drainageSide: DesignSideFactory = (host) => createDrainageSide(host);
|
||||
|
||||
@@ -15,9 +15,17 @@ import {
|
||||
isPipeStation,
|
||||
type IrregularStation,
|
||||
} from "./B05_Profile_UI_IrregularStations";
|
||||
import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures";
|
||||
import {
|
||||
structureAnchorM,
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import type { PickGroup } from "./B05_Profile_UI_Structures_Panel_Types";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import {
|
||||
pipeStationMarks,
|
||||
structureStationMarks,
|
||||
} from "../common_util/common_util_structure_station";
|
||||
|
||||
/** 사이드 「구조물 배치」 군 · 종류 드롭다운 목록(`section.pickGroups()`)을 그대로 구조물군 → 종류
|
||||
* 2단 메뉴 항목으로(129-1 — 종단그래프 · 배수유역도 · 3D · B06 종단 공용 · 가린 군 · 종류는 뺌).
|
||||
@@ -157,6 +165,7 @@ export function structureIdKeeping(
|
||||
detail: {
|
||||
longitudinal: { stations: ReadonlyArray<{ station_id: string; chainage_m: number }> };
|
||||
} | null,
|
||||
types: ReadonlyArray<StructureType> = [],
|
||||
): string | null {
|
||||
const selected = structures.find((item) => item.structure_id === selectedId);
|
||||
const at = detail?.longitudinal.stations.find(
|
||||
@@ -165,20 +174,36 @@ export function structureIdKeeping(
|
||||
const keeps =
|
||||
stationId !== null &&
|
||||
!!selected &&
|
||||
(stationIdAtStructure(selectedId, stations, structures) === stationId ||
|
||||
(stationIdAtStructure(selectedId, stations, structures, types) === stationId ||
|
||||
(at !== undefined && Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M));
|
||||
return keeps ? selectedId : structureIdAtStation(stationId, stations, structures);
|
||||
}
|
||||
|
||||
/** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */
|
||||
/** 제 측점을 심는 알약인가 — 272 측점 심기 한 벌(A군 · C · D군 구간형 · 관 = 물넘이포장까지 · 286-1).
|
||||
* 안 심는 구조물 알약은 같은 자리 · 근처 측점선을 짝으로 켜지 않음(요구 178) · 종류를 모르면 옛 길. */
|
||||
export function plantsStation(
|
||||
structure: StructureInstance,
|
||||
types: ReadonlyArray<StructureType>,
|
||||
): boolean {
|
||||
if (String(structure.structure_id ?? "").startsWith("pipe-"))
|
||||
return (
|
||||
pipeStationMarks({ chainage_m: structureAnchorM(structure), facility: structure.type_id })
|
||||
.length > 0
|
||||
);
|
||||
const type = types.find((entry) => entry.type_id === structure.type_id);
|
||||
return !type || structureStationMarks(structure, type).length > 0;
|
||||
}
|
||||
|
||||
/** 알약(구조물) id → 측점선 id. 제 측점을 안 심는 구조물(B군 · 표지 등)이면 null. */
|
||||
export function stationIdAtStructure(
|
||||
structureId: string | null,
|
||||
stations: IrregularStation[],
|
||||
structures: StructureInstance[],
|
||||
types: ReadonlyArray<StructureType> = [],
|
||||
): string | null {
|
||||
if (structureId === null) return null;
|
||||
const structure = structures.find((item) => item.structure_id === structureId);
|
||||
if (!structure) return null;
|
||||
if (!structure || !plantsStation(structure, types)) return null;
|
||||
const anchor = structureAnchorM(structure);
|
||||
const station = stations.find((entry) => Math.abs(entry.chainage_m - anchor) < SAME_CHAINAGE_M);
|
||||
return station ? irregularStationId(station.id) : null;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Tools.ts
|
||||
* 종단 계획선 도구줄 — [초기화] · [↶][↷] · [직선화] · [쉬프트] · [▲][▼].
|
||||
* 종단 계획선 도구줄 — ([새 창]) · [초기화] · [↶][↷] · [직선화] · [쉬프트] · [▲][▼].
|
||||
* ([초기화]는 2026-09-03 추가 — 마지막 [저장] 시점 기준이며 전체 초기화가 아니다.)
|
||||
*
|
||||
* 자리는 절·성토 요약줄의 **맨 앞**(최대 기울기 칩 왼쪽)이고, 버튼 크기는 같은 줄 오른쪽
|
||||
* [배수유역도] · [새 창] 과 같다(공용 토큰 `--btn-bar-*`).
|
||||
* 자리는 절·성토 요약줄의 **맨 앞**(최대 기울기 칩 왼쪽)이고, 버튼 크기는 맨 앞
|
||||
* [새 창] 과 같다(공용 토큰 `--btn-bar-*`). [새 창] 은 종단도가 그릴 때마다 맨 앞에 옮겨 붙인다(282-2).
|
||||
*
|
||||
* 선택 흐름:
|
||||
* [직선화] → 측점 2개를 그래프에서 고름 → 두 측점의 라운드에 탄젠트한 직선으로 대체.
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
* 인스턴스마다) · 그 밖 종류는 정의 옵션 그룹(`StructureType.groups`)
|
||||
* · 첫 옵션 = 지금 보이는 첫 입력 칸(자동 칸 · 갈래가 없어 가린 칸 · 끈 칸은 건너뜀)
|
||||
* · 값 `auto` = 「(자동)」 · 빈 값은 건너뜀(기본값으로 안 바꿈 · 기본값은 새 구조물 칸에 실제 값)
|
||||
* · `structureOptionLines`(166-1) = 같은 컨테이너의 **보이는 칸 전부** 「제목: 이름 값 · …」 — B06 [상세] CAD 칸
|
||||
* 미리 넣음 · B07 짝(서버 Node `B07_DesignDetail_Note_Options_Node`)이 같이 씀
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
|
||||
import { shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import { bodyOf, boxOptionsIn } from "../A00_Common/design/design_structure_box";
|
||||
import { withLegacyGuards } from "../A00_Common/design/design_structure_ford";
|
||||
@@ -18,6 +21,8 @@ import {
|
||||
branchOf,
|
||||
choiceLabel,
|
||||
choicesOf,
|
||||
containerTitle,
|
||||
itemLabel,
|
||||
readState,
|
||||
type Instance,
|
||||
type SpecContainer,
|
||||
@@ -46,6 +51,14 @@ export function balloonLines(
|
||||
return values.length ? [first, values.join(SEP)] : [first];
|
||||
}
|
||||
|
||||
/** 명세 이름 바꿈 표 — 배관은 정의 파일의 옛 이름 「흙막이 추가」보다 명세 이름 「흙막이」가 앞섬(54-1). */
|
||||
function specNames(structure: StructureInstance, type: StructureType | undefined) {
|
||||
const names = type?.spec_names ?? {};
|
||||
if (structure.type_id !== "pipe") return names;
|
||||
const { soil_add: _old, ...pipeNames } = names;
|
||||
return pipeNames;
|
||||
}
|
||||
|
||||
/** 명세 컨테이너 값 — 폼(`Drainage_Facility.setFacility` · `paintStructureSpec`)과 같은 값 · 이름(빈 값 = ""). */
|
||||
function specLines(
|
||||
spec: StructureSpec,
|
||||
@@ -53,12 +66,7 @@ function specLines(
|
||||
type: StructureType | undefined,
|
||||
count?: ContainerCount,
|
||||
): string[] {
|
||||
let names = type?.spec_names ?? {};
|
||||
if (structure.type_id === "pipe") {
|
||||
// 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1)
|
||||
const { soil_add: _old, ...pipeNames } = names;
|
||||
names = pipeNames;
|
||||
}
|
||||
const names = specNames(structure, type);
|
||||
const state = formState(spec, structure.type_id, structure.options ?? {});
|
||||
return spec.containers.flatMap((c) =>
|
||||
instancesOf(c, state[c.id] ?? [], count).map((values) => {
|
||||
@@ -129,3 +137,76 @@ function groupLines(
|
||||
return raw !== undefined && raw !== "" ? `${raw}${option.unit ?? ""}` : "";
|
||||
});
|
||||
}
|
||||
|
||||
/** 「이름 (단위)」 칸 이름 + 값 → 「이름 값단위」(예 높이 (m) + 1.3 = 높이 1.3m) · 단위 없는 이름은 그대로 띄움. */
|
||||
function nameValue(label: string, shown: string): string {
|
||||
const hit = /^(.*?)\s*\(([^()]+)\)$/.exec(label);
|
||||
return hit && shown !== AUTO_MARK ? `${hit[1]} ${shown}${hit[2]}` : `${label} ${shown}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* CAD 칸 미리 넣음 옵션 줄(166-1) — 좌측 패널 폼과 같은 컨테이너 · 이름 · 보이는 칸 전부 · 빈 칸 뺌.
|
||||
* 명세 종류 = 컨테이너(선택 컨테이너는 저장된 인스턴스마다 · 뒤에 번호)마다 「제목: 이름 값 · 이름 값」 ·
|
||||
* 그 밖 종류 = 정의 그룹마다(그룹 형상 칸 포함) + 그룹 밖 칸은 종류 이름 줄 · 값 하나 없는 상자는 줄 없음.
|
||||
*/
|
||||
export function structureOptionLines(
|
||||
structure: Pick<StructureInstance, "type_id" | "options">,
|
||||
type: StructureType | undefined,
|
||||
): string[] {
|
||||
const options = structure.options ?? {};
|
||||
const line = (title: string, parts: string[]): string[] => {
|
||||
const kept = parts.filter(Boolean);
|
||||
return kept.length ? [`${title}: ${kept.join(SEP)}`] : [];
|
||||
};
|
||||
const spec = STRUCTURE_SPECS[structure.type_id];
|
||||
if (spec) {
|
||||
const names = specNames(structure as StructureInstance, type);
|
||||
const state = formState(spec, structure.type_id, options);
|
||||
return spec.containers.flatMap((c) =>
|
||||
(state[c.id] ?? []).flatMap((values, index) => {
|
||||
const group = type?.groups?.find((g) => g.id === c.link)?.name;
|
||||
const title = containerTitle(c, names, group) + (c.required ? "" : ` ${index + 1}`);
|
||||
const parts = c.items
|
||||
.filter((item) => item.kind !== "auto" && (!item.by || branchOf(item, values)))
|
||||
.map((item) => {
|
||||
// 필수 상자 빈 칸 = 폼이 채워 보이는 기본값(177-2 · 예 동물이동형 유) · 선택 상자는 안내뿐이라 뺌
|
||||
const empty = !values[item.id]?.trim() && c.required && item.default;
|
||||
const filled = empty ? { ...values, [item.id]: item.default ?? "" } : values;
|
||||
const shown = specValue(c, item, filled, names);
|
||||
return shown ? nameValue(itemLabel(c, item, names), shown) : "";
|
||||
});
|
||||
return line(title, parts);
|
||||
}),
|
||||
);
|
||||
}
|
||||
const byKey = new Map((type?.options ?? []).map((option) => [option.key, option]));
|
||||
const valueOf = (key: string, unit?: string | null): string => {
|
||||
const raw = options[key];
|
||||
if (raw === undefined || raw === null || raw === "") return "";
|
||||
return raw === "auto" ? AUTO_MARK : `${raw}${unit ?? ""}`;
|
||||
};
|
||||
const optionParts = (keys: string[]): string[] =>
|
||||
keys.map((key) => {
|
||||
const option = byKey.get(key);
|
||||
if (!option || option.visible === false || option.enabled === false) return "";
|
||||
const shown = valueOf(key, option.unit);
|
||||
return shown ? `${option.label} ${shown}` : "";
|
||||
});
|
||||
const groups = type?.groups ?? [];
|
||||
const grouped = new Set(groups.flatMap((group) => group.options));
|
||||
return [
|
||||
...groups.flatMap((group) =>
|
||||
line(group.name, [
|
||||
...optionParts(group.options),
|
||||
...shapeFields(group.shape).map((field) => {
|
||||
const shown = valueOf(shapeKey(group.id, field.suffix));
|
||||
return shown ? nameValue(field.label, shown) : "";
|
||||
}),
|
||||
]),
|
||||
),
|
||||
...line(
|
||||
type?.name ?? structure.type_id,
|
||||
optionParts((type?.options ?? []).map((o) => o.key).filter((key) => !grouped.has(key))),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -168,13 +168,13 @@ export function stationFields(
|
||||
if (rest < intervalM && Number.isInteger(stationNo)) return;
|
||||
const parts = chainageToStation(stationToChainage(stationNo, rest, intervalM), intervalM);
|
||||
station.value = String(parts.station);
|
||||
remainder.value = parts.remainder.toFixed(1);
|
||||
remainder.value = parts.remainder.toFixed(0); // 측점 + 거리 = 정수 m(167)
|
||||
};
|
||||
// 다른 change 리스너(옵션 잠금·임시 배치)보다 먼저 등록돼 정규화된 값이 읽힌다.
|
||||
[station, remainder].forEach((input) => input.addEventListener("change", normalize));
|
||||
}
|
||||
// 칸은 잔여를 소수 1자리로 보임 — 사용자가 칸을 안 고쳤으면 실어 둔 누가거리를 그대로 돌려줌
|
||||
// (표시값을 다시 읽으면 0.1m 로 뭉개져 옵션 · 메모만 고쳐도 측점이 옮겨 저장됨)
|
||||
// 칸은 잔여를 정수로 보임(167) — 사용자가 칸을 안 고쳤으면 실어 둔 누가거리를 그대로 돌려줌
|
||||
// (표시값을 다시 읽으면 옛 소수 값이 뭉개져 옵션 · 메모만 고쳐도 측점이 옮겨 저장됨)
|
||||
let written: { chainageM: number; station: string; remainder: string } | null = null;
|
||||
return {
|
||||
wrap,
|
||||
@@ -196,7 +196,7 @@ export function stationFields(
|
||||
const valid =
|
||||
Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0;
|
||||
markInvalid(!valid);
|
||||
// 친 값은 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 안 고친 칸은 위에서 옛 값 그대로
|
||||
// 친 값은 정수 m(반올림 · 167) — 안 고친 칸은 위에서 옛 값 그대로
|
||||
return valid ? snapStructureStation(stationNo * intervalM + rest) : null;
|
||||
},
|
||||
write(chainageM, intervalM) {
|
||||
@@ -208,7 +208,7 @@ export function stationFields(
|
||||
}
|
||||
const parts = chainageToStation(chainageM, intervalM);
|
||||
station.value = String(parts.station);
|
||||
remainder.value = parts.remainder.toFixed(1);
|
||||
remainder.value = parts.remainder.toFixed(0); // 측점 + 거리 = 정수 m(167)
|
||||
written = { chainageM, station: station.value, remainder: remainder.value };
|
||||
},
|
||||
clearInvalid: () => markInvalid(false),
|
||||
|
||||
@@ -47,7 +47,11 @@ import { pickable, showsAnchor } from "./B05_Profile_UI_Structures_Define";
|
||||
import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection";
|
||||
import { movedStructure, snapDock, snapStructureStation } from "./B05_Profile_UI_Structures_Snap";
|
||||
import {
|
||||
movedStructure,
|
||||
snapOldStations,
|
||||
snapStructureStation,
|
||||
} from "./B05_Profile_UI_Structures_Snap";
|
||||
|
||||
/** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 —
|
||||
* 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */
|
||||
@@ -177,9 +181,9 @@ export function createStructuresSection(
|
||||
let editingId: string | null = null;
|
||||
/** 목록에서 고른 계곡 통과 시설(누가거리 키). 삭제 버튼이 이쪽으로 동작한다. */
|
||||
let selectedPipeChainage: number | null = null;
|
||||
/** 임시 배치 중인 A군 시설의 누가거리(2026-08-18) — 측점을 넣는 순간 관 지점에
|
||||
* 미리 넣어 세부유역·설계유량 미리보기를 얻고, [추가]로만 확정한다. 취소(삭제·
|
||||
* 리셋·다른 항목 선택·종류 변경)하면 관을 물려 추가 직전 상태로 되돌린다. */
|
||||
/** 임시 배치 중인 A군 시설의 누가거리 — 우클릭 「추가」만 관 지점에 미리 넣음(측점 입력은 안 넣음 · 291-1)
|
||||
* · [추가]로 확정 · 취소(삭제 · 리셋 · 다른 항목 선택 · 종류 변경)하면 관을 물려 추가 직전 상태로.
|
||||
* 새 세월교 · 물넘이 빈 월류 높이는 [추가] 뒤 재계산 유량으로 채움(126-1 `fordWaits`). */
|
||||
let tempPipeChainage: number | null = null;
|
||||
/** 위치 입력이 "확정"됐는지 — 측점번호+잔여거리를 다 받고 입력군을 빠져나간
|
||||
* 순간(또는 폼에 위치를 실어 연 순간)에만 true. 옵션 활성화 시점도 임시 배치와
|
||||
@@ -290,11 +294,9 @@ export function createStructuresSection(
|
||||
facilityOptions.root.classList.toggle("is-locked", locked);
|
||||
}
|
||||
|
||||
/** A군이면 기준 측점(측점번호+잔여거리)으로 임시 배치를 만들거나 옮긴다
|
||||
* (세부유역 미리보기, 2026-08-18). 측점 두 칸을 **다 쓰고 빠져나온 뒤**에만
|
||||
* 부른다 — 측점번호만 넣고 거리 칸으로 넘어가는 순간 나가면 재계산 동기화가
|
||||
* 입력을 끊는다(2026-08-18 사용자 보고). */
|
||||
function commitTempPipe(): void {
|
||||
/** A군 임시 배치 — `create`(우클릭 「추가」)면 만들고, 있으면 기준 측점으로 옮김 · 측점 입력만으론
|
||||
* 안 만듦(신규는 [추가] 뒤에만 생김 · 291-1). 측점 두 칸을 다 쓰고 빠져나온 뒤에만 부름. */
|
||||
function commitTempPipe(create = false): void {
|
||||
const type = currentType();
|
||||
if (!type?.managed_by) return;
|
||||
const anchor = anchorFields.read(interval(), false);
|
||||
@@ -302,6 +304,7 @@ export function createStructuresSection(
|
||||
if (anchor === null || (selectedPipeChainage !== null && tempPipeChainage === null)) return;
|
||||
const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility };
|
||||
if (tempPipeChainage === null) {
|
||||
if (!create) return;
|
||||
tempPipeChainage = anchor;
|
||||
callbacks.onPipeAdd(anchor, attributes);
|
||||
} else if (Math.abs(tempPipeChainage - anchor) > 0.005) {
|
||||
@@ -321,7 +324,7 @@ export function createStructuresSection(
|
||||
}
|
||||
|
||||
/** 위치 입력 확정 — 측점번호+잔여거리를 다 받고 입력군을 빠져나온 순간.
|
||||
* 옵션 활성화와 A군 임시 배치가 이 한 지점에서 같이 나간다(2026-08-18). */
|
||||
* 옵션 활성화(와 우클릭으로 만든 A군 임시 배치 옮기기)가 이 한 지점에서 같이 나간다. */
|
||||
function commitPosition(): void {
|
||||
positionConfirmed = hasPosition();
|
||||
commitTempPipe();
|
||||
@@ -542,13 +545,15 @@ export function createStructuresSection(
|
||||
return {
|
||||
root,
|
||||
facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15)
|
||||
listRoot: snapDock(list, {
|
||||
structures,
|
||||
pipes: () => pipeFacilities,
|
||||
emit,
|
||||
movePipe: (from, to, attributes) => callbacks.onPipeUpdate(from, to, attributes),
|
||||
closeForm: () => resetForm(),
|
||||
}),
|
||||
listRoot: list,
|
||||
snapOld: () =>
|
||||
snapOldStations({
|
||||
structures,
|
||||
pipes: () => pipeFacilities,
|
||||
emit,
|
||||
movePipe: (from, to, attributes) => callbacks.onPipeUpdate(from, to, attributes),
|
||||
closeForm: () => resetForm(),
|
||||
}),
|
||||
hasSelection: () => editingId !== null || selectedPipeChainage !== null,
|
||||
pickGroups: () => pickGroups(types, canPick), // 우클릭 메뉴 = 이 드롭다운 목록(129-1)
|
||||
setTypes(next, pick = () => true) {
|
||||
@@ -667,7 +672,7 @@ export function createStructuresSection(
|
||||
if (addsAtOnce(type, optionInputs))
|
||||
return void finishAdd({ type, anchorM: chainageM, structures, commit, pick: this });
|
||||
// 임시 배치도 즉시 진행(A군).
|
||||
if (type.managed_by) commitTempPipe();
|
||||
if (type.managed_by) commitTempPipe(true);
|
||||
syncButtons();
|
||||
renderList();
|
||||
root.scrollIntoView({ block: "nearest" });
|
||||
|
||||
@@ -22,10 +22,7 @@ import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures
|
||||
import { asRoad } from "./B05_Profile_UI_Structures_Define";
|
||||
import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import {
|
||||
isSnappedStation,
|
||||
snapStructureStation,
|
||||
} from "../common_util/common_util_structure_station";
|
||||
import { snapStructureStation } from "../common_util/common_util_structure_station";
|
||||
import {
|
||||
readGroupItems,
|
||||
readSummaryItem,
|
||||
@@ -309,11 +306,9 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
beforeInput?.focus();
|
||||
return;
|
||||
}
|
||||
// 시작 · 끝도 정수 m(234-1) — 안 고친 옛 소수 기준이면 옛 셈 그대로([정수 맞춤] 몫)
|
||||
const fit = (value: number): number =>
|
||||
isSnappedStation(anchor as number) ? snapStructureStation(value) : value;
|
||||
start = fit(Math.max(anchor - before, 0));
|
||||
end = Math.max(fit(start + length), start + Math.min(length, 1));
|
||||
// 시작 · 끝도 정수 m · 끝은 1 m 이상(234-1 · 167)
|
||||
start = snapStructureStation(Math.max(anchor - before, 0));
|
||||
end = Math.max(snapStructureStation(start + length), start + 1);
|
||||
} else if (placement === "interval") {
|
||||
start = ctx.startFields.read(step, true);
|
||||
end = ctx.endFields.read(step, true);
|
||||
|
||||
@@ -169,8 +169,8 @@ export function bindStructuresEvents(ctx: StructuresEventContext): {
|
||||
input.addEventListener("change", ctx.handleStationInput),
|
||||
),
|
||||
);
|
||||
// 측점번호+잔여거리가 **둘 다 확정된 순간** 임시 배치·옵션 활성화가 바로 나간다
|
||||
// (2026-08-18 사용자 지시 — 입력군을 떠나기 전이라도). 잔여거리 값이 확정되면
|
||||
// 측점번호+잔여거리가 **둘 다 확정된 순간** 옵션 활성화가 바로 나간다(입력군을 떠나기
|
||||
// 전이라도 · 새 구조물은 [추가] 뒤에만 생김 · 291-1). 잔여거리 값이 확정되면
|
||||
// 즉시, 측점번호 수정은 잔여거리가 이미 있을 때만 즉시.
|
||||
ctx.anchorFields.remainder.addEventListener("change", () => ctx.commitPosition());
|
||||
ctx.anchorFields.station.addEventListener("change", () => {
|
||||
@@ -207,7 +207,7 @@ export function bindStructuresEvents(ctx: StructuresEventContext): {
|
||||
|
||||
ctx.positionRow.addEventListener("keydown", (event) => {
|
||||
// Enter = 입력 확정 — 포커스를 빼서 focusout 경로 하나로 처리한다(측점번호+
|
||||
// 잔여거리 합산값이 그대로 임시 배치 좌표가 된다).
|
||||
// 잔여거리 합산값이 그대로 위치가 된다).
|
||||
if (event.key === "Enter" && document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
@@ -154,6 +154,8 @@ export interface StructuresSection {
|
||||
/** 배치된 구조물 목록(<ul>) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다
|
||||
* (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */
|
||||
listRoot: HTMLElement;
|
||||
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 옮긴다(종단 보기를 열 때 한 번 · 167) — 옮긴 수. */
|
||||
snapOld: () => number;
|
||||
/** 서버에서 받은 타입 목록을 채운다(최초 1회). 이름·폼은 넘긴 종류 전부로 풀고,
|
||||
* 고르기 목록엔 `pick` 이 고른 종류만 보임(빼면 전부). */
|
||||
setTypes: (types: StructureType[], pick?: (type: StructureType) => boolean) => void;
|
||||
|
||||
@@ -63,8 +63,10 @@ export async function splitPavementRange(
|
||||
const segments: Span[] = [];
|
||||
let cursor = start;
|
||||
for (const ford of overlaps) {
|
||||
if (ford.start - cursor > EPS) segments.push({ start: cursor, end: ford.start });
|
||||
cursor = ford.end;
|
||||
// 경계도 정수 m(167) — 물넘이를 안 덮게 앞 구간 끝은 내림 · 뒤 구간 시작은 올림
|
||||
const before = Math.floor(ford.start + EPS);
|
||||
if (before - cursor > EPS) segments.push({ start: cursor, end: before });
|
||||
cursor = Math.ceil(ford.end - EPS);
|
||||
}
|
||||
if (end - cursor > EPS) segments.push({ start: cursor, end });
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Structures_Snap.ts
|
||||
* 구조물 기준 측점 정수 맞춤(234-1) — 끌기 · 방향키로 옮기는 구조물 한 건의 모양과
|
||||
* 기존 소수 자료를 옮기는 [정수 맞춤] 단추(좌측 구조물 목록 머리 · B05 · B06 한 벌).
|
||||
* 구조물 기준 측점 정수 맞춤(234-1 · 272) — 끌기 · 방향키로 옮기는 구조물 한 건의 모양과
|
||||
* 옛 소수 자료를 정수 m 로 옮기는 자동 맞춤(종단 보기를 열 때 한 번 · 단추 없음 · 요구 167).
|
||||
*
|
||||
* 맞춤 규칙은 공용 `common_util_structure_station`(파이썬 짝) 한 벌. 단추는 확인 창 뒤
|
||||
* 구조물은 한 번에, 관은 하나씩 폼 수정과 같은 길(`onPipeUpdate`)로 옮긴다 — 계획고 편집
|
||||
* 따라가기(213-2) · 시설 속성 이어받기가 손으로 옮길 때와 같다. 정본은 [저장]에서 나간다.
|
||||
* 맞춤 규칙은 공용 `common_util_structure_station`(파이썬 짝) 한 벌. 구조물은 한 번에, 관은 하나씩
|
||||
* 폼 수정과 같은 길(`onPipeUpdate`)로 옮긴다 — 계획고 편집 따라가기(213-2) · 시설 속성 이어받기가
|
||||
* 손으로 옮길 때와 같다. 정본은 [저장]에서 나간다(알림 한 줄).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
isSnappedStation,
|
||||
snapStructureStation,
|
||||
@@ -26,8 +26,7 @@ export function movedStructure(target: StructureInstance, toChainageM: number):
|
||||
if (target.placement !== "interval") return { ...target, chainage_m: anchor };
|
||||
const length = (target.end_m ?? 0) - (target.start_m ?? 0);
|
||||
const start = snapStructureStation(anchor - (structureAnchorM(target) - (target.start_m ?? 0)));
|
||||
const snappedEnd = snapStructureStation(start + length);
|
||||
const end = snappedEnd > start ? snappedEnd : start + length;
|
||||
const end = Math.max(snapStructureStation(start + length), start + 1); // 끝도 정수 · 1 m 이상(167)
|
||||
return {
|
||||
...target,
|
||||
chainage_m: Math.min(Math.max(anchor, start), end),
|
||||
@@ -55,7 +54,8 @@ export const structureOffGrid = (structure: StructureInstance): boolean =>
|
||||
export interface SnapPlan {
|
||||
/** 목록 자리 · 옮긴 모양. */
|
||||
structures: Array<{ index: number; next: StructureInstance }>;
|
||||
pipes: Array<{ from: number; to: number; pipe: PipeFacilityItem }>;
|
||||
/** 옛 자리 → 새 자리 · 시작 · 끝도 맞춘 속성(독립 기슭막이 구간). */
|
||||
pipes: Array<{ from: number; to: number; attributes: FacilityAttributes }>;
|
||||
/** 맞춘 자리에 다른 관이 있어 그대로 둔 관 수. */
|
||||
skipped: number;
|
||||
}
|
||||
@@ -73,16 +73,20 @@ export function snapPlan(
|
||||
const taken = pipes.filter((pipe) => !offGrid(pipe.chainage_m)).map((pipe) => pipe.chainage_m);
|
||||
const plan: SnapPlan = { structures: moved, pipes: [], skipped: 0 };
|
||||
[...pipes]
|
||||
.filter((pipe) => offGrid(pipe.chainage_m))
|
||||
.filter((pipe) => offGrid(pipe.chainage_m) || offGrid(pipe.start_m) || offGrid(pipe.end_m))
|
||||
.sort((left, right) => left.chainage_m - right.chainage_m)
|
||||
.forEach((pipe) => {
|
||||
const to = snapStructureStation(pipe.chainage_m);
|
||||
if (taken.some((other) => Math.abs(other - to) < PIPE_MATCH_M)) {
|
||||
if (to !== pipe.chainage_m && taken.some((other) => Math.abs(other - to) < PIPE_MATCH_M)) {
|
||||
plan.skipped += 1;
|
||||
return;
|
||||
}
|
||||
taken.push(to);
|
||||
plan.pipes.push({ from: pipe.chainage_m, to, pipe });
|
||||
const attributes = pipeAttributes(pipe);
|
||||
if (attributes.start_m !== undefined)
|
||||
attributes.start_m = snapStructureStation(attributes.start_m);
|
||||
if (attributes.end_m !== undefined) attributes.end_m = snapStructureStation(attributes.end_m);
|
||||
plan.pipes.push({ from: pipe.chainage_m, to, attributes });
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
@@ -97,7 +101,7 @@ export const pipeAttributes = (pipe: PipeFacilityItem): FacilityAttributes => ({
|
||||
group_items: pipe.group_items ?? null,
|
||||
});
|
||||
|
||||
export interface SnapDockContext {
|
||||
export interface SnapContext {
|
||||
/** 패널이 쥔 구조물 배열(참조) — 옮긴 것을 제자리에 갈아 끼움. */
|
||||
structures: StructureInstance[];
|
||||
pipes: () => ReadonlyArray<PipeFacilityItem>;
|
||||
@@ -109,52 +113,23 @@ export interface SnapDockContext {
|
||||
closeForm: () => void;
|
||||
}
|
||||
|
||||
/** [정수 맞춤] 단추를 얹은 목록 뿌리 — 아래 고정 영역에 목록 대신 놓인다. */
|
||||
export function snapDock(list: HTMLElement, ctx: SnapDockContext): HTMLElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b05-route__irregular-btn b05-structure__snap";
|
||||
button.textContent = "정수 맞춤";
|
||||
button.title = "소수 기준 측점(관 · 구조물)을 정수 m 로 — 일반 측점 1 m 안이면 그 측점";
|
||||
button.addEventListener("click", () => void runSnap(ctx));
|
||||
const head = document.createElement("div");
|
||||
head.className = "b05-structure__snap-row";
|
||||
head.append(button);
|
||||
const dock = document.createElement("div");
|
||||
dock.className = "b05-structure__list-dock";
|
||||
dock.append(head, list);
|
||||
return dock;
|
||||
}
|
||||
|
||||
export async function runSnap(ctx: SnapDockContext): Promise<number> {
|
||||
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 옮긴다(요구 167 · 확인 창 없이) — 옮긴 수. */
|
||||
export function snapOldStations(ctx: SnapContext): number {
|
||||
const plan = snapPlan(ctx.structures, ctx.pipes());
|
||||
const count = plan.structures.length + plan.pipes.length;
|
||||
if (!count) {
|
||||
if (count) {
|
||||
ctx.closeForm();
|
||||
if (plan.structures.length) {
|
||||
plan.structures.forEach(({ index, next }) => (ctx.structures[index] = next));
|
||||
ctx.emit();
|
||||
}
|
||||
plan.pipes.forEach(({ from, to, attributes }) => ctx.movePipe(from, to, attributes));
|
||||
}
|
||||
if (count || plan.skipped)
|
||||
showToast(
|
||||
plan.skipped
|
||||
? `소수 측점 관 ${plan.skipped}건은 맞출 자리에 다른 관이 있어 그대로 둡니다.`
|
||||
: "소수 기준 측점이 없습니다.",
|
||||
"info",
|
||||
(count ? `옛 소수 측점 ${count}건을 정수 m 로 옮겼습니다 — [저장]하면 남습니다.` : "") +
|
||||
(plan.skipped ? ` 맞출 자리에 다른 관이 있는 관 ${plan.skipped}건은 그대로 둡니다.` : ""),
|
||||
plan.skipped ? "info" : "success",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
const ok = await showConfirmDialog(
|
||||
`소수 기준 측점 ${count}건(관 ${plan.pipes.length} · 구조물 ${plan.structures.length})을 ` +
|
||||
"정수 m(일반 측점 1 m 안이면 그 측점)로 옮깁니다. 계획고 편집도 따라가며, [저장]을 눌러야 남습니다.",
|
||||
"정수 맞춤",
|
||||
);
|
||||
if (!ok) return 0;
|
||||
ctx.closeForm();
|
||||
if (plan.structures.length) {
|
||||
plan.structures.forEach(({ index, next }) => (ctx.structures[index] = next));
|
||||
ctx.emit();
|
||||
}
|
||||
plan.pipes.forEach(({ from, to, pipe }) => ctx.movePipe(from, to, pipeAttributes(pipe)));
|
||||
showToast(
|
||||
`정수 맞춤 ${count}건` +
|
||||
(plan.skipped ? ` · 자리 겹친 관 ${plan.skipped}건은 그대로` : "") +
|
||||
" — [저장]을 눌러 남기세요.",
|
||||
"success",
|
||||
);
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Structures_Summary_Row.ts
|
||||
* 구조물 폼의 「집계 항목」 줄 — 이름(없으면 「미지정」) · [고르기] · [해제] (PLAN 21-4).
|
||||
* 구조물 폼의 「집계 항목」 줄 — 이름(없으면 「미지정」) · [해제] (PLAN 21-4) · 이름 칸을 누르면 고르기(280-1).
|
||||
*
|
||||
* B05 · B06 · 관 계열 하위 폼이 같은 줄을 쓴다(옵션 · 서브폼 아래 한 줄). 줄은 값만 들고 있고
|
||||
* 저장은 패널이 한다 — [고르기]가 모달(21-5 `openItemPicker`)을 부르고 돌려받은 참조를 `set` 한다.
|
||||
* 저장은 패널이 한다 — 이름 칸(누름 · Enter · Space)이 모달(21-5 `openItemPicker`)을 부르고 돌려받은 참조를 `set` 한다.
|
||||
* 키(SS…)는 앞에 내세우지 않고 툴팁에만 둔다(PLAN 21-0).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -18,29 +18,26 @@ export interface SummaryRow {
|
||||
get: () => SummaryItem | null;
|
||||
/** 값만 바꿔 그림 — 저장 · 알림은 안 함(폼 불러오기 · 모달 결과가 씀). */
|
||||
set: (item: SummaryItem | null) => void;
|
||||
/** [고르기] · [해제] 배선 — 패널이 한 번 부른다. */
|
||||
/** 고르기(이름 칸) · [해제] 배선 — 패널이 한 번 부른다. */
|
||||
bind: (handlers: { pick: () => void; clear: () => void }) => void;
|
||||
}
|
||||
|
||||
const PICK_TIP = "눌러서 고르기";
|
||||
|
||||
export function buildSummaryRow(): SummaryRow {
|
||||
// 옵션 칸과 같은 2행 꼴(54-17) — 위 제목(`b05-route__field > span`) · 아래 [이름 칸][선택][해제]
|
||||
// 옵션 칸과 같은 2행 꼴(54-17) — 위 제목(`b05-route__field > span`) · 아래 [이름 칸][해제]
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-route__field b05-structure__summary";
|
||||
root.dataset.b05Summary = "row";
|
||||
const label = document.createElement("span");
|
||||
label.className = "b05-structure__summary-label";
|
||||
label.textContent = "구조물 조합";
|
||||
// 이름 = 읽기 전용 입력 칸 — 옵션 입력 칸과 글꼴 · 크기 · 칸 모양이 같음
|
||||
// 이름 = 읽기 전용 입력 칸 — 옵션 입력 칸과 글꼴 · 크기 · 칸 모양이 같음 · 누르면 고르기(280-1)
|
||||
const name = document.createElement("input");
|
||||
name.type = "text";
|
||||
name.readOnly = true;
|
||||
name.tabIndex = -1;
|
||||
name.className = "b05-structure__summary-name";
|
||||
const pick = document.createElement("button");
|
||||
pick.type = "button";
|
||||
pick.className = "b05-route__irregular-btn";
|
||||
pick.textContent = "선택";
|
||||
pick.dataset.b05Summary = "pick";
|
||||
name.dataset.b05Summary = "pick";
|
||||
const clear = document.createElement("button");
|
||||
clear.type = "button";
|
||||
clear.className = "b05-route__irregular-btn";
|
||||
@@ -48,14 +45,14 @@ export function buildSummaryRow(): SummaryRow {
|
||||
clear.dataset.b05Summary = "clear";
|
||||
const line = document.createElement("div");
|
||||
line.className = "b05-route__irregular-actions";
|
||||
line.append(name, pick, clear);
|
||||
line.append(name, clear);
|
||||
root.append(label, line);
|
||||
|
||||
let current: SummaryItem | null = null;
|
||||
const set = (item: SummaryItem | null): void => {
|
||||
current = item;
|
||||
name.value = summaryLabel(item);
|
||||
name.title = summaryTooltip(item);
|
||||
name.title = `${summaryTooltip(item)} · ${PICK_TIP}`;
|
||||
name.classList.toggle("is-assigned", item !== null);
|
||||
clear.disabled = item === null;
|
||||
};
|
||||
@@ -66,7 +63,12 @@ export function buildSummaryRow(): SummaryRow {
|
||||
get: () => current,
|
||||
set,
|
||||
bind(handlers) {
|
||||
pick.addEventListener("click", () => handlers.pick());
|
||||
name.addEventListener("click", () => handlers.pick());
|
||||
name.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
handlers.pick();
|
||||
});
|
||||
clear.addEventListener("click", () => handlers.clear());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -357,11 +357,11 @@
|
||||
|
||||
/* ── 조작 컨트롤 높이 템플릿(2026-08-29 사용자 지시) ──────────────────────
|
||||
* 「구조물 배치」 섹션 안의 입력·셀렉트·스텝 묶음·이식 행이 **한 높이**를 쓴다.
|
||||
* 값을 바꾸려면 이 변수 하나만 고치면 된다. 기준은 조정창에서 옮겨 온 행의
|
||||
* 값은 theme `--panel-control-h` 한 곳(좌측 패널 공용 양식이 함께 씀 · 269). 기준은 조정창에서 옮겨 온 행의
|
||||
* 컨트롤 높이(26px)다 — 사용자가 그 크기를 기준으로 정했다. */
|
||||
.b05-structure-section,
|
||||
.b05-drainage__facility {
|
||||
--b05-control-h: 26px;
|
||||
--b05-control-h: var(--panel-control-h);
|
||||
}
|
||||
|
||||
.b05-structure-section input:not([type="checkbox"]),
|
||||
@@ -409,7 +409,7 @@
|
||||
/* 제목(라벨)과 항목 사이 간격도 한 변수로 묶는다(2026-08-29 사용자 지시) — 기준은
|
||||
조정창에서 옮겨 온 행(2px·작은 글자). 측점 행은 가로 배치라 열 간격을 그대로 둔다. */
|
||||
.b05-structure-section {
|
||||
--b05-label-gap: 2px;
|
||||
--b05-label-gap: var(--panel-label-gap);
|
||||
}
|
||||
|
||||
.b05-structure-section .b05-route__field {
|
||||
@@ -436,7 +436,8 @@
|
||||
}
|
||||
|
||||
/* 구조물 조합 잇기 줄(PLAN 21-4 · 54-17) — 옵션 칸 꼴(`b05-route__field`) 2행 · 아래 줄은
|
||||
* [이름 칸][선택][해제] · 이름 칸이 남는 폭을 먹고 말줄임 · 키는 툴팁에만. 지정되면 성공색. */
|
||||
* [이름 칸][해제] · 이름 칸이 남는 폭을 먹고 말줄임 · 키는 툴팁에만. 지정되면 성공색.
|
||||
* 이름 칸 = 고르기 단추 노릇(280-1) — 손 커서 · 키보드 초점 테두리. */
|
||||
.b05-structure__summary[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@@ -445,6 +446,12 @@
|
||||
flex: 1 1 auto;
|
||||
align-self: center;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-structure__summary-name:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* 연결됨 — 연한 초록 배경 + 초록 테두리 · 글은 본문색 그대로(밝음 · 어두움 같은 토큰). */
|
||||
@@ -453,10 +460,10 @@
|
||||
background: color-mix(in srgb, var(--color-success) 18%, var(--color-surface));
|
||||
}
|
||||
|
||||
/* 미연결 — 연한 노랑 배경 + 노랑 테두리(경고 · 밝음 · 어두움 같은 토큰). */
|
||||
/* 미연결 — 옅은 회색 배경 + 회색 테두리(구조물 리스트 램프 색 · 밝음 · 어두움 같은 토큰 · 258-3). */
|
||||
.b05-structure__summary-name:not(.is-assigned) {
|
||||
border-color: var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface));
|
||||
border-color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, var(--color-surface));
|
||||
}
|
||||
|
||||
.b05-structure__summary .b05-route__irregular-btn {
|
||||
@@ -651,14 +658,10 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* [정수 맞춤] 단추 줄 — 구조물 목록 머리(234-1) */
|
||||
.b05-structure__snap-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b05-structure__snap {
|
||||
flex: 0 0 auto;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
/* 월류 높이 안내(287-3) — 필요 수심 · 설계유량 없음 */
|
||||
.b05-structure__flow-hint {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@@ -497,7 +497,7 @@
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
|
||||
}
|
||||
|
||||
/* 도구줄 오른쪽 묶음 — 가로 배율 조작구 + 오른쪽 판 켬끔 단추(두 보기 같은 자리, 계획서 24-2c). */
|
||||
/* 도구줄 오른쪽 묶음 — 가로 배율 조작구(두 보기 같은 자리, 계획서 24-2c). */
|
||||
.b05-route-profile__trailing {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
@@ -506,11 +506,6 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.b05-route-profile__trailing > .b05-drainage__toggle {
|
||||
padding: var(--btn-bar-padding);
|
||||
font-size: var(--btn-bar-font-size);
|
||||
}
|
||||
|
||||
.b05-profile__zoom-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
CrossDesign,
|
||||
CrossDesignRequest,
|
||||
CrossDesignResponse,
|
||||
CrossSection,
|
||||
CrossSectionPatch,
|
||||
SectionConfirmResponse,
|
||||
SectionContextResponse,
|
||||
@@ -120,6 +121,21 @@ export async function getSections(
|
||||
}
|
||||
|
||||
/** 경로의 SVG 렌더링용 종단·횡단 원시 샘플을 조회한다. */
|
||||
/**
|
||||
* 초안 자리(놓거나 옮긴 관 · 구조물)의 횡단을 미리 떠 받는다 — 서버는 아무것도 안 씀 · 캐시에만 얹음
|
||||
* ([저장] 때 서버가 같은 계산으로 정본에 씀 · 273-2).
|
||||
*/
|
||||
export async function previewStructureSections(
|
||||
projectId: string,
|
||||
chainages: number[],
|
||||
): Promise<CrossSection[]> {
|
||||
const result = await requestJson<{ cross_sections?: CrossSection[] }>(
|
||||
`/projects/${projectId}/section/preview-stations`,
|
||||
{ method: "POST", body: JSON.stringify({ chainages }) },
|
||||
);
|
||||
return result.cross_sections ?? [];
|
||||
}
|
||||
|
||||
export async function fetchSectionDetail(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
|
||||
@@ -61,6 +61,7 @@ export type {
|
||||
import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement";
|
||||
import type { RevetmentSpec } from "./B06_Section_UI_Cross_Revetment";
|
||||
import type { PavementEntry } from "@util/common_util_structure_pavement";
|
||||
import type { UnderdrainSpec } from "@util/common_util_structure_underdrain";
|
||||
export type { FordPavementSpec, RevetmentSpec };
|
||||
|
||||
export interface SectionContextResponse {
|
||||
@@ -118,6 +119,8 @@ export interface SectionStation {
|
||||
kind: "bp" | "ep" | "regular" | "irregular";
|
||||
/** 비정규 측점의 구조물 설명(백엔드가 확정 시 부여). 복귀 시 사이드바 목록 복원에 쓴다. */
|
||||
structure?: string;
|
||||
/** 구조물 측점이 얹힌 격자 자리(같은 m 면 측점 하나 · 구조물이 위 · 272) — 구조물이 떠나면 일반 측점. */
|
||||
grid?: boolean;
|
||||
center_z: number | null;
|
||||
azimuth_deg: number | null;
|
||||
/** 평면 곡선반경(m). 직선이거나 잴 수 없으면 null — 곡선부 확폭·법정 최소반경 판정용. */
|
||||
@@ -226,6 +229,8 @@ export interface CulvertSideSpec {
|
||||
export interface ChuteSpec {
|
||||
structure: string;
|
||||
thickness_m: number;
|
||||
/** 사면 따라 그릴 길이(m · 물넘이 포장 286-3) — 그림 전용 · 없으면 원지반까지. */
|
||||
length_m?: number;
|
||||
}
|
||||
|
||||
/** 배수관 측점의 세트(배관·기슭막이·보호공) 제원 — 횡단 카드 오버레이 입력. */
|
||||
@@ -316,6 +321,8 @@ export interface BoxSet {
|
||||
|
||||
export interface CrossSection extends SectionStation {
|
||||
samples: SectionSample[];
|
||||
/** 초안 자리를 서버가 미리 떠 준 횡단 — 캐시에만 있음 · [저장]하면 서버가 정본으로 씀(273-2). */
|
||||
preview?: boolean;
|
||||
/** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */
|
||||
design?: CrossDesign;
|
||||
/** 배수관 측점의 세트 제원(있을 때만). `pipe_points.json` 정본 + 레지스트리 기본값. */
|
||||
@@ -330,6 +337,8 @@ export interface CrossSection extends SectionStation {
|
||||
revetment?: RevetmentSpec;
|
||||
/** 노면공(G군) 포장 — 범위 안 측점마다(88). 두께 · 무늬 · 확폭 켬. */
|
||||
pavement?: PavementEntry[];
|
||||
/** 맹암거 — 기준 측점(288-1). 높이 · 경사 · 측구 없음 · 노면 기울임 · 돌 무늬 형상. */
|
||||
underdrain?: UnderdrainSpec;
|
||||
}
|
||||
|
||||
export interface SectionDetailResponse {
|
||||
@@ -474,6 +483,8 @@ export interface CrossDesign {
|
||||
pavement_suggested?: boolean;
|
||||
/** 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸). */
|
||||
structure_note?: string;
|
||||
/** 표 위 CAD 칸(260-1) — CAD 저장 꼴 · 있으면 B07 이 글 대신 이 엔티티를 표 위에 놓음. */
|
||||
structure_note_cad?: CrossNoteCad;
|
||||
/** [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 서버가 다시 셈). */
|
||||
detail_table?: CrossDetailTable;
|
||||
/** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */
|
||||
@@ -487,6 +498,11 @@ export interface CrossDesign {
|
||||
right: { offset_m: number; elevation_m: number };
|
||||
};
|
||||
design_elevation_m: number;
|
||||
/** 물넘이포장 · 맹암거 자리(286-2 · 287-4) — 측구 없음 · 노면 = 지대 낮은 쪽 한 경사(`tilt_pct`). */
|
||||
tilted?: boolean;
|
||||
tilt_pct?: number;
|
||||
/** 물넘이 낮은 쪽 늘인 노면 끝(294-3) — 그 쪽 절토 사면 대신 노면 · 콘크리트가 원지반까지. */
|
||||
tilt_reach?: { side: "left" | "right"; offset_m: number; elevation_m: number };
|
||||
cut_area_m2: number;
|
||||
/**
|
||||
* 절토 내역(합 = `cut_area_m2`). 지표면~암반 경계선이 토사, 그 아래가 암이다.
|
||||
@@ -625,6 +641,8 @@ export interface CrossSectionPatch {
|
||||
cut_rock_area_m2?: number;
|
||||
/** 측점 구조물 글(138-4) — 빈 글 = 지움. */
|
||||
structure_note?: string;
|
||||
/** 표 위 CAD 칸(260-1) — 세션 초안 그대로. */
|
||||
structure_note_cad?: CrossNoteCad;
|
||||
/** [상세] 표 손질(179-4) — 세션 초안 그대로. */
|
||||
detail_table?: CrossDetailTable;
|
||||
}
|
||||
@@ -638,7 +656,7 @@ export type CrossCellSource =
|
||||
|
||||
/** [상세] 고른 선 조각(209-1) — 어느 선 · 두 끝 [offset, 표고](m). */
|
||||
export interface CrossPickPiece {
|
||||
line: "design" | "ground" | "spoil" | "structure";
|
||||
line: "design" | "ground" | "spoil" | "structure" | "rock";
|
||||
start: [number, number];
|
||||
end: [number, number];
|
||||
}
|
||||
@@ -649,3 +667,9 @@ export interface CrossDetailTable {
|
||||
rows: Array<{ id: string; after: string | null; grouped: boolean; labels: string[] }>;
|
||||
columns: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
/** [상세] 표 위 CAD 칸(260-1) — CAD 저장 꼴(`cad_host` · 엔티티 · 층) 그대로. */
|
||||
export interface CrossNoteCad {
|
||||
entities: Record<string, unknown>[];
|
||||
layers?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ export const USER_TOUCHED_KEYS = [
|
||||
"cut_slope_ratio_user",
|
||||
// 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
|
||||
"structure_note",
|
||||
// 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님.
|
||||
"structure_note_cad",
|
||||
// [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈)
|
||||
"detail_table",
|
||||
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김
|
||||
@@ -357,6 +359,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
choice?.ditch_type ??
|
||||
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
|
||||
paved: pavedBySpecs(section),
|
||||
// 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원)
|
||||
tilted: Boolean(section.ford_pavement || section.underdrain),
|
||||
tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null,
|
||||
standard,
|
||||
berm: bermAt(section.chainage_m, design),
|
||||
rockBoundaryOffsetM,
|
||||
|
||||
@@ -251,7 +251,8 @@ def _side_spec(
|
||||
def _chute(values: dict[str, Any], head: str = "chute_add") -> dict[str, Any] | None:
|
||||
"""도수로 · 돌붙임(90-2) — 첫 상자(`{head}_{n}_*` · 물넘이 포장 = `ford_chute` · 136-2) 구조 ·
|
||||
두께(cm → m · 빈 값 30 cm) · 구조를 안 고르면 None. 횡단이 유출 성토사면을 따라 띠로 그림.
|
||||
TS 짝 `chute`."""
|
||||
길이(`{head}_{n}_length_m` · 286-3) = 사면 따라 그릴 길이 — 그림 전용(수량 · 내역에 안 씀) ·
|
||||
비면 원지반까지. TS 짝 `chute`."""
|
||||
pattern = re.compile(rf"^{head}_(\d+)_structure$")
|
||||
picked = sorted(
|
||||
(int(hit.group(1)), str(value).strip())
|
||||
@@ -263,7 +264,11 @@ def _chute(values: dict[str, Any], head: str = "chute_add") -> dict[str, Any] |
|
||||
n, structure = picked[0]
|
||||
cm = _number(values.get(f"{head}_{n}_thickness"), None)
|
||||
cm = cm if cm and cm > 0 else CHUTE_THICKNESS_CM
|
||||
return {"structure": structure, "thickness_m": round(cm / 100.0, 3)}
|
||||
found = {"structure": structure, "thickness_m": round(cm / 100.0, 3)}
|
||||
length = _number(values.get(f"{head}_{n}_length_m"), None)
|
||||
if length and length > 0:
|
||||
found["length_m"] = round(length, 3)
|
||||
return found
|
||||
|
||||
|
||||
def _with_chute(
|
||||
@@ -508,11 +513,10 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""물넘이포장 1개소 — 노면을 월류 폭만큼, 노선 중심에서 월류 높이만큼 판 자리.
|
||||
"""물넘이포장 1개소 — 월류 폭 범위의 노면 = 콘크리트 윗면(287-4).
|
||||
|
||||
구조물을 얹는 것이 아니라 **계획고를 파낸 노면**이라 다른 세트와 형상이 다르다.
|
||||
바닥은 유입(상류) 쪽이 높고 유출 쪽이 낮게 기운다 — 경사를 사용자가 비워 두면
|
||||
화면이 노면 일반 기울기로 채운다(2026-08-28 사용자 확정). 파임 폭은 노견까지 전폭.
|
||||
설계 노면을 경사각도(`slope_pct`)로 지대 낮은 쪽 한 경사로 기울임(`tilt_ranges` →
|
||||
`compute_cross_design(tilted=…)`) · 월류 높이(`depth_m`) = 노면 위 점선(그림 전용).
|
||||
"""
|
||||
defaults = _registry_defaults("ford_pavement")
|
||||
values = dict(options or {})
|
||||
@@ -531,7 +535,7 @@ def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
{
|
||||
"type": "ford_pavement",
|
||||
"span_m": span,
|
||||
# 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
|
||||
# 월류 높이 — 노면 위 점선. 없으면 안 그린다(수치를 지어내지 않는다).
|
||||
"depth_m": depth if depth and depth > 0 else None,
|
||||
"slope_pct": slope,
|
||||
"defaulted": _defaulted(
|
||||
@@ -658,24 +662,32 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
|
||||
chainage = _number(section.get("chainage_m"), None)
|
||||
if chainage is None:
|
||||
continue
|
||||
for pipe_chainage, spec in sets.items():
|
||||
# ⚠ 키가 다른 시설은 같은 측점에 함께 얹는다(299-1) — 첫 시설 하나에서 멈추면 물넘이포장
|
||||
# 연동 범위(폭 절반) 안 측점의 **자기 관**이 빠졌다(95 m 배관이 90 m 물넘이에 가려짐).
|
||||
# 같은 키끼리는 소유 측점 것 → 흙막이 아닌 자기 시설 → 가까운 것 → 관 지점 차례.
|
||||
best: dict[str, tuple[tuple[Any, ...], dict[str, Any]]] = {}
|
||||
for order, (pipe_chainage, spec) in enumerate(sets.items()):
|
||||
# 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다(`_SPAN_LINKED_TYPES`).
|
||||
reach = _CHAINAGE_TOLERANCE_M
|
||||
if spec.get("type") in _SPAN_LINKED_TYPES:
|
||||
reach += (_number(spec.get("span_m"), 0.0) or 0.0) / 2
|
||||
if abs(chainage - pipe_chainage) <= reach or owner.get(pipe_chainage) == chainage:
|
||||
# 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
|
||||
kind = spec.get("type")
|
||||
# ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의
|
||||
# 절반까지 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려
|
||||
# **같은 시설을 여러 측점에서 센다**(관 9개에 길이가 10곳 실렸던 자리).
|
||||
section[_SECTION_KEYS.get(str(kind), "culvert")] = {
|
||||
**spec,
|
||||
"chainage_m": pipe_chainage,
|
||||
}
|
||||
# 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`)에 함께 얹음.
|
||||
if spec.get("guard"):
|
||||
section["culvert"] = {**spec["guard"], "chainage_m": pipe_chainage}
|
||||
attached += 1
|
||||
break
|
||||
owned = owner.get(pipe_chainage) == chainage
|
||||
if not (abs(chainage - pipe_chainage) <= reach or owned):
|
||||
continue
|
||||
# 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
|
||||
entries = [(_SECTION_KEYS.get(str(spec.get("type")), "culvert"), spec, False)]
|
||||
# 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`)에 함께 얹음 ·
|
||||
# 자기 관과 부딪히면 관 먼저(흙막이가 덮으면 관 그림 · 연장이 빠짐).
|
||||
if spec.get("guard"):
|
||||
entries.append(("culvert", spec["guard"], True))
|
||||
for key, value, is_guard in entries:
|
||||
rank = (not owned, is_guard, abs(chainage - pipe_chainage), order)
|
||||
if key not in best or rank < best[key][0]:
|
||||
# ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의
|
||||
# 절반까지 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려
|
||||
# **같은 시설을 여러 측점에서 센다**(관 9개에 길이가 10곳 실렸던 자리).
|
||||
best[key] = (rank, {**value, "chainage_m": pipe_chainage})
|
||||
for key, (_, value) in best.items():
|
||||
section[key] = value
|
||||
attached += bool(best)
|
||||
return attached
|
||||
|
||||
@@ -155,6 +155,13 @@ def stored_shoulder_extend(stored: dict[str, Any] | None) -> dict[str, float]:
|
||||
return out
|
||||
|
||||
|
||||
def stored_tilt(stored: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""저장분의 구조물 자리 기울기(287-4 · 288-1) — `compute_cross_design(**...)` 로 넘긴다."""
|
||||
value = (stored or {}).get("tilt_pct")
|
||||
pct = float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None
|
||||
return {"tilted": bool((stored or {}).get("tilted")), "tilt_pct": pct}
|
||||
|
||||
|
||||
def curve_widening_args(section: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""측점 기록에서 곡선부 확폭 입력을 뽑는다 — `compute_cross_design(**...)` 로 넘긴다.
|
||||
|
||||
@@ -194,6 +201,8 @@ def compute_cross_design(
|
||||
spoil_fill: dict[str, Any] | None = None,
|
||||
shoulder_extend_left_m: float = 0.0,
|
||||
shoulder_extend_right_m: float = 0.0,
|
||||
tilted: bool = False,
|
||||
tilt_pct: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
|
||||
|
||||
@@ -210,6 +219,9 @@ def compute_cross_design(
|
||||
입력이며, None·45m 이상이면 확폭이 없다.
|
||||
curve_outer_side: 곡선 **바깥쪽**("left"/"right"). 확폭은 그쪽으로만 붙는다
|
||||
(2026-09-06 사용자 확정). 값이 없으면 확폭을 넣지 않는다.
|
||||
tilted · tilt_pct: 물넘이포장 범위 · 맹암거 측점(286-2 · 287-4 · 288-1) — 측구 없음 ·
|
||||
노면 = `tilt_pct`(비면 표준 횡단경사)로 지대 낮은 쪽 한 경사. 결과에 `tilted` · `tilt_pct`
|
||||
로 되실음(`stored_tilt`). TS 짝 `tilted` · `tiltPct`.
|
||||
surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류
|
||||
높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로
|
||||
횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자).
|
||||
@@ -243,6 +255,8 @@ def compute_cross_design(
|
||||
paved_group = _resolve_group("paved", standard)
|
||||
# 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다.
|
||||
cross_slope_pct = paved_group["cross_slope_pct"] if paved else group["cross_slope_pct"]
|
||||
if tilted and tilt_pct is not None and float(tilt_pct) > 0:
|
||||
cross_slope_pct = float(tilt_pct) # 구조물 자리 경사(287-4 · 288-1)
|
||||
resolved_ditch_side = _resolve_ditch_side(section_mode, ditch_side)
|
||||
|
||||
valid = sorted(
|
||||
@@ -299,6 +313,8 @@ def compute_cross_design(
|
||||
berm=berm,
|
||||
shoulder_extend_left_m=shoulder_extend_left_m,
|
||||
shoulder_extend_right_m=shoulder_extend_right_m,
|
||||
tilted=bool(tilted),
|
||||
ground_span=(valid[0][0], valid[-1][0]),
|
||||
)
|
||||
|
||||
# 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야
|
||||
@@ -529,6 +545,17 @@ def compute_cross_design(
|
||||
# 뒤에 쓸 자리가 있다. **되살릴 때는 저장분에서 읽지 말고 계산해서 쓸 것.**
|
||||
"cut_slope_segments": geometry.cut_slope_segments(),
|
||||
}
|
||||
if tilted:
|
||||
result["tilted"] = True
|
||||
for side, reach in geometry.reach.items(): # 낮은 쪽 늘인 노면 끝(294-3)
|
||||
signed = reach if side == "left" else -reach
|
||||
result["tilt_reach"] = {
|
||||
"side": side,
|
||||
"offset_m": round(signed, 4),
|
||||
"elevation_m": round(geometry.road_z(signed), 4),
|
||||
}
|
||||
if tilt_pct is not None:
|
||||
result["tilt_pct"] = round(float(tilt_pct), 4)
|
||||
if drop > 0:
|
||||
# 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다.
|
||||
result["surface_drop_m"] = round(drop, 4)
|
||||
|
||||
@@ -62,6 +62,8 @@ class _SectionGeometry:
|
||||
berm: BermSpec | None = None,
|
||||
shoulder_extend_left_m: float = 0.0,
|
||||
shoulder_extend_right_m: float = 0.0,
|
||||
tilted: bool = False,
|
||||
ground_span: tuple[float, float] | None = None,
|
||||
) -> None:
|
||||
half_road = group["road_width_m"] / 2.0
|
||||
# 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽).
|
||||
@@ -95,6 +97,11 @@ class _SectionGeometry:
|
||||
# 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약).
|
||||
slope = cross_slope_pct / 100.0
|
||||
self.slope_per_offset = -slope if ditch_side == "left" else slope
|
||||
# 물넘이포장 · 맹암거 자리(287-4 · 288-1) — 노면 = 한 경사로 지대 낮은 쪽(노면 끝
|
||||
# 원지반이 낮은 쪽)으로 기움 · 측구 없음(아래). ⚠ TS 짝 `SectionGeometry`.
|
||||
if tilted and ground_at is not None:
|
||||
low_left = ground_at(self.left_extent) < ground_at(-self.right_extent)
|
||||
self.slope_per_offset = -slope if low_left else slope
|
||||
|
||||
# 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다
|
||||
# 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서
|
||||
@@ -144,6 +151,10 @@ class _SectionGeometry:
|
||||
self.has_ditch = auto_ditch if (choice is None or both_fill) else choice
|
||||
#: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다.
|
||||
self.ditch_choice = None if both_fill else choice
|
||||
# 물넘이포장 · 맹암거 자리(286-2 · 288-1) — 측구가 안 섬 · 선택은 그대로(시설을 걷으면
|
||||
# 되살아남).
|
||||
if tilted:
|
||||
self.has_ditch = False
|
||||
|
||||
# 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전**
|
||||
# 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도.
|
||||
@@ -185,6 +196,36 @@ class _SectionGeometry:
|
||||
(edge_offset + outward * top, edge_z),
|
||||
]
|
||||
|
||||
# 물넘이 낮은 쪽 연장(294-3) — 노면이 내려가는 쪽 노면 끝 원지반이 노면보다 높으면(절토)
|
||||
# 노면을 같은 기울기로 늘여 원지반과 처음 만나는 자리까지(그 쪽 절토 사면 없음) · 반폭
|
||||
# (`ground_span`) 안에서 못 만나면 옛 길(절토 사면). ⚠ TS 짝 `SectionGeometry.reach`.
|
||||
self.reach: dict[str, float] = {}
|
||||
if tilted and ground_at is not None and abs(self.slope_per_offset) > 1e-12:
|
||||
self._extend_low_side(ground_at, ground_span)
|
||||
|
||||
def _extend_low_side(
|
||||
self, ground_at: Callable[[float], float], span: tuple[float, float] | None
|
||||
) -> None:
|
||||
side = "left" if self.slope_per_offset < 0 else "right"
|
||||
sign = 1.0 if side == "left" else -1.0
|
||||
extent = self.left_extent if side == "left" else self.right_extent
|
||||
limit = (span[1] if side == "left" else -span[0]) if span else extent + 50.0
|
||||
|
||||
def gap(dist: float) -> float: # > 0 = 원지반이 늘인 노면 위(절토)
|
||||
return ground_at(sign * dist) - self.road_z(sign * dist)
|
||||
|
||||
if gap(extent) <= 1e-3:
|
||||
return
|
||||
step = 0.05
|
||||
dist = extent
|
||||
while dist < limit - 1e-9:
|
||||
nxt = min(dist + step, limit)
|
||||
g0, g1 = gap(dist), gap(nxt)
|
||||
if g1 <= 0:
|
||||
self.reach[side] = dist + (nxt - dist) * g0 / (g0 - g1)
|
||||
return
|
||||
dist = nxt
|
||||
|
||||
def road_z(self, offset_m: float) -> float:
|
||||
"""노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면."""
|
||||
return self.z_center + self.slope_per_offset * offset_m
|
||||
@@ -420,6 +461,9 @@ class _SectionGeometry:
|
||||
return points[-1][1]
|
||||
role = self.left_role if side == "left" else self.right_role
|
||||
dist = abs(offset_m)
|
||||
reach = self.reach.get(side)
|
||||
if reach is not None: # 늘인 노면 → 그 바깥은 원지반(294-3)
|
||||
return self.road_z(offset_m) if dist <= reach + 1e-9 else ground_m
|
||||
if role == "cut":
|
||||
# 지반과 1회 교차하면 그 이후 절토는 의미 없음 → 지반 추종(N-2-4).
|
||||
cross = self.cut_cross_dist(side)
|
||||
@@ -458,6 +502,9 @@ class _SectionGeometry:
|
||||
if cross is not None and offset > cross + 1e-9:
|
||||
break
|
||||
points.append(offset if side == "left" else -offset)
|
||||
# 늘인 노면 끝(294-3)
|
||||
for side, reach in self.reach.items():
|
||||
points.append(reach if side == "left" else -reach)
|
||||
# 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4).
|
||||
for side in ("left", "right"):
|
||||
role = self.left_role if side == "left" else self.right_role
|
||||
|
||||
@@ -25,6 +25,7 @@ from B06_Section.B06_Section_Engine_Design import (
|
||||
compute_cross_design,
|
||||
curve_widening_args,
|
||||
stored_shoulder_extend,
|
||||
stored_tilt,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
@@ -158,6 +159,7 @@ def extend_unclosed_sections(
|
||||
berm=stored_berm(design),
|
||||
**curve_widening_args(section),
|
||||
**stored_shoulder_extend(design), # 노견 확장(139-6)
|
||||
**stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4)
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -23,7 +23,11 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, stored_shoulder_extend
|
||||
from B06_Section.B06_Section_Engine_Design import (
|
||||
compute_cross_design,
|
||||
stored_shoulder_extend,
|
||||
stored_tilt,
|
||||
)
|
||||
from common_util.common_util_structure_face_role import structure_face_role
|
||||
|
||||
#: 폭을 좁혀 가는 이분법 반복 수. TS 짝(`solveSpoilWidthM`)과 같은 값이다.
|
||||
@@ -217,6 +221,7 @@ def enforce_spoil_fills(
|
||||
},
|
||||
**curve_widening_args(section),
|
||||
**stored_shoulder_extend(design), # 노견 확장(139-6)
|
||||
**stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4)
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""구조물 자리 기울기 — 물넘이포장 · 맹암거 측점의 노면(287-4 · 288-1).
|
||||
|
||||
그 자리 횡단 설계 = 측구 없음 · 노면 = 한 경사로 지대 낮은 쪽(`compute_cross_design(tilted=…,
|
||||
tilt_pct=…)`). 경사 = 물넘이 경사각도(%) · 맹암거 암거 경사(%) · 비면 표준 횡단경사.
|
||||
· 물넘이포장 = 관 지점 정본(pipe_points) 범위(기준 ± 월류 폭/2).
|
||||
· 맹암거 = 구조물 정본(structures.json) A군 점형 · 기준 측점 한 곳 — 횡단 dict 에
|
||||
`underdrain`(높이 · 경사)을 얹어 횡단도가 돌 무늬 형상을 그림(TS 짝
|
||||
`common_util_structure_underdrain.ts`).
|
||||
판정 폭 = 포장 범위와 같은 0.02 m(`paved_at`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNDERDRAIN_TYPE_ID = "cross_underdrain"
|
||||
_EDGE_TOLERANCE_M = 0.02
|
||||
|
||||
TiltRange = tuple[float, float, float | None]
|
||||
|
||||
|
||||
def _number(value: Any) -> float | None:
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed == parsed else None
|
||||
|
||||
|
||||
def load_underdrains(project_root: Path) -> list[dict[str, Any]]:
|
||||
"""구조물 정본의 맹암거 — `{structure_id, chainage_m, height_m, slope_pct}` ·
|
||||
실패하면 빈 목록."""
|
||||
try:
|
||||
structures = load_structures(str(project_root))[1]
|
||||
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
|
||||
logger.exception("B06 맹암거를 읽지 못했습니다 (없음으로 본다)")
|
||||
return []
|
||||
found: list[dict[str, Any]] = []
|
||||
for structure in structures:
|
||||
if structure.type_id != UNDERDRAIN_TYPE_ID:
|
||||
continue
|
||||
options = structure.options or {}
|
||||
height = _number(options.get("culvert_height_m"))
|
||||
slope = _number(options.get("culvert_slope_pct"))
|
||||
found.append(
|
||||
{
|
||||
"structure_id": structure.structure_id,
|
||||
"chainage_m": float(structure.anchor_m()),
|
||||
"height_m": height if height and height > 0 else None,
|
||||
"slope_pct": slope if slope and slope > 0 else None,
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def tilt_ranges(project_root: Path | None) -> list[TiltRange]:
|
||||
"""측구 없이 노면을 지대 낮은 쪽 한 경사로 기울이는 자리 — (시작, 끝, 경사 %)."""
|
||||
if project_root is None:
|
||||
return []
|
||||
out: list[TiltRange] = []
|
||||
for at, spec in load_culvert_sets(project_root).items():
|
||||
if spec.get("type") == "ford_pavement":
|
||||
half = float(spec.get("span_m") or 0.0) / 2.0
|
||||
out.append((at - half, at + half, spec.get("slope_pct")))
|
||||
for drain in load_underdrains(project_root):
|
||||
out.append((drain["chainage_m"], drain["chainage_m"], drain["slope_pct"]))
|
||||
return out
|
||||
|
||||
|
||||
def tilt_args(chainage_m: float, ranges: list[TiltRange]) -> dict[str, Any]:
|
||||
"""그 측점의 구조물 자리 기울기 — `compute_cross_design(**...)` 로 넘김 ·
|
||||
저장분 짝 `stored_tilt`."""
|
||||
tol = _EDGE_TOLERANCE_M
|
||||
hit = next((r for r in ranges if r[0] - tol <= chainage_m <= r[1] + tol), None)
|
||||
pct = hit[2] if hit else None
|
||||
return {"tilted": hit is not None, "tilt_pct": float(pct) if pct is not None else None}
|
||||
|
||||
|
||||
def attach_underdrains(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
|
||||
"""맹암거 기준 측점의 횡단 dict 에 `underdrain` 을 얹는다. 얹은 개수를 돌려준다."""
|
||||
drains = load_underdrains(project_root)
|
||||
attached = 0
|
||||
for section in cross_sections:
|
||||
chainage = _number(section.get("chainage_m"))
|
||||
if chainage is None:
|
||||
continue
|
||||
hit = next(
|
||||
(d for d in drains if abs(d["chainage_m"] - chainage) <= _EDGE_TOLERANCE_M), None
|
||||
)
|
||||
if hit:
|
||||
section["underdrain"] = dict(hit)
|
||||
attached += 1
|
||||
return attached
|
||||
@@ -26,6 +26,7 @@ from B06_Section.B06_Section_Engine_Design import (
|
||||
stored_shoulder_extend,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures
|
||||
from B06_Section.B06_Section_Engine_Tilt import attach_underdrains, tilt_args, tilt_ranges
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
count_cross_sections,
|
||||
create_longitudinal_section,
|
||||
@@ -285,6 +286,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
|
||||
# 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정).
|
||||
attach_wall_structures(root, cross_sections)
|
||||
attach_pavement_structures(root, cross_sections) # 노면공 형상(88)
|
||||
attach_underdrains(root, cross_sections) # 맹암거 형상(288-1)
|
||||
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
|
||||
|
||||
|
||||
@@ -448,6 +450,7 @@ async def regenerate_sections(
|
||||
attach_culvert_sets(project_root, result["cross_sections"])
|
||||
attach_wall_structures(project_root, result["cross_sections"])
|
||||
attach_pavement_structures(project_root, result["cross_sections"])
|
||||
attach_underdrains(project_root, result["cross_sections"])
|
||||
return SectionDetailResponse(
|
||||
longitudinal=result["longitudinal"], cross_sections=result["cross_sections"]
|
||||
)
|
||||
@@ -600,6 +603,7 @@ async def compute_cross_section_design(
|
||||
berm=stored_berm(stored_design or {}),
|
||||
**curve_widening_args(cross_record),
|
||||
**stored_shoulder_extend(stored_design or {}), # 노견 확장(139-6)
|
||||
**tilt_args(request.chainage_m, tilt_ranges(project_root)),
|
||||
)
|
||||
# B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다.
|
||||
design["status"] = "provisional"
|
||||
|
||||
@@ -89,20 +89,20 @@ def _rowless_station_chainages(
|
||||
return result
|
||||
|
||||
|
||||
async def _stand_patch_stations(
|
||||
project_id: UUID, request: SectionConfirmRequest | None, known: list[float]
|
||||
) -> None:
|
||||
"""행 없는 측점의 횡단 수정 — 버리지 않고 그 측점부터 세운다(B05·B06 한 페이지처럼).
|
||||
async def _stand_patch_stations(project_id: UUID) -> None:
|
||||
"""저장 · 확정 앞에 구조물 측점을 지금 관 · 구조물 목록으로 맞춘다(B05·B06 한 페이지처럼).
|
||||
|
||||
옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없다. 여기서
|
||||
[측점 만들기]와 같은 조건으로 세우면 아래 `_rowless_station_chainages` 가 행을 만들고
|
||||
수정이 그 행에 얹힌다.
|
||||
옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없고, 옛 자리 측점은
|
||||
남아 있다 — 행 없는 측점의 수정이 버려지고 옛 측점이 카드 · 수량에 남음. [측점 만들기]와 같은
|
||||
조건으로 세우고 낡은 것을 지우면(맞출 것이 없으면 그냥 돌아옴 · 272) 아래
|
||||
`_rowless_station_chainages` 가 행을 만들고 수정이 그 행에 얹힌다.
|
||||
"""
|
||||
patches = (request.cross_patches or []) if request else []
|
||||
if any(all(abs(p.chainage_m - k) >= 0.01 for k in known) for p in patches):
|
||||
from B06_Section.B06_Section_Router_Stations import ensure_structure_stations
|
||||
from B06_Section.B06_Section_Router_Stations import ensure_structure_stations
|
||||
|
||||
try:
|
||||
await ensure_structure_stations(project_id)
|
||||
except Exception: # 맞춤이 안 돼도 저장은 진행 — 옛 측점이 남을 뿐(다음 저장에서 다시)
|
||||
logger.exception("B06 저장: 구조물 측점 맞춤 실패(저장은 진행): project_id=%s", project_id)
|
||||
|
||||
|
||||
async def _apply_section_edits(
|
||||
@@ -230,6 +230,9 @@ async def save_sections(
|
||||
if stale:
|
||||
return stale
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
# 측점부터 맞춤 — 아래 행 조회가 맞춘(옮기고 지운) 뒤 행을 봄(272)
|
||||
await _stand_patch_stations(project_id)
|
||||
async with pool.acquire() as connection:
|
||||
known = await get_cross_section_chainages(connection, route_id)
|
||||
# 행은 있는데 **설계가 빈** 측점도 채운다(2026-09-09). 「빈 설계」란 `design` 이
|
||||
# 없거나 **`ground_type` 이 없는 것**이다 — 재생성이 행을 다시 쓰면 면적 4키만
|
||||
@@ -238,7 +241,6 @@ async def save_sections(
|
||||
# ⚠ **값이 있는 행은 건드리지 않는다** — 사용자 조작값·이월분이 거기 있다.
|
||||
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
|
||||
|
||||
await _stand_patch_stations(project_id, request, known)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
rowless = missing + await asyncio.to_thread(
|
||||
_rowless_station_chainages,
|
||||
@@ -311,10 +313,12 @@ async def confirm_sections(
|
||||
if stale:
|
||||
return stale
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
await _stand_patch_stations(
|
||||
project_id
|
||||
) # 측점부터 맞춤 — 아래 행 조회가 맞춘 뒤 행을 봄(272)
|
||||
async with pool.acquire() as connection:
|
||||
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
|
||||
known = await get_cross_section_chainages(connection, route_id)
|
||||
|
||||
await _stand_patch_stations(project_id, request, known)
|
||||
marks.append(("조회(종단·경로·미지정 측점)", time.perf_counter()))
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
# 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면
|
||||
|
||||
@@ -13,7 +13,9 @@ from B06_Section.B06_Section_Engine_Design import (
|
||||
compute_cross_design,
|
||||
curve_widening_args,
|
||||
stored_shoulder_extend,
|
||||
stored_tilt,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges
|
||||
from common_util.common_util_cross_berm import (
|
||||
BERM_DEFAULT_DEPTH_M,
|
||||
BERM_DEFAULT_INTERVAL_M,
|
||||
@@ -77,12 +79,20 @@ def pavement_ranges(project_root: Path) -> list[tuple[float, float]]:
|
||||
ranges.append((min(float(start), float(end)), max(float(start), float(end))))
|
||||
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
|
||||
logger.exception("B06 포장 구간을 읽지 못했습니다 (사용자 지정 없음으로 본다)")
|
||||
for chainage, spec in load_culvert_sets(project_root).items():
|
||||
if spec.get("type") != "ford_pavement":
|
||||
continue
|
||||
half = float(spec.get("span_m") or 0.0) / 2.0
|
||||
ranges.append((chainage - half, chainage + half))
|
||||
return ranges
|
||||
return ranges + ford_pavement_ranges(project_root)
|
||||
|
||||
|
||||
def ford_pavement_ranges(project_root: Path | None) -> list[tuple[float, float]]:
|
||||
"""물넘이포장 범위(기준 ± 월류 폭/2) — 포장 필수 · 측구 없음(286-2). 판정은 `paved_at`."""
|
||||
if project_root is None:
|
||||
return []
|
||||
sets = load_culvert_sets(project_root).items()
|
||||
halves = [
|
||||
(at, float(s.get("span_m") or 0.0) / 2.0)
|
||||
for at, s in sets
|
||||
if s.get("type") == "ford_pavement"
|
||||
]
|
||||
return [(at - half, at + half) for at, half in halves]
|
||||
|
||||
|
||||
def ford_surface_drops(project_root: Path | None) -> list[tuple[float, float]]:
|
||||
@@ -155,6 +165,8 @@ USER_TOUCHED_KEYS = (
|
||||
"cut_slope_ratio_user",
|
||||
# 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
|
||||
"structure_note",
|
||||
# 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님.
|
||||
"structure_note_cad",
|
||||
# [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈)
|
||||
"detail_table",
|
||||
# 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`)
|
||||
@@ -205,6 +217,7 @@ def enforce_pavement_ranges(
|
||||
사용자 조작값(반폭·구조물 조정)은 그대로 승계한다.
|
||||
"""
|
||||
ranges = pavement_ranges(project_root)
|
||||
tilts = tilt_ranges(project_root)
|
||||
ford_drops = ford_surface_drops(project_root)
|
||||
changed = 0
|
||||
for section in cross_sections:
|
||||
@@ -213,7 +226,8 @@ def enforce_pavement_ranges(
|
||||
continue
|
||||
chainage = float(section.get("chainage_m", 0.0))
|
||||
wanted = paved_at(chainage, ranges)
|
||||
if bool(design.get("paved")) == wanted:
|
||||
tilt = tilt_args(chainage, tilts) # 물넘이 자리 = 측구 없음 · 기울기(286-2 · 287-4)
|
||||
if bool(design.get("paved")) == wanted and stored_tilt(design) == tilt:
|
||||
continue
|
||||
try:
|
||||
recomputed = compute_cross_design(
|
||||
@@ -236,6 +250,7 @@ def enforce_pavement_ranges(
|
||||
berm=stored_berm(design),
|
||||
**curve_widening_args(section),
|
||||
**stored_shoulder_extend(design), # 노견 확장(139-6)
|
||||
**tilt,
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
@@ -264,6 +279,7 @@ def enforce_design_lines(
|
||||
from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans
|
||||
|
||||
ford_drops = ford_surface_drops(project_root)
|
||||
tilts = tilt_ranges(project_root)
|
||||
spans = load_berm_spans(project_root)
|
||||
changed = 0
|
||||
for section in cross_sections:
|
||||
@@ -294,6 +310,7 @@ def enforce_design_lines(
|
||||
berm=stored_berm(design),
|
||||
**curve_widening_args(section),
|
||||
**stored_shoulder_extend(design),
|
||||
**tilt_args(chainage, tilts),
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
@@ -349,6 +366,7 @@ def enforce_ford_surface_drops(
|
||||
berm=stored_berm(design),
|
||||
**curve_widening_args(section),
|
||||
**stored_shoulder_extend(design), # 노견 확장(139-6)
|
||||
**stored_tilt(design),
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
@@ -421,6 +439,7 @@ def attach_default_designs(
|
||||
modes = default_section_modes(longitudinal)
|
||||
pavement = pavement_suggestions(longitudinal)
|
||||
paved_ranges = pavement_ranges(project_root) if project_root else []
|
||||
tilts = tilt_ranges(project_root)
|
||||
ford_drops = ford_surface_drops(project_root)
|
||||
for section in cross_sections:
|
||||
if section.get("design"):
|
||||
@@ -439,6 +458,7 @@ def attach_default_designs(
|
||||
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
||||
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
||||
**curve_widening_args(section),
|
||||
**tilt_args(chainage, tilts),
|
||||
)
|
||||
design.update(status="provisional", pavement_suggested=suggested)
|
||||
section["design"] = design
|
||||
@@ -499,6 +519,7 @@ def recompute_designs_for_alignment(
|
||||
modes = default_section_modes(longitudinal)
|
||||
pavement = pavement_suggestions(longitudinal)
|
||||
paved_ranges = pavement_ranges(project_root) if project_root else []
|
||||
tilts = tilt_ranges(project_root)
|
||||
ford_drops = ford_surface_drops(project_root)
|
||||
stored_by_chainage = {
|
||||
round(float(record["chainage_m"]), 3): (record.get("design") or {})
|
||||
@@ -557,6 +578,7 @@ def recompute_designs_for_alignment(
|
||||
berm=session_berms.get(key) or stored_berm(stored),
|
||||
**curve_widening_args(section),
|
||||
**stored_shoulder_extend(stored), # 노견 확장(139-6)
|
||||
**tilt_args(chainage, tilts),
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
@@ -580,9 +602,13 @@ def compute_default_designs(
|
||||
chainages: list[float],
|
||||
standard: dict[str, Any] | None = None,
|
||||
carried: dict[float, dict[str, Any]] | None = None,
|
||||
crosses: dict[float, dict[str, Any]] | None = None,
|
||||
) -> list[tuple[float, dict[str, Any]]]:
|
||||
"""미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다.
|
||||
|
||||
`crosses`(누가거리 round 3 → 횡단 dict)가 오면 파일 대신 그 횡단으로 계산한다 — 아직 정본에
|
||||
없는 B06 미리 뜨기 자리(273-2) · 상단측도 그 횡단 값(종단 정본에 측점이 없음).
|
||||
|
||||
standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다.
|
||||
`carried`(누가거리 round 3 → `CARRY_INPUT_KEYS` 값)가 오면 그 측점은 기본값 대신 그 입력으로
|
||||
계산한다 — 노선 [확인] 체인의 옛 설계 이월(PLAN 74-3). 결과 칸은 늘 새 계획고로 다시 낸다.
|
||||
@@ -597,14 +623,17 @@ def compute_default_designs(
|
||||
default_modes = default_section_modes(longitudinal)
|
||||
pavement = pavement_suggestions(longitudinal)
|
||||
paved_ranges = pavement_ranges(root)
|
||||
tilts = tilt_ranges(root)
|
||||
ford_drops = ford_surface_drops(root)
|
||||
results: list[tuple[float, dict[str, Any]]] = []
|
||||
for chainage_m in chainages:
|
||||
cross = (crosses or {}).get(round(chainage_m, 3))
|
||||
cross_path = find_cross_file(cross_dir, chainage_m)
|
||||
if not cross_path.is_file():
|
||||
if cross is None and not cross_path.is_file():
|
||||
continue
|
||||
try:
|
||||
cross = json.loads(cross_path.read_text(encoding="utf-8"))
|
||||
if cross is None:
|
||||
cross = json.loads(cross_path.read_text(encoding="utf-8"))
|
||||
samples = cross.get("samples")
|
||||
if not isinstance(samples, list):
|
||||
continue
|
||||
@@ -615,7 +644,12 @@ def compute_default_designs(
|
||||
samples,
|
||||
design_elevation_from_longitudinal(longitudinal, chainage_m),
|
||||
ground_type=str(user.get("ground_type") or "ripping_rock"),
|
||||
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
|
||||
section_mode=default_modes.get(
|
||||
round(chainage_m, 3),
|
||||
f"{cross['uphill_side']}_cut"
|
||||
if cross.get("uphill_side") in ("left", "right")
|
||||
else "left_cut",
|
||||
),
|
||||
ditch_type=str(user.get("ditch_type") or "standard"),
|
||||
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
|
||||
paved=paved_at(chainage_m, paved_ranges),
|
||||
@@ -629,6 +663,7 @@ def compute_default_designs(
|
||||
surface_drop_m=ford_drop_at(chainage_m, ford_drops),
|
||||
berm=stored_berm(user),
|
||||
**curve_widening_args(cross),
|
||||
**tilt_args(chainage_m, tilts),
|
||||
)
|
||||
for key in ("berm", "cut_slope_ratio_user"):
|
||||
if user.get(key) is not None:
|
||||
|
||||
@@ -27,6 +27,7 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import SAME_STATION_SNAP_M
|
||||
@@ -83,8 +84,8 @@ async def _sampling_conditions(project_id: UUID, project_root: Path) -> dict[str
|
||||
}
|
||||
|
||||
|
||||
def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]:
|
||||
"""구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록."""
|
||||
def _planted(project_root: Path, route_data_path: str) -> list[tuple[float, str]]:
|
||||
"""관 · 구조물 정본이 심는 구조물 측점 가운데 **노선 안**(세울 수 있는 것)만."""
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
_load_pipe_points,
|
||||
_load_route_polyline,
|
||||
@@ -92,23 +93,28 @@ def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, A
|
||||
)
|
||||
|
||||
polyline = _load_route_polyline(project_root, route_data_path)
|
||||
pipes = _load_pipe_points(project_root, polyline)
|
||||
extras = resolve_extra_stations(project_root, pipes)
|
||||
if not extras:
|
||||
return []
|
||||
existing = _station_chainages(project_root)
|
||||
extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline))
|
||||
# 노선 밖(0 미만 · 끝 넘음) = 측점을 못 세움 — 판정에서 뺌(219-1 · 길이 모르면 끝은 안 봄)
|
||||
end_m = sum(math.dist(a[:2], b[:2]) for a, b in zip(polyline, polyline[1:]))
|
||||
missing = []
|
||||
for chainage, label in extras:
|
||||
value = float(chainage)
|
||||
if value < -STATION_MATCH_TOLERANCE_M or (
|
||||
end_m > 0 and value > end_m + STATION_MATCH_TOLERANCE_M
|
||||
):
|
||||
continue
|
||||
if any(abs(value - other) <= STATION_MATCH_TOLERANCE_M for other in existing):
|
||||
continue
|
||||
missing.append({"chainage_m": round(value, 3), "label": label})
|
||||
return [
|
||||
(float(chainage), label)
|
||||
for chainage, label in extras
|
||||
if float(chainage) >= -STATION_MATCH_TOLERANCE_M
|
||||
and (end_m <= 0 or float(chainage) <= end_m + STATION_MATCH_TOLERANCE_M)
|
||||
]
|
||||
|
||||
|
||||
def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]:
|
||||
"""구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록."""
|
||||
planted = _planted(project_root, route_data_path)
|
||||
if not planted:
|
||||
return []
|
||||
existing = _station_chainages(project_root)
|
||||
missing = [
|
||||
{"chainage_m": round(value, 3), "label": label}
|
||||
for value, label in planted
|
||||
if all(abs(value - other) > STATION_MATCH_TOLERANCE_M for other in existing)
|
||||
]
|
||||
return sorted(missing, key=lambda item: item["chainage_m"])
|
||||
|
||||
|
||||
@@ -130,6 +136,24 @@ def _station_chainages(project_root: Path) -> list[float]:
|
||||
return values
|
||||
|
||||
|
||||
def _stale_marks(project_root: Path, route_data_path: str) -> int:
|
||||
"""종단 정본의 구조물 측점과 **지금 관 · 구조물**이 어긋난 수 — 옮긴 관의 옛 자리 · 구조물이
|
||||
떠난 격자 자리 · 구조물이 새로 얹혀야 할 격자 자리(272). 0 이면 맞음."""
|
||||
import json
|
||||
|
||||
planted = {round(value, 2) for value, _label in _planted(project_root, route_data_path)}
|
||||
standing: set[float] = set()
|
||||
for path in sorted((project_root / "B06_Section" / "longitudinal").glob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for station in data.get("stations") or []:
|
||||
if station.get("kind") == "irregular" or station.get("structure"):
|
||||
standing.add(round(float(station.get("chainage_m") or 0.0), 2))
|
||||
return len(standing ^ planted)
|
||||
|
||||
|
||||
async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None:
|
||||
async def _load(connection):
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
@@ -142,6 +166,26 @@ async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None
|
||||
return Path(resolve_stored_project_path(stored)), route
|
||||
|
||||
|
||||
async def _generation_inputs(
|
||||
project_id: UUID, route: dict[str, Any], snapshot: dict[str, Any]
|
||||
) -> tuple[Any, int | None, dict[str, Any] | None]:
|
||||
"""측점을 뜰 때 쓰는 저장분 — (횡단 옵션, 좌표계 EPSG, 종단 행) · 만들기 · 미리 뜨기 한 벌."""
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_latest_section_options,
|
||||
get_longitudinal_section,
|
||||
)
|
||||
|
||||
async def _load(connection):
|
||||
options = await get_latest_section_options(connection, project_id)
|
||||
crs_epsg = await get_surface_crs_epsg(
|
||||
connection, project_id, snapshot.get("surface_model_id")
|
||||
)
|
||||
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
|
||||
return options, crs_epsg, longitudinal
|
||||
|
||||
return await run_with_connection(_load)
|
||||
|
||||
|
||||
async def _regenerate_structure_stations(
|
||||
project_id: UUID, project_root: Path, route: dict[str, Any], snapshot: dict[str, Any]
|
||||
) -> int:
|
||||
@@ -158,21 +202,10 @@ async def _regenerate_structure_stations(
|
||||
from B05_Profile.B05_Profile_Router_Confirm import (
|
||||
_merge_irregular_into_longitudinal,
|
||||
_section_options_from_stored,
|
||||
)
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_latest_section_options,
|
||||
get_longitudinal_section,
|
||||
drop_rows_off_stations,
|
||||
)
|
||||
|
||||
async def _load(connection):
|
||||
options = await get_latest_section_options(connection, project_id)
|
||||
crs_epsg = await get_surface_crs_epsg(
|
||||
connection, project_id, snapshot.get("surface_model_id")
|
||||
)
|
||||
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
|
||||
return options, crs_epsg, longitudinal
|
||||
|
||||
stored_options, crs_epsg, longitudinal = await run_with_connection(_load)
|
||||
stored_options, crs_epsg, longitudinal = await _generation_inputs(project_id, route, snapshot)
|
||||
|
||||
def _regenerate() -> tuple[int, dict[str, Any] | None]:
|
||||
polyline = _load_route_polyline(project_root, str(route["route_data_path"]))
|
||||
@@ -189,13 +222,20 @@ async def _regenerate_structure_stations(
|
||||
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
||||
)
|
||||
summary = None
|
||||
if stations and longitudinal:
|
||||
if longitudinal: # 빈 목록이어도 맞춤 — 옛 구조물 측점을 지움(272)
|
||||
summary = _merge_irregular_into_longitudinal(
|
||||
project_root, str(longitudinal["longitudinal_file_path"]), stations
|
||||
)
|
||||
return len(stations), summary
|
||||
|
||||
made, summary = await asyncio.to_thread(_regenerate)
|
||||
if longitudinal:
|
||||
await run_with_connection(
|
||||
drop_rows_off_stations,
|
||||
route["id"],
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
)
|
||||
if summary is not None:
|
||||
# 옮긴 관 자리의 계획선이 다시 셈됨 — DB 등급 요약도 같이(계획선 저장과 같은 길).
|
||||
await run_with_connection(
|
||||
@@ -207,7 +247,7 @@ async def _regenerate_structure_stations(
|
||||
|
||||
|
||||
async def ensure_structure_stations(project_id: UUID) -> int:
|
||||
"""빠진 구조물 측점을 세운다 — 횡단 저장이 **행 없는 측점의 수정**을 받을 때 부름.
|
||||
"""빠진 구조물 측점을 세우고 낡은 것을 지운다 — 횡단 저장마다 부름(맞출 것이 있을 때만 돎).
|
||||
|
||||
옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없어, 그 자리
|
||||
횡단 수정이 받을 곳 없이 버려진다. [측점 만들기] 단추와 같은 조건으로 세운다.
|
||||
@@ -218,14 +258,18 @@ async def ensure_structure_stations(project_id: UUID) -> int:
|
||||
return 0
|
||||
project_root, route = paths
|
||||
missing = await asyncio.to_thread(_missing_marks, project_root, str(route["route_data_path"]))
|
||||
if not missing:
|
||||
# 빠진 것만이 아니라 낡은 것(옮긴 관 옛 자리 · 떠난 격자 자리)도 맞춤 — 두 벌로 남지 않게(272)
|
||||
stale = await asyncio.to_thread(_stale_marks, project_root, str(route["route_data_path"]))
|
||||
if not missing and not stale:
|
||||
return 0
|
||||
snapshot = await _sampling_conditions(project_id, project_root)
|
||||
if snapshot is None:
|
||||
logger.warning("B06 저장: 빠진 구조물 측점 %d곳 — 샘플링 조건이 없어 못 세움", len(missing))
|
||||
logger.warning(
|
||||
"B06 저장: 구조물 측점 %d곳을 못 맞춤 — 샘플링 조건 없음", len(missing) or stale
|
||||
)
|
||||
return 0
|
||||
await _regenerate_structure_stations(project_id, project_root, route, snapshot)
|
||||
return len(missing)
|
||||
return len(missing) or stale
|
||||
|
||||
|
||||
@router.get("/{project_id}/section/missing-stations")
|
||||
@@ -299,3 +343,88 @@ async def create_missing_stations(project_id: UUID) -> JSONResponse:
|
||||
"missing": missing,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
#: 한 번에 미리 뜨는 자리 수 상한 — 화면 초안 한 벌이면 충분 · 큰 요청으로 서버를 붙잡지 않게.
|
||||
PREVIEW_MAX_STATIONS = 60
|
||||
|
||||
|
||||
class PreviewStationsRequest(BaseModel):
|
||||
"""미리 뜰 자리(누가거리 m) — 노선 밖 · 숫자 아닌 값은 서버가 버림."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
chainages: list[float] = Field(default_factory=list, max_length=PREVIEW_MAX_STATIONS)
|
||||
|
||||
|
||||
@router.post("/{project_id}/section/preview-stations")
|
||||
async def preview_structure_sections(
|
||||
project_id: UUID, request: PreviewStationsRequest
|
||||
) -> JSONResponse:
|
||||
"""초안 자리(놓거나 옮긴 관 · 구조물)의 횡단을 **미리 떠서 돌려만 준다**(273-2 · 요구 168).
|
||||
|
||||
파일 · DB 에 아무것도 안 쓴다 — 화면이 캐시에 얹어 B06 카드를 [저장] 전에 보이고, [저장] 때
|
||||
서버가 같은 길(`sample_structure_sections` · `compute_default_designs`)로 다시 만들어 정본에 씀.
|
||||
샘플링 조건은 측점 만들기와 같다(없으면 지어내지 않고 409).
|
||||
"""
|
||||
from B05_Profile.B05_Profile_Engine_Sections import sample_structure_sections
|
||||
from B05_Profile.B05_Profile_Router_Confirm import _section_options_from_stored
|
||||
from B06_Section.B06_Section_Router_Design import compute_default_designs
|
||||
|
||||
chainages = sorted({round(c, 3) for c in request.chainages if math.isfinite(c) and c >= 0})
|
||||
if not chainages:
|
||||
return JSONResponse(content={"status": "success", "cross_sections": []})
|
||||
try:
|
||||
paths = await _project_paths(project_id)
|
||||
if paths is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "확정된 노선이 없습니다."},
|
||||
)
|
||||
project_root, route = paths
|
||||
snapshot = await _sampling_conditions(project_id, project_root)
|
||||
if snapshot is None:
|
||||
return JSONResponse(
|
||||
status_code=409, content={"status": "error", "message": SNAPSHOT_MISSING}
|
||||
)
|
||||
stored_options, crs_epsg, longitudinal = await _generation_inputs(
|
||||
project_id, route, snapshot
|
||||
)
|
||||
if not longitudinal:
|
||||
return JSONResponse(content={"status": "success", "cross_sections": []})
|
||||
|
||||
def _preview() -> list[dict[str, Any]]:
|
||||
_stations, crosses = sample_structure_sections(
|
||||
project_root,
|
||||
str(route["route_data_path"]),
|
||||
str(snapshot["filter_key"]),
|
||||
str(snapshot["method"]),
|
||||
bool(snapshot.get("smooth")),
|
||||
extra_stations=tuple((chainage, "") for chainage in chainages),
|
||||
options=_section_options_from_stored(stored_options),
|
||||
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
||||
)
|
||||
wanted = set(chainages)
|
||||
picked = {
|
||||
round(float(cross["chainage_m"]), 3): cross
|
||||
for cross in crosses
|
||||
if round(float(cross["chainage_m"]), 3) in wanted
|
||||
}
|
||||
designs = compute_default_designs(
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
list(picked),
|
||||
crosses=picked,
|
||||
)
|
||||
for chainage, design in designs:
|
||||
picked[round(chainage, 3)]["design"] = design
|
||||
return list(picked.values())
|
||||
|
||||
sections = await asyncio.to_thread(_preview)
|
||||
except Exception:
|
||||
logger.exception("B06 미리 뜨기 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "횡단을 미리 뜨지 못했습니다."},
|
||||
)
|
||||
return JSONResponse(content={"status": "success", "cross_sections": sections})
|
||||
|
||||
@@ -138,7 +138,7 @@ class BoxAdjustPatch(BaseModel):
|
||||
class CrossPickPiece(BaseModel):
|
||||
"""[상세] 고른 선 조각(209-1) — 어느 선 · 두 끝(m · offset, 표고)."""
|
||||
|
||||
line: Literal["design", "ground", "spoil", "structure"]
|
||||
line: Literal["design", "ground", "spoil", "structure", "rock"]
|
||||
start: list[float] = Field(..., min_length=2, max_length=2)
|
||||
end: list[float] = Field(..., min_length=2, max_length=2)
|
||||
|
||||
@@ -179,6 +179,15 @@ class CrossDetailTable(BaseModel):
|
||||
columns: list[CrossDetailColumn] = Field(default_factory=list, max_length=4)
|
||||
|
||||
|
||||
class CrossNoteCad(BaseModel):
|
||||
"""[상세] 표 위 CAD 칸(260-1) — CAD 저장 꼴(엔티티 · 층) 그대로 · B07 이 표 위에 놓음."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
entities: list[dict[str, Any]] = Field(default_factory=list, max_length=2000)
|
||||
layers: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class CrossSectionPatch(BaseModel):
|
||||
"""확정 시 측점별 data.design에 병합할 프론트 세션 보관값."""
|
||||
|
||||
@@ -246,6 +255,8 @@ class CrossSectionPatch(BaseModel):
|
||||
ford_pipe_length_m: float | None = Field(default=None, ge=0)
|
||||
# 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움.
|
||||
structure_note: str | None = Field(default=None, max_length=2000)
|
||||
# 표 위 CAD 칸(260-1) — 엔티티가 빈 칸 = 비움(옛 글 대신 아무것도 안 놓음)
|
||||
structure_note_cad: CrossNoteCad | None = None
|
||||
# [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열 · 값은 서버가 다시 셈
|
||||
detail_table: CrossDetailTable | None = None
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_Section_Drafts.ts
|
||||
* 초안 측점 한 벌(272 · 273-2) — 관 · 구조물을 놓거나 옮긴 초안을 공유 상세(횡단 카드 · 유토곡선)에 맞춘다.
|
||||
*
|
||||
* · 옛 자리 구조물 측점(서버가 [저장] 때 다시 맞추기 전) = 숨김 — 쥐고 있다가 초안이 다시 그 자리를
|
||||
* 심거나 초안이 없어지면 돌려 놓음.
|
||||
* · 새 자리(서버 횡단 없음) = 서버가 미리 떠 준 횡단을 캐시에 얹음(`preview-stations` · 파일 · DB
|
||||
* 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음.
|
||||
*
|
||||
* 심는 자리 규칙 = `plantedChainages`(서버 `resolve_extra_stations` 짝) · 카드 이름 = 서버 측점 이름 규칙
|
||||
* (`pipe_station_label` · 종류 이름) · 관 그림 = 짝 산식 `common_util_culvert_sets`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
fetchStructures,
|
||||
fetchStructureTypes,
|
||||
readPendingStructures,
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { readPendingPipes, type PendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import {
|
||||
attachCulvertSets,
|
||||
buildCulvertSets,
|
||||
registryDefaults,
|
||||
} from "@util/common_util_culvert_sets";
|
||||
import {
|
||||
pipeStationMarks,
|
||||
plantedChainages,
|
||||
SAME_STATION_M,
|
||||
structureStationMarks,
|
||||
} from "../common_util/common_util_structure_station";
|
||||
import {
|
||||
previewStructureSections,
|
||||
type CrossSection,
|
||||
type SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
|
||||
type Pipes = PendingPipes;
|
||||
|
||||
const near = (a: number, b: number): boolean => Math.abs(a - b) < SAME_STATION_M;
|
||||
const byChainage = (a: { chainage_m: number }, b: { chainage_m: number }) =>
|
||||
a.chainage_m - b.chainage_m;
|
||||
|
||||
/** 저장된 관 · 구조물 — 한쪽 초안만 있을 때 심는 자리 셈에 씀 · 저장 뒤(`forgetDraftStations`) 다시 받음. */
|
||||
const saved = new Map<string, Promise<[Pipes, StructureInstance[]]>>();
|
||||
/** 미리 뜬 횡단(`프로젝트:노선:누가거리`) — 못 뜬 자리는 null(다시 안 물음) · 저장 뒤 비움. */
|
||||
const previews = new Map<string, CrossSection | null>();
|
||||
/** 초안이 빼 둔 옛 자리 측점. */
|
||||
const parked = new WeakMap<SectionDetailResponse, CrossSection[]>();
|
||||
/** 상세마다 마지막 맞춤 번호 — 늦게 끝난 옛 맞춤이 새 맞춤을 덮지 않게. */
|
||||
const turns = new WeakMap<SectionDetailResponse, number>();
|
||||
|
||||
/** 저장 · 노선 바뀜 뒤 — 쥔 저장분 · 미리 뜬 횡단을 버림. */
|
||||
export function forgetDraftStations(projectId: string): void {
|
||||
saved.delete(projectId);
|
||||
for (const key of [...previews.keys()]) if (key.startsWith(`${projectId}:`)) previews.delete(key);
|
||||
}
|
||||
|
||||
function savedOf(projectId: string): Promise<[Pipes, StructureInstance[]]> {
|
||||
let hit = saved.get(projectId);
|
||||
if (!hit) {
|
||||
hit = Promise.all([
|
||||
fetchDetailPipePoints(projectId).then((res) => res.pipe_points as Pipes),
|
||||
fetchStructures(projectId).then((res) => res.structures),
|
||||
]);
|
||||
hit.catch(() => saved.delete(projectId));
|
||||
saved.set(projectId, hit);
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
async function previewOf(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
chainages: number[],
|
||||
): Promise<CrossSection[]> {
|
||||
const key = (at: number) => `${projectId}:${routeId}:${at.toFixed(3)}`;
|
||||
const ask = chainages.filter((at) => !previews.has(key(at)));
|
||||
if (ask.length) {
|
||||
const got = await previewStructureSections(projectId, ask);
|
||||
ask.forEach((at) => previews.set(key(at), got.find((s) => near(s.chainage_m, at)) ?? null));
|
||||
}
|
||||
return chainages.flatMap((at) => previews.get(key(at)) ?? []);
|
||||
}
|
||||
|
||||
/** 미리 뜬 횡단에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */
|
||||
function decorate(
|
||||
fresh: CrossSection[],
|
||||
pipes: Pipes,
|
||||
structures: StructureInstance[],
|
||||
types: StructureType[],
|
||||
): void {
|
||||
const registry = registryDefaults(types);
|
||||
const byId = new Map(types.map((type) => [type.type_id, type]));
|
||||
const pipeLabel = (pipe: Pipes[number]): string | null => {
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
if (facility !== "pipe") return byId.get(facility)?.name ?? facility;
|
||||
const values = { ...registry.pipe, ...pipe.options };
|
||||
return `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`;
|
||||
};
|
||||
const labels: Array<[number, string]> = [
|
||||
...pipes.flatMap((pipe) => {
|
||||
const label = pipeLabel(pipe);
|
||||
return label
|
||||
? pipeStationMarks(pipe).map((m): [number, string] => [m.chainage_m, label])
|
||||
: [];
|
||||
}),
|
||||
...structures.flatMap((structure) => {
|
||||
const type = byId.get(structure.type_id);
|
||||
const label = type?.name ?? structure.type_id;
|
||||
return structureStationMarks(structure, type).map((m): [number, string] => [
|
||||
m.chainage_m,
|
||||
label,
|
||||
]);
|
||||
}),
|
||||
];
|
||||
for (const section of fresh) {
|
||||
section.preview = true;
|
||||
const hit = labels.find(([at]) => near(at, section.chainage_m));
|
||||
if (hit) section.structure = hit[1];
|
||||
}
|
||||
const own = pipes.filter((pipe) =>
|
||||
fresh.some((section) => near(section.chainage_m, pipe.chainage_m)),
|
||||
);
|
||||
attachCulvertSets(
|
||||
fresh as unknown as Array<Record<string, unknown>>,
|
||||
buildCulvertSets(own, registry),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 초안에 맞춰 상세의 횡단 목록을 고친다 — 옛 자리 숨김 · 새 자리 미리 뜬 횡단 얹음. 초안이 없으면
|
||||
* 서버 목록 그대로(빼 둔 것 돌려 놓음). 미리 뜨기를 못 하면(샘플링 조건 없음 · 끊김) 새 자리는
|
||||
* 종전처럼 [저장] 뒤에 섬.
|
||||
*/
|
||||
export async function withDraftStations(
|
||||
detail: SectionDetailResponse,
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): Promise<void> {
|
||||
const turn = (turns.get(detail) ?? 0) + 1;
|
||||
turns.set(detail, turn);
|
||||
const pipesDraft = readPendingPipes(projectId);
|
||||
const structuresDraft = readPendingStructures(projectId);
|
||||
const all = [
|
||||
...detail.cross_sections.filter((section) => !section.preview),
|
||||
...(parked.get(detail) ?? []),
|
||||
].sort(byChainage);
|
||||
let live = all;
|
||||
let stale: CrossSection[] = [];
|
||||
let fresh: CrossSection[] = [];
|
||||
if (pipesDraft || structuresDraft) {
|
||||
const inputs = await Promise.all([
|
||||
pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes),
|
||||
structuresDraft ?? savedOf(projectId).then(([, structures]) => structures),
|
||||
fetchStructureTypes(true),
|
||||
]).catch(() => null);
|
||||
if (inputs) {
|
||||
const [pipes, structures, types] = inputs;
|
||||
const planted = plantedChainages(pipes, structures, types);
|
||||
const isStale = (section: CrossSection): boolean =>
|
||||
section.kind === "irregular" &&
|
||||
!section.grid &&
|
||||
planted.every((at) => !near(at, section.chainage_m));
|
||||
stale = all.filter(isStale);
|
||||
live = all.filter((section) => !isStale(section));
|
||||
const missing = planted.filter(
|
||||
(at, index) =>
|
||||
planted.findIndex((other) => near(other, at)) === index &&
|
||||
live.every((section) => !near(section.chainage_m, at)),
|
||||
);
|
||||
fresh = missing.length ? await previewOf(projectId, routeId, missing).catch(() => []) : [];
|
||||
if (fresh.length) decorate(fresh, pipes, structures, types);
|
||||
}
|
||||
}
|
||||
if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음
|
||||
detail.cross_sections.splice(
|
||||
0,
|
||||
detail.cross_sections.length,
|
||||
...[...live, ...fresh].sort(byChainage),
|
||||
);
|
||||
parked.set(detail, stale);
|
||||
}
|
||||
@@ -40,6 +40,12 @@ import {
|
||||
type PavementStructureInput,
|
||||
} from "@util/common_util_structure_pavement";
|
||||
import { setGeneralStations } from "../common_util/common_util_structure_station";
|
||||
import {
|
||||
attachUnderdrainSpecs,
|
||||
underdrainSpecsFrom,
|
||||
type UnderdrainInput,
|
||||
} from "../common_util/common_util_structure_underdrain";
|
||||
import { forgetDraftStations, withDraftStations } from "./B06_Section_Section_Drafts";
|
||||
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
|
||||
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
|
||||
@@ -63,14 +69,21 @@ function keyOf(projectId: string, routeId: number): string {
|
||||
async function withDraftWalls(
|
||||
detail: SectionDetailResponse,
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): Promise<SectionDetailResponse> {
|
||||
setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1)
|
||||
await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2)
|
||||
const drafts = readPendingStructures(projectId);
|
||||
// ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이
|
||||
// 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도
|
||||
// 횡단도에 아무것도 안 보이던 결함의 원인이다(창 둘에서 같은 증상, 실측 확인).
|
||||
await withDraftPipeKinds(detail, projectId);
|
||||
if (!drafts || !drafts.length) return withStructureAreas(detail);
|
||||
// 맹암거(288-1) — 초안 기준 측점으로 제원을 다시 얹음(짝 `common_util_structure_underdrain`)
|
||||
attachUnderdrainSpecs(
|
||||
detail.cross_sections as unknown as Array<Record<string, unknown>>,
|
||||
underdrainSpecsFrom(drafts as unknown as UnderdrainInput[]),
|
||||
);
|
||||
const types = await fetchStructureTypes().catch(() => []);
|
||||
// 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`)
|
||||
const paveTypes = new Set(types.filter((type) => type.group === "G").map((type) => type.type_id));
|
||||
@@ -144,8 +157,8 @@ async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: stri
|
||||
const spec = section[key] as Record<string, unknown> | undefined;
|
||||
if (spec && stale.some((pipe) => near(spec, section, pipe.chainage_m))) delete section[key];
|
||||
}
|
||||
// 카드 제목 구조물 이름 — 서버 `resolve_extra_stations` 라벨 규칙(배관 = 관종 D관경 · 물넘이 = 없음)
|
||||
// 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점(물넘이로 지웠다 되돌린 때)
|
||||
// 카드 제목 구조물 이름 — 서버 `resolve_extra_stations` 라벨 규칙(배관 = 관종 D관경 · 그 밖 = 종류 이름)
|
||||
// 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점
|
||||
const pipe = stale.find((entry) => {
|
||||
const gap = Math.abs(entry.chainage_m - Number(section.chainage_m));
|
||||
return section.structure ? gap < PIPE_MATCH_M : gap < 0.01;
|
||||
@@ -153,8 +166,7 @@ async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: stri
|
||||
if (!pipe) continue;
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
const values = { ...registry.pipe, ...pipe.options };
|
||||
if (facility === "ford_pavement") delete section.structure;
|
||||
else if (facility === "pipe")
|
||||
if (facility === "pipe")
|
||||
section.structure = `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`;
|
||||
else section.structure = types.find((type) => type.type_id === facility)?.name ?? facility;
|
||||
}
|
||||
@@ -185,21 +197,21 @@ export async function loadSectionDetail(
|
||||
const key = keyOf(projectId, routeId);
|
||||
if (!options?.force) {
|
||||
const cached = cache.get(key);
|
||||
if (cached) return withDraftWalls(cached, projectId);
|
||||
if (cached) return withDraftWalls(cached, projectId, routeId);
|
||||
const inFlight = pending.get(key);
|
||||
if (inFlight) return inFlight;
|
||||
// 새로고침으로 메모리가 빈 경우 — 세션에 얹어 둔 한 벌로 바로 선다.
|
||||
const stored = readState<SectionDetailResponse>("section-detail", projectId, routeId);
|
||||
if (stored) {
|
||||
cache.set(key, stored);
|
||||
return withDraftWalls(stored, projectId);
|
||||
return withDraftWalls(stored, projectId, routeId);
|
||||
}
|
||||
}
|
||||
const request = fetchSectionDetail(projectId, routeId)
|
||||
.then(async (detail) => {
|
||||
cache.set(key, detail);
|
||||
writeState("section-detail", detail, projectId, routeId);
|
||||
return withDraftWalls(detail, projectId);
|
||||
return withDraftWalls(detail, projectId, routeId);
|
||||
})
|
||||
.finally(() => {
|
||||
pending.delete(key);
|
||||
@@ -224,6 +236,7 @@ export function replaceSectionDetail(
|
||||
* 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다.
|
||||
*/
|
||||
export function invalidateSectionDetail(projectId: string, routeId?: number): void {
|
||||
forgetDraftStations(projectId);
|
||||
if (routeId !== undefined) {
|
||||
cache.delete(keyOf(projectId, routeId));
|
||||
clearState("section-detail", projectId, routeId);
|
||||
|
||||
@@ -51,6 +51,13 @@ const EXTRA_ROWS = [
|
||||
const CHUTE_BOX =
|
||||
'fieldset[data-spec-container="chute_add"], fieldset[data-spec-container="chute"]';
|
||||
|
||||
/** 물넘이포장 「경사각도」 상자 — 콘크리트 형상 고름이 켬(294-2 · 「관」 상자가 없는 시설). */
|
||||
const SLOPE_BOX = 'fieldset[data-spec-container="slope"]';
|
||||
|
||||
/** 숨지 않은(지금 보이는 명세의) 상자 하나. */
|
||||
const visibleBox = (selector: string): HTMLElement | undefined =>
|
||||
[...document.querySelectorAll<HTMLElement>(selector)].find((box) => !box.closest("[hidden]"));
|
||||
|
||||
/** 연동을 푼 옆 카드의 「이 측점 높이」 줄 — 어느 칸이든 받음(152-2). */
|
||||
const OWN_HEIGHT_ROW = ".b06-structure-panel__struct--own-height";
|
||||
|
||||
@@ -101,13 +108,17 @@ function slotOf(side: "inlet" | "outlet"): HTMLElement {
|
||||
* 나오지 않는다(2026-08-29 사용자 보고: 단 수·옵션이 사라짐). */
|
||||
const movedRows: Array<{ row: HTMLElement; anchor: Comment }> = [];
|
||||
|
||||
/** 그룹 이름표 — 원래 문구를 한 번만 적어 두고, 선택에 따라 괄호 표기를 붙였다 뗀다. */
|
||||
/** 그룹 이름표 — 원래 문구를 한 번만 적어 두고, 선택에 따라 괄호 표기를 붙였다 뗀다.
|
||||
* 글 칸(첫 글 마디)만 고침 — 이름표에 붙은 [삭제] 단추는 그대로(294-1). */
|
||||
function setGroupLabel(group: HTMLElement, suffix: string): void {
|
||||
const legend = group.querySelector<HTMLElement>("legend");
|
||||
if (!legend || legend.dataset.fixedLabel) return;
|
||||
legend.dataset.baseLabel ??= legend.textContent ?? "";
|
||||
const text =
|
||||
[...legend.childNodes].find((node) => node.nodeType === Node.TEXT_NODE) ??
|
||||
legend.insertBefore(document.createTextNode(""), legend.firstChild);
|
||||
legend.dataset.baseLabel ??= text.textContent ?? "";
|
||||
const base = legend.dataset.baseLabel;
|
||||
legend.textContent = suffix ? `${base} (${suffix})` : base;
|
||||
text.textContent = suffix ? `${base} (${suffix})` : base;
|
||||
}
|
||||
|
||||
/** 옮겨 온 행을 **원래 자리로 되돌리고** 슬롯을 비운다. 그룹 강조도 끈다. */
|
||||
@@ -163,14 +174,12 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void {
|
||||
clearSideSlots();
|
||||
const side = panelRoot.dataset.side;
|
||||
// 세월교 · BOX 본체 = 옮길 줄 없이 그 명세의 본체 상자만 강조(66-1) — 날개벽 칸이 든 명세 폼에서 찾음
|
||||
// 관 = 배관 명세 폼의 「관」 상자만 강조(118-1)
|
||||
// 관 = 배관 명세 폼의 「관」 상자만 강조(118-1) · 물넘이포장 콘크리트 = 「경사각도」 상자(294-2)
|
||||
// 도수로 띠 = 보이는 명세의 「도수로/돌붙임」 상자(배관 · 박스 · 세월교 chute_add · 물넘이 chute · 152-4)
|
||||
if (side === "body" || side === "pipe" || side === "chute") {
|
||||
const body =
|
||||
const own =
|
||||
side === "chute"
|
||||
? [...document.querySelectorAll<HTMLElement>(CHUTE_BOX)].find(
|
||||
(box) => !box.closest("[hidden]"),
|
||||
)
|
||||
? visibleBox(CHUTE_BOX)
|
||||
: (side === "pipe" ? sideSlots?.inlet : sideSlots?.wingInlet)
|
||||
?.closest(".b05-structure__spec")
|
||||
?.querySelector<HTMLElement>(
|
||||
@@ -178,6 +187,7 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void {
|
||||
? 'fieldset[data-spec-container="pipe"]'
|
||||
: 'fieldset[data-spec-container="ford"], fieldset[data-spec-container="body"]',
|
||||
);
|
||||
const body = side === "pipe" && (!own || own.closest("[hidden]")) ? visibleBox(SLOPE_BOX) : own;
|
||||
if (!body || body.closest("[hidden]")) return;
|
||||
setGroupLabel(body, "");
|
||||
body.classList.add("is-active");
|
||||
|
||||
@@ -166,6 +166,7 @@ export function appendBoxOverlay(
|
||||
box.chute,
|
||||
x,
|
||||
toDisplayY,
|
||||
layout.ground,
|
||||
);
|
||||
// ⑥ 끝 바깥 절 · 성토선 — 세월교 측벽과 같은 체계(79-7). 흙막이 쪽은 기하가 걷어 둠.
|
||||
for (const side of layout.sides) {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* 부재 두께는 세월교 승계(측벽 0.2·상판 0.3·저판 0.3)이며 백엔드가 실어 보낸다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { groundLine } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
import type { BoxSet, CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
|
||||
import type {
|
||||
BasinLayout,
|
||||
@@ -74,6 +75,8 @@ export interface BoxSideLayout {
|
||||
|
||||
export interface BoxLayout {
|
||||
box: BoxSet;
|
||||
/** 원지반 — 도수로 · 돌붙임 길이가 사면보다 길면 이어 그림(294-4). */
|
||||
ground?: OffsetPoint[];
|
||||
sides: BoxSideLayout[];
|
||||
/** 상판 폴리곤(좌·우 표고가 다르면 기울어진다). */
|
||||
topSlab: OffsetPoint[];
|
||||
@@ -222,6 +225,7 @@ export function computeBoxLayout(
|
||||
const apronLengthOf = (side: BoxSideLayout): number =>
|
||||
side.apron.length ? Math.abs(side.apron[1].offset - side.apron[0].offset) : 0;
|
||||
return {
|
||||
ground: groundLine(groundSamples),
|
||||
box,
|
||||
sides: [left, right],
|
||||
topSlab: quad(0, barrelTopDrop),
|
||||
|
||||
@@ -125,6 +125,7 @@ export function appendCulvertOverlay(
|
||||
culvert.chute,
|
||||
x,
|
||||
toDisplayY,
|
||||
layout.ground,
|
||||
);
|
||||
for (const segment of layout.outletFill.segments) {
|
||||
// 묻힌 유출 끝 = 3도 수평 절토선(보라 · 면적에도 듦 · 139-4) — 3도 선이 원지반을
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* (designTrim). 유입 = 상단측. 집수정은 `_Basin.ts`, 성토부·다단은 `_Extra.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { groundLine } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
revetWallSpec,
|
||||
@@ -575,6 +576,7 @@ export function computeCulvertLayout(
|
||||
const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null;
|
||||
|
||||
return {
|
||||
ground: groundLine(groundSamples),
|
||||
inletOptions,
|
||||
culvert,
|
||||
pipe: { inlet: pipeStart, outlet, lengthM, slopePct },
|
||||
|
||||
@@ -201,6 +201,8 @@ export interface OutletFillSegment {
|
||||
|
||||
export interface CulvertLayout {
|
||||
culvert: CulvertSet;
|
||||
/** 원지반 — 도수로 · 돌붙임 길이가 사면보다 길면 이어 그림(294-4). */
|
||||
ground?: OffsetPoint[];
|
||||
pipe: { inlet: OffsetPoint; outlet: OffsetPoint; lengthM: number; slopePct: number };
|
||||
/** 관 축·법선 단위벡터(그리기가 두께 방향으로 밀 때 쓴다). */
|
||||
pipeAxis: { axis: OffsetPoint; normal: OffsetPoint };
|
||||
|
||||
@@ -41,7 +41,8 @@ export interface RevetOffsetControl {
|
||||
* (2026-08-30 사용자: 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다).
|
||||
* 조정창은 고른 카드 하나에만 뜨므로 `selectedFor`와 따로 둔다. */
|
||||
highlightFor: (section: CrossSection) => RevetPick | null;
|
||||
select: (chainageM: number, key: RevetPick | null) => void;
|
||||
/** `ownerM` = 폼에 올릴 시설 자리를 누른 쪽이 앎(물넘이 콘크리트 · 294-7) — 없으면 카드로 셈. */
|
||||
select: (chainageM: number, key: RevetPick | null, ownerM?: number) => void;
|
||||
/** 선택이 바뀌면 화면이 연동 카드 강조를 다시 칠한다 — 목록 쪽이 꽂는다. */
|
||||
onSelectionChange?: () => void;
|
||||
/** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */
|
||||
|
||||
@@ -33,7 +33,7 @@ const HIT_CLASS = "b06-chart__design-hit";
|
||||
/** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */
|
||||
export const DESIGN_LINE_ATTR = "data-design-line";
|
||||
/** 보라 선을 덮는 벽 · 본체 폴리곤. */
|
||||
const OCCLUDER_CLASSES = [
|
||||
export const OCCLUDER_CLASSES = [
|
||||
"b06-chart__culvert-revet",
|
||||
"b06-chart__culvert-basin",
|
||||
"b06-chart__ford-slab",
|
||||
@@ -419,7 +419,7 @@ export function attachDesignLinePick(input: {
|
||||
const pieces = visiblePieces(mergeStraightPieces(runs), occluders);
|
||||
if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces));
|
||||
|
||||
// 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김.
|
||||
// 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1).
|
||||
const firstStructureHit = kids.find((el) =>
|
||||
el.classList.contains("b06-chart__culvert-revet-hit"),
|
||||
);
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
*
|
||||
* 위 = 카드 그리기 함수로 칸 크기에 맞춰 새로 그린 그림의 복제(173-4 · 테마 색 · 칸 가득 ·
|
||||
* 칸 크기가 바뀌면 다시 그림) ·
|
||||
* 가운데 = 도면 표 위 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄 ·
|
||||
* B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) ·
|
||||
* 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) ·
|
||||
* 가운데 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) ·
|
||||
* 칸 우클릭 = 그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움(179-4 · `_Cross_Detail_Table`).
|
||||
* 아래 = 도면 표 위 빈 CAD(260 · `_Cross_Detail_Cad` · `design.structure_note_cad` · 세션 초안 →
|
||||
* [저장]이 patch 로 내보냄 · B07 횡단도 수량표 위에 좌상 맞춤 · 옛 글은 처음 열 때 옮김) ·
|
||||
* 머리 한 줄 접기(265-1 · 구조물 측점 폄 · 일반 측점 접힘).
|
||||
* 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2).
|
||||
* 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) —
|
||||
* 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3).
|
||||
@@ -16,7 +17,7 @@
|
||||
|
||||
import "./B06_Section_UI_Style_Cross_Detail.css";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
import { fetchCrossQuantityTable } from "./B06_Section_Api_Fetch";
|
||||
import type {
|
||||
CrossQuantitySheet,
|
||||
@@ -24,27 +25,39 @@ import type {
|
||||
SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { CROSS_PAD, inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common";
|
||||
import { DETAIL_TARGETS, highlightDetail, presentKeys } from "./B06_Section_UI_Cross_Detail_Pick";
|
||||
import {
|
||||
DETAIL_TARGETS,
|
||||
highlightDetail,
|
||||
outlinePicked,
|
||||
presentKeys,
|
||||
} from "./B06_Section_UI_Cross_Detail_Pick";
|
||||
import {
|
||||
attachSvgZoomPan,
|
||||
type ZoomPanHandle,
|
||||
type ZoomPanState,
|
||||
} from "../A00_Common/b_svg_zoom_pan";
|
||||
import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import { detailHomeView, layerPoints } from "./B06_Section_UI_Cross_Detail_Home";
|
||||
import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table";
|
||||
import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
|
||||
import {
|
||||
loadNoteThings,
|
||||
mountNoteCad,
|
||||
stationNoLabel,
|
||||
thingsAt,
|
||||
} from "./B06_Section_UI_Cross_Detail_Cad";
|
||||
|
||||
/** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */
|
||||
export interface CrossDetailHost {
|
||||
projectId: string;
|
||||
routeId: () => number | null;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
/** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션(173-4 · 179-3 · 뷰 `detailChart`). */
|
||||
/** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션 + 처음 배율(173-4 · 179-3 · 266-1 · 뷰 `detailChart`). */
|
||||
chart: (
|
||||
stationId: string,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;
|
||||
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;
|
||||
}
|
||||
|
||||
let host: CrossDetailHost | null = null;
|
||||
@@ -52,9 +65,7 @@ export function setCrossDetailHost(next: CrossDetailHost): void {
|
||||
host = next;
|
||||
}
|
||||
|
||||
/* ── 구조물 글 세션 초안(측점키 0.01 m → 글) ─────────────────────────────── */
|
||||
|
||||
const noteKey = (chainageM: number): string => chainageM.toFixed(2);
|
||||
/* ── 옛 구조물 글 세션 초안(측점키 0.01 m → 글 · 새 글은 CAD 칸 260) ─────────────── */
|
||||
|
||||
/** [저장]이 patch 로 싣는 글 — 측점(m) → 글(빈 글 = 지움). */
|
||||
export function readStructureNotes(projectId: string, routeId: number): Map<number, string> {
|
||||
@@ -62,26 +73,6 @@ export function readStructureNotes(projectId: string, routeId: number): Map<numb
|
||||
return new Map(Object.entries(raw).map(([key, text]) => [Number(key), String(text)]));
|
||||
}
|
||||
|
||||
function noteOf(section: CrossSection): string {
|
||||
const routeId = host?.routeId();
|
||||
if (host && routeId !== null && routeId !== undefined) {
|
||||
const draft = readState<Record<string, string>>("structure-notes", host.projectId, routeId);
|
||||
const text = draft?.[noteKey(section.chainage_m)];
|
||||
if (text !== undefined) return text;
|
||||
}
|
||||
return section.design?.structure_note ?? "";
|
||||
}
|
||||
|
||||
function writeNote(section: CrossSection, text: string): void {
|
||||
// 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음
|
||||
if (section.design) section.design.structure_note = text;
|
||||
const routeId = host?.routeId();
|
||||
if (!host || routeId === null || routeId === undefined) return;
|
||||
const all = readState<Record<string, string>>("structure-notes", host.projectId, routeId) ?? {};
|
||||
all[noteKey(section.chainage_m)] = text;
|
||||
writeState("structure-notes", all, host.projectId, routeId);
|
||||
}
|
||||
|
||||
/* ── 표(B07 칸 배치 그대로) ─────────────────────────────────────────────── */
|
||||
|
||||
function cell(
|
||||
@@ -132,18 +123,25 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
|
||||
if (repick.has(key)) td.dataset.repick = "";
|
||||
return mark(td, { cell: key, row, col }, isAdded(key));
|
||||
};
|
||||
// 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2)
|
||||
// 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2) · 합친 묶음 = 덮는 줄 키 모두(259-3)
|
||||
const nameCell = (
|
||||
label: string | null,
|
||||
span: { col?: number; row?: number } | undefined,
|
||||
key: string | null,
|
||||
row: string,
|
||||
slot: number,
|
||||
keys?: Array<string | null>,
|
||||
): HTMLElement => {
|
||||
const added = row.startsWith("r");
|
||||
const data = { row, label: added ? `${row}:${slot}` : undefined };
|
||||
const data = {
|
||||
row,
|
||||
label: added ? `${row}:${slot}` : undefined,
|
||||
keys: keys && keys.length > 1 ? keys.filter(Boolean).join(" ") : undefined,
|
||||
};
|
||||
return mark(cell("th", label ?? "", span, key), data, added);
|
||||
};
|
||||
const groupKeys = (slots: typeof layout.left, index: number) =>
|
||||
slots.slice(index, index + groupSpan(slots, index)).map((slot) => slot[2]);
|
||||
const table = document.createElement("table");
|
||||
table.className = "b06-detail__qtable";
|
||||
const titleRow = table.insertRow();
|
||||
@@ -158,13 +156,31 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
|
||||
const row = table.insertRow();
|
||||
// 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) · 묶음 = 하위 줄만큼(179-4)
|
||||
if (groupL !== null && subL !== null)
|
||||
row.append(nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0));
|
||||
row.append(
|
||||
nameCell(
|
||||
groupL,
|
||||
{ row: groupSpan(layout.left, index) },
|
||||
keyL,
|
||||
id,
|
||||
0,
|
||||
groupKeys(layout.left, index),
|
||||
),
|
||||
);
|
||||
else if (groupL !== null) row.append(nameCell(groupL, { col: 2 }, keyL, id, 0));
|
||||
if (subL !== null) row.append(nameCell(subL, undefined, keyL, id, 0));
|
||||
row.append(valueCell(keyL, id));
|
||||
const [groupM, subM, keyM] = layout.middle[index];
|
||||
if (groupM !== null && subM !== null)
|
||||
row.append(nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1));
|
||||
row.append(
|
||||
nameCell(
|
||||
groupM,
|
||||
{ row: groupSpan(layout.middle, index) },
|
||||
keyM,
|
||||
id,
|
||||
1,
|
||||
groupKeys(layout.middle, index),
|
||||
),
|
||||
);
|
||||
else if (groupM !== null) row.append(nameCell(groupM, { col: 2 }, keyM, id, 1));
|
||||
if (subM !== null) row.append(nameCell(subM, undefined, keyM, id, 1));
|
||||
row.append(valueCell(keyM, id));
|
||||
@@ -178,12 +194,14 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
|
||||
/* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */
|
||||
|
||||
/** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 ·
|
||||
* 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */
|
||||
* 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴) ·
|
||||
* 처음 · 원복 = 중심점이 칸 가운데 · 그림 전체(가로 = 카드 반폭 266-1 · 세로 = 그 안 그림)(295-2). */
|
||||
export function attachDetailZoom(
|
||||
clone: SVGSVGElement,
|
||||
axes: CrossAxesOptions | null,
|
||||
initial: ZoomPanState | undefined,
|
||||
onChange: (state: ZoomPanState) => void,
|
||||
homeScale = 1,
|
||||
): ZoomPanHandle | null {
|
||||
const layer = clone.querySelector<SVGGElement>("g.b06-chart__plot-layer");
|
||||
if (!layer) return null;
|
||||
@@ -203,6 +221,16 @@ export function attachDetailZoom(
|
||||
);
|
||||
};
|
||||
follow(initial ?? IDENTITY_VIEW);
|
||||
const plot = {
|
||||
x: CROSS_PAD.left,
|
||||
y: CROSS_PAD.top,
|
||||
width: width - CROSS_PAD.left - CROSS_PAD.right,
|
||||
height: height - CROSS_PAD.top - CROSS_PAD.bottom,
|
||||
};
|
||||
const home =
|
||||
markerLine && Number.isFinite(cx) && Number.isFinite(cy)
|
||||
? detailHomeView(layerPoints(layer), [cx, cy], plot, homeScale, 16)
|
||||
: undefined;
|
||||
return attachSvgZoomPan({
|
||||
svg: clone,
|
||||
layer,
|
||||
@@ -214,6 +242,8 @@ export function attachDetailZoom(
|
||||
content: { x: -width, y: -height, width: 3 * width, height: 3 * height },
|
||||
wheelZoom: true,
|
||||
initial,
|
||||
homeScale,
|
||||
home,
|
||||
onChange: (state) => {
|
||||
follow(state);
|
||||
onChange(state);
|
||||
@@ -242,7 +272,7 @@ function drawChart(
|
||||
section: CrossSection,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
): { clone: SVGSVGElement; axes: CrossAxesOptions | null } | null {
|
||||
): { clone: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null {
|
||||
drawing = true;
|
||||
try {
|
||||
const drawn = host?.chart(section.station_id, widthPx, heightPx);
|
||||
@@ -255,7 +285,7 @@ function drawChart(
|
||||
.querySelectorAll(`[clip-path="url(#${id})"]`)
|
||||
.forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`));
|
||||
});
|
||||
return { clone, axes: drawn.axes };
|
||||
return { clone, axes: drawn.axes, homeScale: drawn.homeScale };
|
||||
} finally {
|
||||
drawing = false;
|
||||
}
|
||||
@@ -277,36 +307,80 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
dialogClass: "b06-detail",
|
||||
wide: true,
|
||||
mount(body, close) {
|
||||
const note = document.createElement("textarea");
|
||||
note.className = "ui-input b06-detail__note";
|
||||
note.rows = 3;
|
||||
note.placeholder = "도면 표 위 글 — B07 횡단도 수량표 바로 위에 줄마다 찍힘";
|
||||
note.value = noteOf(section);
|
||||
note.addEventListener("input", () => writeNote(state.section, note.value));
|
||||
// 표 위 빈 CAD(260-2) — 높이 고정 · 닫으면 destroy · 머리 한 줄 접기(265-1 · 공용 접기 꼴) ·
|
||||
// 열 때 = 구조물 측점 폄 · 일반 측점 접힘(그 창에서만) · 처음 펼 때 한 번 만듦(접힌 동안 칸 ·
|
||||
// 초안 · [저장] 그대로 — 안 연 칸은 B07 이 같은 짝으로 그림)
|
||||
const noteBox = document.createElement("section");
|
||||
noteBox.className = "b06-detail__notebox ui-collapsible";
|
||||
const noteHead = document.createElement("div");
|
||||
noteHead.className = "b06-detail__notehead ui-collapsible__title";
|
||||
noteHead.textContent = "표 위 CAD";
|
||||
const note = document.createElement("div");
|
||||
note.className = "b06-detail__note";
|
||||
note.style.height = "180px";
|
||||
noteBox.append(noteHead, note);
|
||||
const routeId = host?.routeId();
|
||||
const stations = host?.detail()?.longitudinal.stations ?? [];
|
||||
let noteCad: { destroy: () => void } | null = null;
|
||||
const openNote = (): void => {
|
||||
if (noteCad || !host || routeId === null || routeId === undefined) return;
|
||||
noteCad = mountNoteCad(note, section, () => state.section, {
|
||||
projectId: host.projectId,
|
||||
routeId,
|
||||
station: stationNoLabel(section.chainage_m, inferStationInterval(stations)),
|
||||
things,
|
||||
});
|
||||
note.querySelector<HTMLElement>(".cad-host")?.style.setProperty("min-height", "0");
|
||||
};
|
||||
let touched = false;
|
||||
noteHead.addEventListener("click", () => {
|
||||
touched = true;
|
||||
if (!noteBox.classList.toggle("is-collapsed")) openNote();
|
||||
});
|
||||
// 접힘으로 시작 → 그 측점에 놓인 구조물이 있으면 폄(사용자가 먼저 누르면 그대로)
|
||||
noteBox.classList.add("is-collapsed");
|
||||
const things = host ? loadNoteThings(host.projectId) : Promise.resolve([]);
|
||||
void things.then((list) => {
|
||||
if (touched || current !== state || !thingsAt(list, section.chainage_m).length) return;
|
||||
noteBox.classList.remove("is-collapsed");
|
||||
openNote();
|
||||
});
|
||||
const cad = document.createElement("div");
|
||||
cad.className = "b06-detail__cad";
|
||||
const tableBox = document.createElement("div");
|
||||
tableBox.className = "b06-detail__table";
|
||||
body.append(cad, note, tableBox);
|
||||
// 차례 = 그림 → 표 → CAD 칸(265-1)
|
||||
body.append(cad, tableBox, noteBox);
|
||||
|
||||
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2)
|
||||
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) ·
|
||||
// 저장된 조각 · 띠 칸 = 그래프에서 고름 + 값 글(259-2) · 고른 칸 묶음 = 바깥 한 줄(259-3)
|
||||
let picked: string | null = null;
|
||||
const showPicked = (): void => {
|
||||
const svg = cad.querySelector("svg");
|
||||
const present = svg ? presentKeys(svg, state.section.design) : new Set<string>();
|
||||
if (picked && !present.has(picked)) picked = null;
|
||||
tableBox.querySelectorAll<HTMLElement>("[data-key]").forEach((el) => {
|
||||
el.classList.toggle("is-picked", el.dataset.key === picked);
|
||||
el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""));
|
||||
const graph = picked ? tablePick.graph(picked) : null;
|
||||
if (picked && !graph && !present.has(picked)) picked = null;
|
||||
tableBox.querySelectorAll<HTMLElement>("[data-key], [data-cell]").forEach((el) => {
|
||||
const keys = (el.dataset.keys ?? el.dataset.key ?? el.dataset.cell ?? "").split(" ");
|
||||
el.classList.toggle("is-picked", picked !== null && keys.includes(picked));
|
||||
if (el.dataset.key)
|
||||
el.toggleAttribute(
|
||||
"data-empty",
|
||||
!present.has(el.dataset.key) && !tablePick.graph(el.dataset.key),
|
||||
);
|
||||
});
|
||||
if (svg) highlightDetail(svg, state.section.design, picked);
|
||||
outlinePicked(tableBox);
|
||||
const band = graph && "band" in graph ? graph.band : graph ? null : picked;
|
||||
if (svg) highlightDetail(svg, state.section.design, band);
|
||||
};
|
||||
tableBox.addEventListener("click", (event) => {
|
||||
const el = (event.target as HTMLElement).closest<HTMLElement>("[data-key]");
|
||||
const key = el?.dataset.key;
|
||||
if (!key || el.hasAttribute("data-empty")) return;
|
||||
const el = (event.target as HTMLElement).closest<HTMLElement>("[data-key], [data-cell]");
|
||||
const key = el?.dataset.key ?? el?.dataset.cell;
|
||||
if (!el || !key) return;
|
||||
if (!tablePick.graph(key) && (!el.dataset.key || el.hasAttribute("data-empty"))) return;
|
||||
picked = picked === key ? null : key;
|
||||
showPicked();
|
||||
tablePick.showCell(picked);
|
||||
});
|
||||
|
||||
// 모달 배율 — 다시 그려도 이어 감 · 칸 크기가 바뀌면 원복(173-4)
|
||||
@@ -330,7 +404,13 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
else {
|
||||
const { clone } = drawn;
|
||||
axes = drawn.axes;
|
||||
const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next));
|
||||
const handle = attachDetailZoom(
|
||||
clone,
|
||||
axes,
|
||||
zoom,
|
||||
(next) => (zoom = next),
|
||||
drawn.homeScale,
|
||||
);
|
||||
cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []));
|
||||
tablePick.redraw(); // 선 조각 고르기(209-1)
|
||||
}
|
||||
@@ -369,6 +449,7 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
stop: () => {
|
||||
resize.disconnect();
|
||||
tablePick.detach();
|
||||
noteCad?.destroy();
|
||||
},
|
||||
};
|
||||
// 표 손질(179-4) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Detail_Cad.ts
|
||||
* 횡단 [상세] 모달 아래 = 표 위 CAD 칸(260 · 요구 162-21 · 표 아래 접기 265-1).
|
||||
*
|
||||
* 칸 = `design.structure_note_cad`(CAD 저장 꼴 · 엔티티 · 층) · 세션 초안(`structure-note-cads` ·
|
||||
* 측점키) → [저장]이 patch 로 내보냄 → B07 횡단도가 수량표 위에 좌상 맞춤으로 놓음
|
||||
* (`_Engine_Cad_Table.cross_note_cad_entities`).
|
||||
* 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없으면 미리 넣음
|
||||
* (265-2 · 관 주인 측점 = 원형 마크 · 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 =
|
||||
* 「이름 조합 미지정」 · 이어서 좌측 패널 옵션 줄 = `structureOptionLines` · 166-1) · 그 밖 = 빈 도면.
|
||||
* ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `noteEntities` ·
|
||||
* `structureNoteLines` · `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연
|
||||
* 측점은 B07 이 그것으로 그림 · 거울 시험 `test_260_note_cad.py` · `test_265_note_cad.py`.
|
||||
* 좌표 = 도면 mm(B07 표와 같은 자 · 글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host";
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import {
|
||||
fetchStructures,
|
||||
fetchStructureTypes,
|
||||
readPendingStructures,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { structureOptionLines } from "../B05_Profile/B05_Profile_UI_Structures_Balloon";
|
||||
import {
|
||||
readGroupItems,
|
||||
readSummaryItem,
|
||||
summaryLabel,
|
||||
type SummaryItem,
|
||||
} from "../common_util/common_util_summary_item";
|
||||
import type { CrossNoteCad, CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import { pipeLengthWhole } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
|
||||
const noteKey = (chainageM: number): string => chainageM.toFixed(2);
|
||||
|
||||
/** [저장]이 patch 로 싣는 CAD 칸 — 측점(m) → 엔티티 묶음. */
|
||||
export function readStructureNoteCads(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): Map<number, CrossNoteCad> {
|
||||
const raw = readState<Record<string, CrossNoteCad>>("structure-note-cads", projectId, routeId);
|
||||
return new Map(Object.entries(raw ?? {}).map(([key, cad]) => [Number(key), cad]));
|
||||
}
|
||||
|
||||
function writeNoteCad(section: CrossSection, cad: CrossNoteCad, at: NoteCadPlace): void {
|
||||
// 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음
|
||||
if (section.design) section.design.structure_note_cad = cad;
|
||||
const all = readState<Record<string, CrossNoteCad>>(
|
||||
"structure-note-cads",
|
||||
at.projectId,
|
||||
at.routeId,
|
||||
);
|
||||
const next = { ...all, [noteKey(section.chainage_m)]: cad };
|
||||
writeState("structure-note-cads", next, at.projectId, at.routeId);
|
||||
}
|
||||
|
||||
/** 세션 초안 → 저장분 → 없음(null). */
|
||||
function noteCadOf(section: CrossSection, at: NoteCadPlace): CrossNoteCad | null {
|
||||
const draft = readState<Record<string, CrossNoteCad>>(
|
||||
"structure-note-cads",
|
||||
at.projectId,
|
||||
at.routeId,
|
||||
);
|
||||
return draft?.[noteKey(section.chainage_m)] ?? section.design?.structure_note_cad ?? null;
|
||||
}
|
||||
|
||||
/* ── 엔티티(CAD 저장 꼴 · B07 `_text_entity` · `_circle_entity` 와 같은 꼴) ───────── */
|
||||
|
||||
const LAYER_ID = "기본";
|
||||
const BLANK_LAYER = { id: LAYER_ID, name: "기본", isVisible: true, isLocked: false };
|
||||
const FONT = 2.0;
|
||||
const LINE = 3.0;
|
||||
/** 옛 글 색 = B07 표 글(`TABLE_LABEL_COLOR`) · 미리 넣음 = 그림처럼 붉은 계열. */
|
||||
const NOTE_COLOR = "#e8edf4";
|
||||
const CULVERT_COLOR = "#ff7f7f";
|
||||
|
||||
const newId = (): string => crypto.randomUUID();
|
||||
|
||||
function textEntity(
|
||||
label: string,
|
||||
x: number,
|
||||
y: number,
|
||||
color: string,
|
||||
align: "left" | "center" = "left",
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id: newId(),
|
||||
type: "Text",
|
||||
lineColor: color,
|
||||
lineWidth: 1,
|
||||
layerId: LAYER_ID,
|
||||
shapeData: {
|
||||
label,
|
||||
basePoint: { x, y },
|
||||
options: {
|
||||
textDirection: { x: 1, y: 0 },
|
||||
textAlign: align,
|
||||
textColor: color,
|
||||
fontSize: FONT,
|
||||
fontFamily: "sans-serif",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 옛 글 → 줄마다 Text(B07 `cross_note_lines` 와 같은 줄 나눔 · 빈 줄은 자리만). */
|
||||
export function legacyNoteEntities(note: string | undefined): Record<string, unknown>[] {
|
||||
const lines = (note ?? "")
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd());
|
||||
if (!lines.some(Boolean)) return [];
|
||||
return lines.flatMap((line, index) =>
|
||||
line ? [textEntity(line, 0, -LINE * (index + 0.5), NOTE_COLOR)] : [],
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 미리 넣음(265-2) = 원형 마크(관 주인 측점) + 측점 · 구조물 조합 줄 ─────────────── */
|
||||
|
||||
/** 관 길이 = 사사오입 정수(265-3 · B08 「관매설」과 같은 값) · 없으면 빈칸. */
|
||||
const wholeMetres = (value: number | null | undefined): string =>
|
||||
typeof value === "number" && Number.isFinite(value) ? `${pipeLengthWhole(value)}m` : "";
|
||||
|
||||
/** 측점 이름(B07 `station_no_label` 꼴 · 대문자 NO · 거리는 정수 = 화면 표기). */
|
||||
export function stationNoLabel(chainageM: number, intervalM: number): string {
|
||||
const safe = intervalM > 0 ? intervalM : 1;
|
||||
let number = Math.floor((chainageM + 1e-6) / safe);
|
||||
let rest = chainageM - number * safe;
|
||||
if (rest >= safe - 0.05) {
|
||||
number += 1;
|
||||
rest = 0;
|
||||
}
|
||||
return Math.abs(rest) < 0.05 ? `NO.${number}` : `NO.${number}+${Math.round(rest)}`;
|
||||
}
|
||||
|
||||
/** 측점 글 줄 재료 한 건(관 지점 · 구조물 배치) — 이름 = 구조물정의 종류 이름. */
|
||||
export interface NoteThing {
|
||||
name: string;
|
||||
/** 배관(`facility` pipe) — 원형 마크가 관을 그려 관 본체 조합 줄은 안 씀. */
|
||||
pipe: boolean;
|
||||
/** 놓인 누가거리(관 = 관 자리 · 구조물 = 기준 측점 · 없으면 시작). */
|
||||
at: number;
|
||||
summary_item?: unknown;
|
||||
group_items?: unknown;
|
||||
/** 좌측 패널 옵션 줄(166-1 · `structureOptionLines` · B07 짝은 서버 Node 가 같은 함수로 채움). */
|
||||
optionLines?: string[];
|
||||
}
|
||||
|
||||
/** 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리. */
|
||||
const PIPE_BODY_LINK = "grv6lny";
|
||||
|
||||
/** 그 측점에 놓인 구조물(관 · 구조물 배치 · 기준 측점이 찾는 거리 안) — 구조물 측점 판정 · 글 줄 재료. */
|
||||
export const thingsAt = (things: readonly NoteThing[], chainageM: number): NoteThing[] =>
|
||||
things.filter((thing) => Math.abs(thing.at - chainageM) < PIPE_MATCH_M);
|
||||
|
||||
/**
|
||||
* 구조물 조합 줄 — 구조물마다 지정된 조합(본체 → 그룹 차례) 이름 한 줄씩(이름 = 일위대가 이름 +
|
||||
* 조합 경로 옵션 값) · 같은 구조물의 같은 이름은 한 줄 · 미지정 = 「{구조물 이름} 조합 미지정」 ·
|
||||
* 배관은 본체 줄 없음(원형 마크) — 보호공 · 흙막이 · 도수로 조합만 · 이어서 그 구조물 좌측 패널 옵션 줄(166-1).
|
||||
*/
|
||||
export function structureNoteLines(things: readonly NoteThing[], chainageM: number): string[] {
|
||||
return thingsAt(things, chainageM).flatMap((thing) => {
|
||||
const own = readSummaryItem(thing.summary_item);
|
||||
const slots: Array<[string, SummaryItem]> = Object.entries(readGroupItems(thing.group_items));
|
||||
if (own) slots.unshift(["", own]);
|
||||
const names = slots
|
||||
.filter(([gid]) => !(thing.pipe && (gid === "" || gid === PIPE_BODY_LINK)))
|
||||
.map(([, item]) => summaryLabel(item));
|
||||
const combos = slots.length
|
||||
? [...new Set(names)]
|
||||
: thing.pipe
|
||||
? []
|
||||
: [`${thing.name} 조합 미지정`];
|
||||
return [...combos, ...(thing.optionLines ?? [])];
|
||||
});
|
||||
}
|
||||
|
||||
function shape(type: string, shapeData: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
id: newId(),
|
||||
type,
|
||||
lineColor: CULVERT_COLOR,
|
||||
lineWidth: 1,
|
||||
layerId: LAYER_ID,
|
||||
shapeData,
|
||||
};
|
||||
}
|
||||
|
||||
/** 원형 마크 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) ·
|
||||
* 이웃 측점 = 없음 · 원 안 세 줄(배수관 / Φ관경 / L=관 길이 정수 · 없으면 빈칸) · 지시선 없음(169). */
|
||||
function pipeMark(section: CrossSection): Record<string, unknown>[] {
|
||||
const culvert = section.culvert;
|
||||
if (!culvert || culvert.type !== "pipe" || culvert.body || culvert.hidden_pipe) return [];
|
||||
if (Math.abs((culvert.chainage_m ?? section.chainage_m) - section.chainage_m) >= PIPE_MATCH_M)
|
||||
return [];
|
||||
const [cx, cy, radius] = [6.5, -9.0, 6.5];
|
||||
const inside = [
|
||||
"배수관",
|
||||
`Φ${Math.round(culvert.diameter_m * 1000)}m/m`,
|
||||
`L=${wholeMetres(section.design?.pipe_length_m)}`,
|
||||
];
|
||||
return [
|
||||
shape("Circle", { center: { x: cx, y: cy }, radius }), // 아래 수직 지시선 없음(169)
|
||||
...inside.map((line, index) =>
|
||||
textEntity(line, cx, cy + LINE * (1 - index), CULVERT_COLOR, "center"),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리 넣음 — 원형 마크(있으면) + 오른쪽 측점 · 구조물 조합 줄(`lines` = `structureNoteLines`) ·
|
||||
* 마크 없으면 글이 왼쪽 끝부터 · 마크도 줄도 없으면 빈 목록.
|
||||
*/
|
||||
export function noteEntities(
|
||||
section: CrossSection,
|
||||
station: string,
|
||||
lines: readonly string[],
|
||||
): Record<string, unknown>[] {
|
||||
const mark = pipeMark(section);
|
||||
if (!mark.length && !lines.length) return [];
|
||||
const x = mark.length ? 15 : 0;
|
||||
return [
|
||||
...mark,
|
||||
...[station, ...lines].map((line, index) =>
|
||||
textEntity(line, x, -LINE * (index + 0.5), CULVERT_COLOR),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** 글 줄 재료 — 관(세션 초안 → 정본 `pipe_points.json`) · 구조물(세션 초안 → 정본 `structures.json`) ·
|
||||
* 이름 = 구조물정의 종류 이름(꺼진 종류 포함) · 못 받으면 빈 목록. */
|
||||
export async function loadNoteThings(projectId: string): Promise<NoteThing[]> {
|
||||
try {
|
||||
const [pipes, structures, types] = await Promise.all([
|
||||
readPendingPipes(projectId) ??
|
||||
fetchDetailPipePoints(projectId).then((res) => res.pipe_points),
|
||||
readPendingStructures(projectId) ?? fetchStructures(projectId).then((res) => res.structures),
|
||||
fetchStructureTypes(true),
|
||||
]);
|
||||
const byId = new Map(types.map((type) => [type.type_id, type]));
|
||||
const nameOf = (kind: string): string => byId.get(kind)?.name ?? kind;
|
||||
const optionLines = (type_id: string, options?: Record<string, string | number>): string[] =>
|
||||
structureOptionLines({ type_id, options: options ?? {} }, byId.get(type_id));
|
||||
const piped = pipes.map((point): NoteThing => {
|
||||
const kind = point.facility ?? "pipe";
|
||||
const { summary_item, group_items } = point;
|
||||
return {
|
||||
name: nameOf(kind),
|
||||
pipe: kind === "pipe",
|
||||
at: point.chainage_m,
|
||||
summary_item,
|
||||
group_items,
|
||||
optionLines: optionLines(kind, point.options as Record<string, string | number>),
|
||||
};
|
||||
});
|
||||
const placed = structures.flatMap((item): NoteThing[] => {
|
||||
const at = item.chainage_m ?? item.start_m;
|
||||
if (typeof at !== "number" || !Number.isFinite(at)) return [];
|
||||
const { summary_item, group_items } = item;
|
||||
return [
|
||||
{
|
||||
name: nameOf(item.type_id),
|
||||
pipe: false,
|
||||
at,
|
||||
summary_item,
|
||||
group_items,
|
||||
optionLines: optionLines(item.type_id, item.options),
|
||||
},
|
||||
];
|
||||
});
|
||||
return [...piped, ...placed];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 미리 넣음(원형 마크 · 구조물 조합 줄) → 빈 도면.
|
||||
* 옮기거나 넣었으면 초안에 씀. */
|
||||
async function firstNoteCad(section: CrossSection, at: NoteCadPlace): Promise<CrossNoteCad> {
|
||||
const stored = noteCadOf(section, at);
|
||||
if (stored) return stored;
|
||||
let entities = legacyNoteEntities(section.design?.structure_note);
|
||||
if (!entities.length) {
|
||||
const lines = structureNoteLines(await at.things, section.chainage_m);
|
||||
entities = noteEntities(section, at.station, lines);
|
||||
}
|
||||
const cad: CrossNoteCad = { entities, layers: [BLANK_LAYER] };
|
||||
if (entities.length) writeNoteCad(section, cad, at);
|
||||
return cad;
|
||||
}
|
||||
|
||||
/* ── 모달 칸(빈 CAD · 바탕만) ───────────────────────────────────────────── */
|
||||
|
||||
export interface NoteCadPlace {
|
||||
projectId: string;
|
||||
routeId: number;
|
||||
/** 미리 넣음 첫 줄(`stationNoLabel`). */
|
||||
station: string;
|
||||
/** 글 줄 재료(`loadNoteThings` · 모달이 한 번 받음). */
|
||||
things: Promise<NoteThing[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* [상세] 모달 아래 칸(`first` = 연 측점 · `section` = 카드를 다시 그려 바뀐 지금 측점) — 캔버스만(`bare`) · 그리기 됨 · 고칠 때마다 편집본을 초안에 씀 ·
|
||||
* 모달을 닫으면 `destroy`. 칸 크기가 바뀌어도 다시 만들지 않음(iframe 이 스스로 맞춤).
|
||||
*/
|
||||
export function mountNoteCad(
|
||||
box: HTMLElement,
|
||||
first: CrossSection,
|
||||
section: () => CrossSection,
|
||||
at: NoteCadPlace,
|
||||
): { destroy: () => void } {
|
||||
let closed = false;
|
||||
const cad = createCadHost<CadHostDrawing>({
|
||||
title: "표 위 CAD",
|
||||
onChanged: (dirty) => {
|
||||
if (!dirty || closed) return;
|
||||
cad
|
||||
.requestSave()
|
||||
.then(({ drawing }) => {
|
||||
if (!closed)
|
||||
writeNoteCad(section(), { entities: drawing.entities, layers: drawing.layers }, at);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
});
|
||||
box.append(cad.element);
|
||||
cad.beginLoading();
|
||||
const scope = `b06-detail-note:${at.projectId}:${at.routeId}:${noteKey(first.chainage_m)}`;
|
||||
void firstNoteCad(first, at).then((drawing) => {
|
||||
if (closed) return;
|
||||
const layers = drawing.layers?.length ? drawing.layers : [BLANK_LAYER];
|
||||
cad.load(
|
||||
{ entities: drawing.entities, layers },
|
||||
null,
|
||||
false,
|
||||
{},
|
||||
{
|
||||
recoveryScope: scope,
|
||||
hostTitle: "표 위 CAD",
|
||||
bare: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
return {
|
||||
destroy: () => {
|
||||
closed = true;
|
||||
cad.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* 짝(① 파이썬 · TS) = `B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py`
|
||||
* (`piece_path` · `sub_path` · `path_length` · `ring_area` · `ground_runs` — [저장] 뒤 정본 셈) ·
|
||||
* 거울 시험 `resources/tester/test_209_1_detail_select.py`.
|
||||
* 나눔(`splitAtCrossings`)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산.
|
||||
* 나눔(`splitAtCrossings`) · 닫힌 영역(`findFaces` · 259-2)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign, CrossPickPiece, SectionSample } from "./B06_Section_Api_Types";
|
||||
@@ -16,6 +16,8 @@ export type LineKind = CrossPickPiece["line"];
|
||||
export interface SplitPiece {
|
||||
line: LineKind;
|
||||
points: Pt[];
|
||||
/** 구조물 선 하나(295-3) — 나눈 조각이 같은 번호면 고를 때 한 묶음(닫힌 영역 · 저장은 조각대로). */
|
||||
group?: number;
|
||||
}
|
||||
|
||||
/** 서버 `LINE_TOLERANCE_M` 과 같은 값 — 두 끝이 선에서 이만큼 안이면 「선 위」 · 고리 끝 맞음. */
|
||||
@@ -44,6 +46,16 @@ export function groundRuns(samples: ReadonlyArray<SectionSample> | undefined): P
|
||||
const polyline = (raw: ReadonlyArray<{ offset_m: number; elevation_m: number }> | undefined) =>
|
||||
(raw ?? []).map((p): Pt => [p.offset_m, p.elevation_m]).filter((p) => p.every(Number.isFinite));
|
||||
|
||||
/** 암 경계선 — 원지반선 + 오프셋(암 프리셋만 · 카드 `appendRockBoundaryOverlay` · 서버 `rock_runs`). */
|
||||
export function rockRuns(
|
||||
design: CrossDesign | undefined,
|
||||
samples: ReadonlyArray<SectionSample> | undefined,
|
||||
): Pt[][] {
|
||||
const offset = design?.rock_boundary_offset_m;
|
||||
if (design?.geometry_preset !== "rock" || typeof offset !== "number") return [];
|
||||
return groundRuns(samples).map((run) => run.map(([x, y]): Pt => [x, y + offset]));
|
||||
}
|
||||
|
||||
/** 서버가 아는 선(서버 `pick_lines`) — 고른 조각 위치 검산 대상. */
|
||||
export function pickLines(
|
||||
design: CrossDesign | undefined,
|
||||
@@ -53,6 +65,7 @@ export function pickLines(
|
||||
design: [polyline(design?.design_line)],
|
||||
ground: groundRuns(samples),
|
||||
spoil: [polyline(design?.spoil_fill_line)],
|
||||
rock: rockRuns(design, samples),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,12 +223,100 @@ export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] {
|
||||
const [c0, c1] = [marks[k], marks[k + 1]];
|
||||
const inner = pts.slice(Math.floor(c0) + 1, Math.ceil(c1));
|
||||
const points = [at(c0), ...inner, at(c1)];
|
||||
if (pathLength(points) >= MIN_PIECE_M) out.push({ line: line.line, points });
|
||||
if (pathLength(points) >= MIN_PIECE_M)
|
||||
out.push({
|
||||
line: line.line,
|
||||
points,
|
||||
...(line.group !== undefined && { group: line.group }),
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 닫힌 영역(259-2 · 화면 몫) ─────────────────────────────────────────── */
|
||||
|
||||
/** 나눈 조각으로 둘러싸인 면 하나 — 둘레 조각 번호 · 고리(m) · 넓이(㎡). */
|
||||
export interface Face {
|
||||
edges: number[];
|
||||
ring: Pt[];
|
||||
area: number;
|
||||
}
|
||||
|
||||
/** 꺾은선이 그 끝에서 떠나는 방향(라디안) — 아주 짧은 첫 토막은 건너뜀. */
|
||||
function leaving(points: Pt[]): number {
|
||||
const [x0, y0] = points[0];
|
||||
const next = points.find((p) => dist(p, points[0]) > 1e-6) ?? points[points.length - 1];
|
||||
return Math.atan2(next[1] - y0, next[0] - x0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조각(변)으로 된 평면 그래프의 면 — 끝이 `TOUCH_M` 안이면 같은 마디 · 열린 끝 조각(마디 차수 1)은
|
||||
* 거듭 지움(열린 영역 제외) · 각 반변에서 도착 마디의 시계 방향 다음 변으로 돌아 면을 셈 ·
|
||||
* 넓이가 양(반시계)인 것만 = 바깥 면 뺌.
|
||||
* ponytail: 안에 떠 있는 섬(다리 없음)은 바깥 면의 구멍으로 안 뺌 — 필요하면 포함 판정 더함.
|
||||
*/
|
||||
export function findFaces(pieces: SplitPiece[]): Face[] {
|
||||
const nodes: Pt[] = [];
|
||||
const nodeOf = (p: Pt): number => {
|
||||
const found = nodes.findIndex((q) => dist(p, q) <= TOUCH_M);
|
||||
return found >= 0 ? found : nodes.push(p) - 1;
|
||||
};
|
||||
const ends = pieces.map((piece) => [
|
||||
nodeOf(piece.points[0]),
|
||||
nodeOf(piece.points[piece.points.length - 1]),
|
||||
]);
|
||||
const alive = ends.map(([a, b]) => a !== b);
|
||||
for (let changed = true; changed;) {
|
||||
const degree = new Array<number>(nodes.length).fill(0);
|
||||
ends.forEach(([a, b], i) => alive[i] && (degree[a]++, degree[b]++));
|
||||
changed = false;
|
||||
ends.forEach(([a, b], i) => {
|
||||
if (alive[i] && (degree[a] < 2 || degree[b] < 2)) [alive[i], changed] = [false, true];
|
||||
});
|
||||
}
|
||||
// 반변 h = 2i(앞 → 뒤) · 2i+1(뒤 → 앞)
|
||||
const path = (h: number): Pt[] => {
|
||||
const points = pieces[h >> 1].points;
|
||||
return h & 1 ? [...points].reverse() : points;
|
||||
};
|
||||
const head = (h: number): number => ends[h >> 1][h & 1 ? 0 : 1];
|
||||
const out = nodes.map(() => [] as Array<[number, number]>);
|
||||
ends.forEach(([a, b], i) => {
|
||||
if (!alive[i]) return;
|
||||
out[a].push([leaving(path(2 * i)), 2 * i]);
|
||||
out[b].push([leaving(path(2 * i + 1)), 2 * i + 1]);
|
||||
});
|
||||
out.forEach((list) => list.sort((p, q) => p[0] - q[0]));
|
||||
const next = (h: number): number => {
|
||||
const list = out[head(h)];
|
||||
const at = list.findIndex(([, e]) => e === (h ^ 1));
|
||||
return list[(at - 1 + list.length) % list.length][1];
|
||||
};
|
||||
const faces: Face[] = [];
|
||||
const seen = new Set<number>();
|
||||
ends.forEach((_, i) => {
|
||||
if (!alive[i]) return;
|
||||
for (const start of [2 * i, 2 * i + 1]) {
|
||||
if (seen.has(start)) continue;
|
||||
const cycle: number[] = [];
|
||||
for (let h = start; !seen.has(h) && cycle.length <= 2 * pieces.length; h = next(h)) {
|
||||
seen.add(h);
|
||||
cycle.push(h);
|
||||
}
|
||||
const ring = cycle.flatMap((h) => path(h).slice(1));
|
||||
let twice = 0;
|
||||
ring.forEach((p, k) => {
|
||||
const q = ring[(k + 1) % ring.length];
|
||||
twice += p[0] * q[1] - q[0] * p[1];
|
||||
});
|
||||
if (twice / 2 > 1e-9)
|
||||
faces.push({ edges: [...new Set(cycle.map((h) => h >> 1))], ring, area: twice / 2 });
|
||||
}
|
||||
});
|
||||
return faces;
|
||||
}
|
||||
|
||||
/** 조각 → 저장 꼴(두 끝 · 0.1 mm 반올림). */
|
||||
export function pieceSource(piece: SplitPiece): CrossPickPiece {
|
||||
const round = (p: Pt): Pt => [Math.round(p[0] * 1e4) / 1e4, Math.round(p[1] * 1e4) / 1e4];
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Detail_Home.ts
|
||||
* [상세] 모달 첫 보기(295-2) — 중심점(중심 십자)이 칸 가운데 · 그림 전체가 들어가게
|
||||
* 축마다 중심에서 큰 쪽 반폭으로. [⤢] · 더블클릭 원복도 같은 보기. 카드 보기는 그대로.
|
||||
*
|
||||
* 가로 반폭 = 카드 반폭(266-1 `homeScale` 이 되좁히던 폭)의 큰 쪽 · 세로 반폭 = 그 가로 안에
|
||||
* 그려진 꼭짓점이 중심에서 위 · 아래로 먼 쪽.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
|
||||
|
||||
type Pt = [number, number];
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** 첫 보기 배율 · 이동 — 중심 `center` 를 `plot` 가운데에. `homeScale` = 1배 대비 카드 반폭 배율. */
|
||||
export function detailHomeView(
|
||||
points: Pt[],
|
||||
center: Pt,
|
||||
plot: Rect,
|
||||
homeScale: number,
|
||||
maxScale: number,
|
||||
): ZoomPanState {
|
||||
const [cx, cy] = center;
|
||||
const halfX = Math.max(cx - plot.x, plot.x + plot.width - cx) / Math.max(homeScale, 1e-6);
|
||||
const ys = points.filter(([x]) => Math.abs(x - cx) <= halfX).map(([, y]) => y);
|
||||
const halfY = Math.max(1, ...ys.map((y) => Math.abs(y - cy)));
|
||||
const scale = Math.min(plot.width / 2 / halfX, plot.height / 2 / halfY, maxScale);
|
||||
return {
|
||||
scale,
|
||||
tx: plot.x + plot.width / 2 - scale * cx,
|
||||
ty: plot.y + plot.height / 2 - scale * cy,
|
||||
};
|
||||
}
|
||||
|
||||
/** 그림 층의 꺾은선 · 다각형 꼭짓점(그림 px). */
|
||||
export function layerPoints(layer: Element): Pt[] {
|
||||
return [...layer.querySelectorAll("polyline, polygon")].flatMap((el) =>
|
||||
(el.getAttribute("points") ?? "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((pair) => pair.split(",").map(Number))
|
||||
.filter((p): p is Pt => p.length === 2 && p.every(Number.isFinite)),
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
* (사면 · 노면 · 측구). 판정 선은 화면 좌표라 십자(x(0)) · `data-px-per-m` 로 m 로 되돌려
|
||||
* `crossZoneAt` 으로 가름 — 측구 = 단면유형을 성토 양쪽으로 둬도 절토로 남는 자리.
|
||||
* 편책 · 제근 · 표토제거는 늘 빈칸 = 누름 없음(짝 표에서 뺌) · 대상이 그 카드에 없는 칸 = `presentKeys` 밖.
|
||||
* 고른 칸 묶음 = 바깥 한 줄(`outlinePicked` · 259-3).
|
||||
* ponytail: 판정 선의 구조물 트림 여부는 복제에서 모름 → 자리로만 가름(트림 선은 놓인 구역을 따름).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -75,3 +76,48 @@ export function presentKeys(svg: Element, design: CrossDesign | undefined): Set<
|
||||
Object.keys(DETAIL_TARGETS).filter((key) => targetsOf(svg, design, key).length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 표 강조 바깥 한 줄(259-3) ──────────────────────────────────────────── */
|
||||
|
||||
/** 직사각형들(좌 · 위 · 우 · 아래)의 합친 모양 바깥 줄 — 격자로 나눠 덮인 칸과 안 덮인 칸 사이 변만(SVG path). */
|
||||
export function unionOutline(rects: number[][]): string {
|
||||
const xs = [...new Set(rects.flatMap((r) => [r[0], r[2]]))].sort((a, b) => a - b);
|
||||
const ys = [...new Set(rects.flatMap((r) => [r[1], r[3]]))].sort((a, b) => a - b);
|
||||
const covered = (i: number, j: number): boolean =>
|
||||
i >= 0 &&
|
||||
j >= 0 &&
|
||||
i + 1 < xs.length &&
|
||||
j + 1 < ys.length &&
|
||||
rects.some((r) => r[0] <= xs[i] && xs[i + 1] <= r[2] && r[1] <= ys[j] && ys[j + 1] <= r[3]);
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i + 1 < xs.length; i++)
|
||||
for (let j = 0; j + 1 < ys.length; j++) {
|
||||
if (!covered(i, j)) continue;
|
||||
const [x0, x1, y0, y1] = [xs[i], xs[i + 1], ys[j], ys[j + 1]];
|
||||
if (!covered(i, j - 1)) parts.push(`M${x0} ${y0}H${x1}`);
|
||||
if (!covered(i, j + 1)) parts.push(`M${x0} ${y1}H${x1}`);
|
||||
if (!covered(i - 1, j)) parts.push(`M${x0} ${y0}V${y1}`);
|
||||
if (!covered(i + 1, j)) parts.push(`M${x1} ${y0}V${y1}`);
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
/** 고른 칸(`.is-picked`) 묶음에 바깥 한 줄 — 칸마다 테두리를 그리면 붙은 칸에 겹선이 생김. */
|
||||
export function outlinePicked(box: HTMLElement): void {
|
||||
box.querySelector(".b06-detail__pick-outline")?.remove();
|
||||
const cells = [...box.querySelectorAll(".is-picked")];
|
||||
if (!cells.length) return;
|
||||
const base = box.getBoundingClientRect();
|
||||
const rects = cells.map((cell) => {
|
||||
const r = cell.getBoundingClientRect();
|
||||
return [r.left - base.left, r.top - base.top, r.right - base.left, r.bottom - base.top].map(
|
||||
(v) => Math.round(v),
|
||||
);
|
||||
});
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("class", "b06-detail__pick-outline");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", unionOutline(rects));
|
||||
svg.append(path);
|
||||
box.append(svg);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Detail_Select.ts
|
||||
* [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선을 만나는 점에서 조각으로
|
||||
* [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로
|
||||
* 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 ·
|
||||
* Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴):
|
||||
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」).
|
||||
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이 · 둘레(안 닫히면 「닫히지 않음」).
|
||||
* 닫힌 영역(259-2) = 조각으로 둘러싸인 면(`findFaces` · 열린 영역 제외) · 안 누름 = 그 영역(둘레 조각)
|
||||
* 고름 · Shift/Ctrl = 영역 더하기 · 채움 강조 · 값 = 면적 / 둘레 · 선으로 한 영역을 닫아도 채움.
|
||||
* 누름 판정(261-3) = 판정 선 굵기 대신 누른 자리 거리(`pickAt`) — 보통 배율 얇은 띠도 영역 ·
|
||||
* 벽 · 집수정 · 본체 둘레도 구조물 선(설계선 끝 ~ 비탈 밑을 이어 깎기 토사 · 암석 영역이 닫힘).
|
||||
* 표 칸 누름 = 저장된 조각을 다시 고름(`choose`) · 띠 = 값 글만(`note`).
|
||||
* 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀).
|
||||
* ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때.
|
||||
* ========================================================================== */
|
||||
@@ -14,19 +19,27 @@ import { CROSS_PAD } from "./B06_Section_UI_Section_Common";
|
||||
import {
|
||||
DESIGN_LINE_ATTR,
|
||||
mergeStraightPieces,
|
||||
OCCLUDER_CLASSES,
|
||||
pieceLabel,
|
||||
} from "./B06_Section_UI_Cross_Design_Pick";
|
||||
import {
|
||||
findFaces,
|
||||
LINE_TOLERANCE_M,
|
||||
pathLength,
|
||||
pieceSource,
|
||||
ringArea,
|
||||
splitAtCrossings,
|
||||
type Face,
|
||||
type Pt,
|
||||
type SplitPiece,
|
||||
} from "./B06_Section_UI_Cross_Detail_Geom";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
export const PIECE_HIT_CLASS = "b06-detail__piece";
|
||||
/** 구조물 선 묶음 걸침 표시(295-3) — 조각 판정 선 위 · 누름은 거리 판정이라 걸침만. */
|
||||
export const PIECE_GROUP_CLASS = "b06-detail__piece-group";
|
||||
/** 닫힌 영역 판정 면(259-2) — 조각 판정 선 밑 · 투명 채움. */
|
||||
export const FACE_HIT_CLASS = "b06-detail__face";
|
||||
|
||||
/** 화면 px → m(카드 `x` · `toDisplayY` 의 역 · 축 옵션). */
|
||||
export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt {
|
||||
@@ -39,12 +52,13 @@ export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt {
|
||||
};
|
||||
}
|
||||
|
||||
function mToPx(axes: CrossAxesOptions): (p: Pt) => string {
|
||||
/** m → 그림 px(`join` 하면 `x,y`). */
|
||||
function mToPx(axes: CrossAxesOptions): (p: Pt) => Pt {
|
||||
return ([offset, elevation]) => {
|
||||
const display = axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration;
|
||||
const x = CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter;
|
||||
const y = CROSS_PAD.top + (axes.displayMax - display) * axes.pixelsPerMeter;
|
||||
return `${x},${y}`;
|
||||
return [x, y];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,16 +90,26 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli
|
||||
points: points.map(([offset, elevation]) => ({ offset, elevation })),
|
||||
structure: !el.hasAttribute(DESIGN_LINE_ATTR),
|
||||
});
|
||||
else if (OCCLUDER_CLASSES.some((name) => el.classList.contains(name)))
|
||||
// 벽 · 집수정 · 본체 둘레(261-3) — 설계선 끝 · 비탈 밑을 잇는 구조물 선(닫힌 고리)
|
||||
runs.push({
|
||||
points: [...points, points[0]].map(([offset, elevation]) => ({ offset, elevation })),
|
||||
structure: true,
|
||||
});
|
||||
else if (el.classList.contains("b06-chart__cross-profile"))
|
||||
others.push({ line: "ground", points });
|
||||
else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points });
|
||||
else if (el.classList.contains("b06-chart__rock-boundary"))
|
||||
others.push({ line: "rock", points });
|
||||
}
|
||||
const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({
|
||||
// 구조물 선(295-3) = 다른 선을 나누는 기준은 그대로 · 자기 조각은 한 묶음(고를 때 통째)
|
||||
const straight = mergeStraightPieces(runs).map((piece, index): SplitPiece => ({
|
||||
line: piece.structure ? "structure" : "design",
|
||||
points: [
|
||||
[piece.from.offset, piece.from.elevation],
|
||||
[piece.to.offset, piece.to.elevation],
|
||||
],
|
||||
...(piece.structure && { group: index }),
|
||||
}));
|
||||
return splitAtCrossings([...straight, ...others]);
|
||||
}
|
||||
@@ -97,36 +121,93 @@ export function toggleSelection(selected: number[], index: number, additive: boo
|
||||
return selected.length === 1 && selected[0] === index ? [] : [index];
|
||||
}
|
||||
|
||||
/** 묶음 고름 바꿈(295-3) — 구조물 선 조각 묶음을 한 몸으로 넣고 빼기 · 하나면 `toggleSelection` 과 같음. */
|
||||
export function toggleMembers(selected: number[], members: number[], additive: boolean): number[] {
|
||||
const all = members.every((m) => selected.includes(m));
|
||||
if (additive)
|
||||
return all
|
||||
? selected.filter((i) => !members.includes(i))
|
||||
: [...selected, ...members.filter((m) => !selected.includes(m))];
|
||||
const same = all && selected.length === members.length;
|
||||
return same ? [] : [...members];
|
||||
}
|
||||
|
||||
/** 같은 묶음 조각 번호 — 묶음 없으면 그 하나. */
|
||||
export function groupMembers(pieces: SplitPiece[], index: number): number[] {
|
||||
const group = pieces[index]?.group;
|
||||
if (group === undefined) return [index];
|
||||
return pieces.flatMap((piece, i) => (piece.group === group ? [i] : []));
|
||||
}
|
||||
|
||||
const LINE_NAME: Record<SplitPiece["line"], string> = {
|
||||
design: "설계선",
|
||||
structure: "구조물 선",
|
||||
ground: "원지반선",
|
||||
spoil: "사토장선",
|
||||
rock: "암 경계선",
|
||||
};
|
||||
|
||||
/** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */
|
||||
export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPiece[]): string {
|
||||
if (!pieces.length) return "";
|
||||
// 구조물 선 한 묶음(295-3) = 선 하나 — 첫 조각 앞 끝 ~ 끝 조각 뒤 끝
|
||||
const group = pieces[0].group;
|
||||
if (pieces.length > 1 && group !== undefined && pieces.every((p) => p.group === group))
|
||||
pieces = [{ ...pieces[0], points: [pieces[0].points[0], pieces.at(-1)!.points.at(-1)!] }];
|
||||
if (pieces.length === 1) {
|
||||
const [piece] = pieces;
|
||||
const from = piece.points[0];
|
||||
const to = piece.points[piece.points.length - 1];
|
||||
if (design && piece.points.length === 2 && piece.line !== "ground" && piece.line !== "spoil")
|
||||
if (
|
||||
design &&
|
||||
piece.points.length === 2 &&
|
||||
(piece.line === "design" || piece.line === "structure")
|
||||
)
|
||||
return pieceLabel(design, {
|
||||
from: { offset: from[0], elevation: from[1] },
|
||||
to: { offset: to[0], elevation: to[1] },
|
||||
structure: piece.line === "structure",
|
||||
});
|
||||
const horizontal = Math.abs(to[0] - from[0]);
|
||||
const height = Math.abs(to[1] - from[1]);
|
||||
const grade = horizontal > 0 ? `${((height / horizontal) * 100).toFixed(1)}%` : "수직";
|
||||
return (
|
||||
`${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m\n` +
|
||||
`수평 ${Math.abs(to[0] - from[0]).toFixed(2)}m · 높이 ${Math.abs(to[1] - from[1]).toFixed(2)}m`
|
||||
`${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m · 경사비율 ${grade}\n` +
|
||||
`수평 ${horizontal.toFixed(2)}m · 높이 ${height.toFixed(2)}m`
|
||||
);
|
||||
}
|
||||
const total = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0);
|
||||
const area = ringArea(pieces.map((piece) => piece.points));
|
||||
const units = new Set(pieces.map((piece) => piece.group ?? piece)).size;
|
||||
return (
|
||||
`${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` +
|
||||
(area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡`)
|
||||
`${units}개 · 길이 합 ${total.toFixed(2)}m\n` +
|
||||
(area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡ · 둘레 ${total.toFixed(2)}m`)
|
||||
);
|
||||
}
|
||||
|
||||
/** 고른 영역 값 두 줄(259-2) — 넓이 합 / 둘레(고른 둘레 조각 길이 합 · 붙은 영역은 맞닿은 변 뺌). */
|
||||
export function regionLabel(faces: Face[], pieces: SplitPiece[]): string {
|
||||
const area = faces.reduce((sum, face) => sum + face.area, 0);
|
||||
const around = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0);
|
||||
const count = faces.length > 1 ? `${faces.length}개 · ` : "";
|
||||
return `영역 ${count}면적 ${area.toFixed(2)}㎡\n둘레 ${around.toFixed(2)}m`;
|
||||
}
|
||||
|
||||
/** 영역 여럿 → 둘레 조각 — 둘 이상이 맞닿은 변은 짝수 번 나와 빠짐. */
|
||||
export function regionEdges(faces: Face[]): number[] {
|
||||
const count = new Map<number, number>();
|
||||
for (const face of faces)
|
||||
for (const edge of face.edges) count.set(edge, (count.get(edge) ?? 0) + 1);
|
||||
return [...count].filter(([, n]) => n % 2).map(([edge]) => edge);
|
||||
}
|
||||
|
||||
/** 그림 조각이 저장된 조각(두 끝 · 어느 방향이든)과 같은 자리인가(259-2 표 칸 → 그래프). */
|
||||
export function samePiece(piece: SplitPiece, stored: CrossPickPiece): boolean {
|
||||
if (piece.line !== stored.line) return false;
|
||||
const near = (a: Pt, b: Pt) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= LINE_TOLERANCE_M;
|
||||
const [a, b] = [piece.points[0], piece.points[piece.points.length - 1]];
|
||||
return (
|
||||
(near(a, stored.start) && near(b, stored.end)) || (near(a, stored.end) && near(b, stored.start))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,9 +230,85 @@ export interface DetailSelect {
|
||||
setMode: (kind: "area" | "length" | null) => void;
|
||||
source: (kind: "area" | "length") => ReturnType<typeof selectionSource>;
|
||||
clear: () => void;
|
||||
/** 저장된 조각을 그림에서 고름(259-2 표 칸 누름) · null = 비움. */
|
||||
choose: (stored: CrossPickPiece[] | null) => void;
|
||||
/** 고름 없이 값 글만(띠 · 259-2) — 다음 누름에 지워짐. */
|
||||
note: (text: string | null) => void;
|
||||
detach: () => void;
|
||||
}
|
||||
|
||||
/** 선 누름 거리(화면 px) — 이 안이면 선 · 영역 안이면 맞은편 선까지의 ⅓ 안이어야 선(261-3). */
|
||||
export const LINE_PICK_PX = 4;
|
||||
|
||||
const segmentGap = (p: Pt, a: Pt, b: Pt): number => {
|
||||
const d = [b[0] - a[0], b[1] - a[1]];
|
||||
const size2 = d[0] ** 2 + d[1] ** 2;
|
||||
const t = size2
|
||||
? Math.max(0, Math.min(1, ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / size2))
|
||||
: 0;
|
||||
return Math.hypot(p[0] - a[0] - t * d[0], p[1] - a[1] - t * d[1]);
|
||||
};
|
||||
const pathGap = (p: Pt, path: Pt[]): number =>
|
||||
Math.min(...path.slice(1).map((q, i) => segmentGap(p, path[i], q)));
|
||||
const inside = (p: Pt, ring: Pt[]): boolean =>
|
||||
ring.reduce((inn, a, i) => {
|
||||
const b = ring[(i + 1) % ring.length];
|
||||
return a[1] > p[1] !== b[1] > p[1] &&
|
||||
p[0] < a[0] + ((p[1] - a[1]) * (b[0] - a[0])) / (b[1] - a[1])
|
||||
? !inn
|
||||
: inn;
|
||||
}, false);
|
||||
|
||||
/**
|
||||
* 누른 자리 → 선 · 영역(261-3) — 좌표는 그림 px · `scale` = 화면 px / 그림 px · `areas` = 영역 넓이.
|
||||
* 가장 가까운 선이 `LINE_PICK_PX` 안이면 선 — 단 영역 안이면 그 선과 끝이 안 닿는 맞은편 선보다
|
||||
* 절반 이하로 가까워야 선(얇은 띠 가운데 ⅓ = 영역) · 아니면 누른 자리를 품은 가장 작은 영역.
|
||||
* 고르기 차례(295-1 · 카드와 같은 잣대) = 선 > 작은 면 > 큰 면 · 띠 — `bandArea`(그 자리 면적 띠
|
||||
* 넓이 · 그림 px²)가 영역보다 작을 때만 띠(표가 맡음).
|
||||
*/
|
||||
export function pickAt(
|
||||
point: Pt,
|
||||
scale: number,
|
||||
paths: Pt[][],
|
||||
rings: Pt[][],
|
||||
areas: number[],
|
||||
pieces: SplitPiece[],
|
||||
bandArea: number | null = null,
|
||||
): { piece: number } | { face: number } | { band: true } | null {
|
||||
const gaps = paths.map((path) => pathGap(point, path) * scale);
|
||||
const nearest = gaps.reduce((best, gap, i) => (gap < gaps[best] ? i : best), 0);
|
||||
const ends = (piece: SplitPiece): Pt[] => [
|
||||
piece.points[0],
|
||||
piece.points[piece.points.length - 1],
|
||||
];
|
||||
const touches = (a: SplitPiece, b: SplitPiece): boolean =>
|
||||
ends(a).some((p) =>
|
||||
ends(b).some((q) => Math.hypot(p[0] - q[0], p[1] - q[1]) <= LINE_TOLERANCE_M),
|
||||
);
|
||||
const across = Math.min(
|
||||
Infinity,
|
||||
...gaps.filter((_, i) => i !== nearest && !touches(pieces[i], pieces[nearest])),
|
||||
);
|
||||
const face = rings
|
||||
.flatMap((ring, i) => (inside(point, ring) ? [i] : []))
|
||||
.sort((a, b) => areas[a] - areas[b])[0];
|
||||
if (
|
||||
paths.length &&
|
||||
gaps[nearest] <= LINE_PICK_PX &&
|
||||
(face === undefined || gaps[nearest] * 2 <= across)
|
||||
)
|
||||
return { piece: nearest };
|
||||
const faceArea = face === undefined ? Infinity : Math.abs(shoelace(rings[face]));
|
||||
if (bandArea !== null && bandArea < faceArea) return { band: true };
|
||||
return face === undefined ? null : { face };
|
||||
}
|
||||
|
||||
const shoelace = (ring: Pt[]): number =>
|
||||
ring.reduce((sum, [x0, y0], i) => {
|
||||
const [x1, y1] = ring[(i + 1) % ring.length];
|
||||
return sum + (x0 * y1 - x1 * y0) / 2;
|
||||
}, 0);
|
||||
|
||||
/** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */
|
||||
const pieceKey = (piece: SplitPiece | undefined): string =>
|
||||
piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : "";
|
||||
@@ -164,11 +321,22 @@ export function attachDetailSelect(input: {
|
||||
picked: (additive: boolean) => void;
|
||||
/** [칸에 넣기] 단추. */
|
||||
commit: () => void;
|
||||
/** 면적 고르기 중 그 자리 면적 띠 넓이(그림 px²) · 없음 = null — 영역보다 작을 때만 띠(295-1). */
|
||||
band?: (event: MouseEvent) => number | null;
|
||||
}): DetailSelect {
|
||||
const { cad } = input;
|
||||
let pieces: SplitPiece[] = [];
|
||||
let hits: SVGPolylineElement[] = [];
|
||||
let faces: Face[] = [];
|
||||
let faceEls: SVGPolygonElement[] = [];
|
||||
/** 그림 층 · 조각 · 영역 고리(그림 px) — 누름 판정. */
|
||||
let layer: SVGGElement | null = null;
|
||||
let paths: Pt[][] = [];
|
||||
let rings: Pt[][] = [];
|
||||
let selected: number[] = [];
|
||||
/** 영역 누름으로 고른 면 — 비면 고른 조각이 한 면을 꼭 닫을 때 그 면. */
|
||||
let chosenFaces: number[] = [];
|
||||
let noteText: string | null = null;
|
||||
let mode: "area" | "length" | null = null;
|
||||
|
||||
const label = document.createElement("span");
|
||||
@@ -186,26 +354,62 @@ export function attachDetailSelect(input: {
|
||||
const show = (): void => {
|
||||
const chosen = selected.map((i) => pieces[i]);
|
||||
hits.forEach((hit, i) => hit.classList.toggle("is-selected", selected.includes(i)));
|
||||
label.textContent = selectionLabel(input.design(), chosen);
|
||||
label.hidden = !chosen.length;
|
||||
const same = (edges: number[]) =>
|
||||
edges.length === selected.length && edges.every((edge) => selected.includes(edge));
|
||||
const filled = chosenFaces.length
|
||||
? chosenFaces
|
||||
: faces.flatMap((face, i) => (same(face.edges) ? [i] : []));
|
||||
faceEls.forEach((el, i) => el.classList.toggle("is-selected", filled.includes(i)));
|
||||
label.textContent =
|
||||
noteText ??
|
||||
(filled.length
|
||||
? regionLabel(
|
||||
filled.map((i) => faces[i]),
|
||||
chosen,
|
||||
)
|
||||
: selectionLabel(input.design(), chosen));
|
||||
label.hidden = !label.textContent;
|
||||
put.hidden = !mode || !selectionSource(mode, chosen);
|
||||
// [+ − ⤢] 왼쪽(카드 꼴) — 그림을 다시 그리면 막대가 새로 생김
|
||||
const bar = cad.querySelector(".b06-cross-card__zoom");
|
||||
if (bar && label.parentElement !== bar) bar.prepend(label, put);
|
||||
};
|
||||
|
||||
/** 누른 자리(그림 안만) → 선 · 영역 — 판정 선 굵기 대신 거리로(261-3). */
|
||||
const pickOf = (event: MouseEvent): ReturnType<typeof pickAt> => {
|
||||
const ctm = layer?.getScreenCTM();
|
||||
if (!ctm || !layer?.ownerSVGElement?.contains(event.target as Node)) return null;
|
||||
const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(ctm.inverse());
|
||||
const areas = faces.map((face) => face.area);
|
||||
const band = mode === "area" ? (input.band?.(event) ?? null) : null;
|
||||
return pickAt([p.x, p.y], Math.hypot(ctm.a, ctm.b), paths, rings, areas, pieces, band);
|
||||
};
|
||||
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
const hit = (event.target as Element).closest?.(`.${PIECE_HIT_CLASS}`);
|
||||
const additive = event.shiftKey || event.ctrlKey || event.metaKey || mode === "area";
|
||||
if (!hit) {
|
||||
if (!additive && !mode && selected.length) {
|
||||
selected = [];
|
||||
const pick = pickOf(event);
|
||||
const shift = event.shiftKey || event.ctrlKey || event.metaKey;
|
||||
const additive = shift || mode === "area";
|
||||
noteText = null;
|
||||
if (!pick || !("piece" in pick)) {
|
||||
if (pick && "face" in pick) {
|
||||
// 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter) ·
|
||||
// 띠보다 좁은 영역이라 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬(295-1)
|
||||
event.stopImmediatePropagation();
|
||||
const index = pick.face;
|
||||
chosenFaces = toggleSelection(chosenFaces, index, shift);
|
||||
selected = regionEdges(chosenFaces.map((i) => faces[i]));
|
||||
show();
|
||||
input.picked(true);
|
||||
return;
|
||||
}
|
||||
if (!additive && !mode) [selected, chosenFaces] = [[], []];
|
||||
show();
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive);
|
||||
// 선이 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬
|
||||
event.stopImmediatePropagation();
|
||||
chosenFaces = [];
|
||||
selected = toggleMembers(selected, groupMembers(pieces, pick.piece), additive);
|
||||
show();
|
||||
input.picked(additive);
|
||||
};
|
||||
@@ -216,19 +420,42 @@ export function attachDetailSelect(input: {
|
||||
// 다시 그려도(창 크기 · 카드 다시 세움) 같은 자리 조각은 고름 유지 — 지우면 바로 꺼졌음(233-2)
|
||||
const keep = new Set(selected.map((i) => pieceKey(pieces[i])));
|
||||
const axes = input.axes();
|
||||
const layer = cad.querySelector<SVGGElement>("g.b06-chart__plot-layer");
|
||||
layer = cad.querySelector<SVGGElement>("g.b06-chart__plot-layer");
|
||||
pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : [];
|
||||
selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : []));
|
||||
chosenFaces = [];
|
||||
faces = findFaces(pieces);
|
||||
const toPx = axes ? mToPx(axes) : null;
|
||||
hits = toPx
|
||||
? pieces.map((piece) => {
|
||||
const hit = document.createElementNS(SVG_NS, "polyline");
|
||||
hit.setAttribute("points", piece.points.map(toPx).join(" "));
|
||||
hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${piece.line}`);
|
||||
layer?.append(hit);
|
||||
return hit;
|
||||
})
|
||||
: [];
|
||||
paths = toPx ? pieces.map((piece) => piece.points.map(toPx)) : [];
|
||||
rings = toPx ? faces.map((face) => face.ring.map(toPx)) : [];
|
||||
// 영역 판정 면 먼저(밑) → 조각 판정 선(위)
|
||||
faceEls = rings.map((ring) => {
|
||||
const el = document.createElementNS(SVG_NS, "polygon");
|
||||
el.setAttribute("points", ring.join(" "));
|
||||
el.setAttribute("class", FACE_HIT_CLASS);
|
||||
layer?.append(el);
|
||||
return el;
|
||||
});
|
||||
hits = paths.map((path, i) => {
|
||||
const hit = document.createElementNS(SVG_NS, "polyline");
|
||||
hit.setAttribute("points", path.join(" "));
|
||||
hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${pieces[i].line}`);
|
||||
layer?.append(hit);
|
||||
return hit;
|
||||
});
|
||||
// 구조물 선 묶음 걸침 표시(295-3) — 조각 대신 선 하나로 비침
|
||||
new Set(pieces.flatMap((piece) => (piece.group === undefined ? [] : [piece.group]))).forEach(
|
||||
(group) => {
|
||||
const members = pieces.flatMap((piece, i) => (piece.group === group ? [i] : []));
|
||||
const el = document.createElementNS(SVG_NS, "polyline");
|
||||
el.setAttribute(
|
||||
"points",
|
||||
[paths[members[0]][0], paths[members.at(-1)!].at(-1)!].join(" "),
|
||||
);
|
||||
el.setAttribute("class", PIECE_GROUP_CLASS);
|
||||
layer?.append(el);
|
||||
},
|
||||
);
|
||||
show();
|
||||
},
|
||||
setMode(kind) {
|
||||
@@ -241,7 +468,19 @@ export function attachDetailSelect(input: {
|
||||
selected.map((i) => pieces[i]),
|
||||
),
|
||||
clear() {
|
||||
selected = [];
|
||||
[selected, chosenFaces] = [[], []];
|
||||
show();
|
||||
},
|
||||
choose(stored) {
|
||||
chosenFaces = [];
|
||||
noteText = null;
|
||||
selected = stored
|
||||
? pieces.flatMap((piece, i) => (stored.some((s) => samePiece(piece, s)) ? [i] : []))
|
||||
: [];
|
||||
show();
|
||||
},
|
||||
note(text) {
|
||||
noteText = text;
|
||||
show();
|
||||
},
|
||||
detach() {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) 또는 닫힌 선 조각들 · 길이 = 선 조각(들) —
|
||||
* 조각 = 선이 만나는 점에서 나눈 것(209-1 · `_Cross_Detail_Select`) · 저장 = 조각 두 끝 목록.
|
||||
* 길이는 한 번 누름 = 바로 칸에 · Shift/Ctrl+누름 = 여럿 → Enter · [칸에 넣기].
|
||||
* 닫힌 영역 안 누름 = 그 영역 둘레 조각(259-2) · 면적 고르기 중 띠 자리는 띠가 앞.
|
||||
* 표 칸 누름(259-2) = 저장된 조각을 그림에서 다시 고름 · 띠 칸 = 값 글(`cellGraph` · `bandLabel`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
@@ -19,10 +21,11 @@ import type {
|
||||
CrossDetailTable,
|
||||
CrossSection,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import type { SectionSample } from "./B06_Section_Api_Types";
|
||||
import type { CrossPickPiece, SectionSample } from "./B06_Section_Api_Types";
|
||||
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
|
||||
import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom";
|
||||
import { attachDetailSelect, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select";
|
||||
import { attachDetailSelect } from "./B06_Section_UI_Cross_Detail_Select";
|
||||
import { pointsArea } from "./B06_Section_UI_Cross_Pick_Order";
|
||||
|
||||
/** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */
|
||||
export const MAX_ROWS = 12;
|
||||
@@ -173,6 +176,32 @@ export function areaSourceOf(target: Element): CrossCellSource | null {
|
||||
return band ? { src: "area", key: band[1] } : null;
|
||||
}
|
||||
|
||||
/** 그 칸이 그래프에서 무엇을 가리키나(259-2) — 저장된 조각 · 띠 키(저장 띠 출처 → 칸 키) · 없음. */
|
||||
export function cellGraph(
|
||||
table: CrossDetailTable,
|
||||
key: string,
|
||||
): { pieces: CrossPickPiece[] } | { band: string } | null {
|
||||
const source = table.cells[key];
|
||||
if (source && "pieces" in source) return { pieces: source.pieces };
|
||||
if (source && source.src === "area" && "key" in source) return { band: source.key };
|
||||
return null;
|
||||
}
|
||||
|
||||
const BAND_NAME: Readonly<Record<string, string>> = {
|
||||
cut_soil: "깍기(토사)",
|
||||
cut_rock: "깍기(암석)",
|
||||
ditch_soil: "측구(토사)",
|
||||
ditch_rock: "측구(암석)",
|
||||
embankment: "쌓기",
|
||||
spoil_fill: "사토장",
|
||||
};
|
||||
|
||||
/** 띠 값 글(259-2) — 「깍기(토사) 면적 X㎡」 · 띠 아님 · 값 없음 = null. */
|
||||
export function bandLabel(key: string, design: CrossDesign | undefined): string | null {
|
||||
const value = BAND_NAME[key] ? localCellValue({ src: "area", key }, design) : null;
|
||||
return value === null ? null : `${BAND_NAME[key]} 면적 ${value.toFixed(2)}㎡`;
|
||||
}
|
||||
|
||||
/* ── 칸 값(짝 ① · 서버 `cell_value` 거울) ─────────────────────────────────── */
|
||||
|
||||
const AREA_DESIGN: Readonly<Record<string, string>> = {
|
||||
@@ -280,7 +309,7 @@ export interface DetailTableHost {
|
||||
let menu: ReturnType<typeof createMapContextMenu> | null = null;
|
||||
|
||||
const PICK_HINT = {
|
||||
area: "면적 띠를 누름 · 또는 닫힌 영역의 선들을 차례로 누름 → Enter · Esc 취소",
|
||||
area: "면적 띠를 누름 · 또는 닫힌 영역 안 · 선들을 누름 → Enter · Esc 취소",
|
||||
length: "선을 누름 · Shift/Ctrl+누름 = 여럿 → Enter · Esc 취소",
|
||||
} as const;
|
||||
|
||||
@@ -289,6 +318,10 @@ const PICK_HINT = {
|
||||
export function attachDetailTable(host: DetailTableHost): {
|
||||
detach: () => void;
|
||||
redraw: () => void;
|
||||
/** 칸 그래프 짝(259-2) — 저장된 조각 · 띠 키 · 없음. */
|
||||
graph: (key: string) => ReturnType<typeof cellGraph>;
|
||||
/** 표 칸 누름(259-2) — 저장된 조각을 그림에서 고름 · 띠면 값 글 · null = 비움. */
|
||||
showCell: (key: string | null) => void;
|
||||
} {
|
||||
menu ??= createMapContextMenu("b06-detail");
|
||||
const ctxMenu = menu;
|
||||
@@ -323,6 +356,7 @@ export function attachDetailTable(host: DetailTableHost): {
|
||||
const shown = localCellValue(source, section.design, section.samples);
|
||||
const td = tableBox.querySelector<HTMLElement>(`td[data-cell="${CSS.escape(key)}"]`);
|
||||
if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2);
|
||||
if ("key" in source) select.note(bandLabel(source.key, section.design)); // 띠도 값 글(259-2)
|
||||
edit((table) => setCell(table, key, source));
|
||||
};
|
||||
const commitSelection = (): void => {
|
||||
@@ -354,14 +388,19 @@ export function attachDetailTable(host: DetailTableHost): {
|
||||
if (picking?.kind === "length" && !additive) commitSelection();
|
||||
},
|
||||
commit: commitSelection,
|
||||
band: (event) => {
|
||||
const hit = bandAt(event);
|
||||
return hit ? pointsArea(hit.getAttribute("points")) : null;
|
||||
},
|
||||
});
|
||||
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로) — 선 조각 누름은 위가 맡음
|
||||
const onPick = (event: MouseEvent): void => {
|
||||
if (picking?.kind !== "area" || (event.target as Element).closest(`.${PIECE_HIT_CLASS}`))
|
||||
return;
|
||||
const hit = document
|
||||
const bandAt = (event: MouseEvent): Element | undefined =>
|
||||
document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR));
|
||||
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4) — 선 · 띠보다 좁은 영역은 위가 막음(261-3 거리 판정 · 295-1)
|
||||
const onPick = (event: MouseEvent): void => {
|
||||
if (picking?.kind !== "area") return;
|
||||
const hit = bandAt(event);
|
||||
const source = hit ? areaSourceOf(hit) : null;
|
||||
if (!source) return;
|
||||
event.stopPropagation();
|
||||
@@ -426,5 +465,15 @@ export function attachDetailTable(host: DetailTableHost): {
|
||||
tableBox.removeEventListener("keydown", onEnter);
|
||||
},
|
||||
redraw: () => select.redraw(),
|
||||
graph: (key) => cellGraph(detailTableOf(host.section(), host.projectId, host.routeId()), key),
|
||||
showCell: (key) => {
|
||||
const target = key
|
||||
? cellGraph(detailTableOf(host.section(), host.projectId, host.routeId()), key)
|
||||
: null;
|
||||
select.choose(target && "pieces" in target ? target.pieces : null);
|
||||
// 저장 조각 = 조각 값 글 · 저장 띠 = 그 띠 · 저장 없음 = 칸 키 띠
|
||||
const band = !target ? key : "band" in target ? target.band : null;
|
||||
select.note(band ? bandLabel(band, host.section().design) : null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ export function appendFordOverlay(
|
||||
ford.chute,
|
||||
x,
|
||||
toDisplayY,
|
||||
layout.ground,
|
||||
);
|
||||
// ④ 계류측 마감선 — 구조물이 원지반 위면 성토부선, 안이면 절토선(집수정과 같은 체계).
|
||||
for (const side of layout.sides) {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
* 성토측이라도 기슭막이 세트(다단·재질)와는 연계하지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { groundLine } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
import type { CrossSection, FordSet, SectionSample } from "./B06_Section_Api_Fetch";
|
||||
import { GUARD_CUT_BERM_M, pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type {
|
||||
@@ -108,6 +109,8 @@ export interface FordSideLayout {
|
||||
|
||||
export interface FordLayout {
|
||||
ford: FordSet;
|
||||
/** 원지반 — 도수로 · 돌붙임 길이가 사면보다 길면 이어 그림(294-4). */
|
||||
ground?: OffsetPoint[];
|
||||
sides: FordSideLayout[];
|
||||
/** 두 측벽 사이를 잇는 바닥판. */
|
||||
slabBridge: OffsetPoint[];
|
||||
@@ -329,6 +332,7 @@ export function computeFordLayout(
|
||||
|
||||
const countText = ford.pipe_count > 1 ? ` ${ford.pipe_count}련` : "";
|
||||
return {
|
||||
ground: groundLine(groundSamples),
|
||||
ford,
|
||||
sides,
|
||||
slabBridge,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Ford_Linked.ts
|
||||
* 물넘이포장 연결 표기(294-6) — 월류 폭으로 옆 측점 카드에 걸쳐 그린 포장층 · 월류 높이선을
|
||||
* 흙막이 연동 형상(79-6 `.is-linked`)과 같은 꼴로 · 호버 글에 주인 측점. 주인 측점 카드는 그대로 ·
|
||||
* 고름 표식(`data-structure-id`)은 주인 구조물 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** 연결 꼴을 입힐 물넘이 그림 — 포장층 · 월류 높이선 */
|
||||
const LINKED_CLASSES = ["b06-chart__ford-pavement", "b06-chart__ford-deck-plan"];
|
||||
|
||||
/** `from` 번째 자식부터(방금 그린 물넘이포장) 옆 카드면 연결 꼴 · 주인 측점 글. 붙였으면 true */
|
||||
export function markLinkedFordPavement(
|
||||
svg: SVGElement,
|
||||
from: number,
|
||||
section: Pick<CrossSection, "ford_pavement" | "chainage_m">,
|
||||
stationInterval: number,
|
||||
): boolean {
|
||||
const owner = section.ford_pavement?.chainage_m;
|
||||
if (typeof owner !== "number" || Math.abs(owner - section.chainage_m) < 1e-3) return false;
|
||||
const name = `물넘이포장 · ${stationLabel(owner, stationInterval)} 연결`;
|
||||
for (const el of [...svg.children].slice(from)) {
|
||||
if (!LINKED_CLASSES.some((className) => el.classList.contains(className))) continue;
|
||||
el.classList.add("is-linked");
|
||||
const title = document.createElementNS(SVG_NS, "title");
|
||||
title.textContent = name;
|
||||
el.append(title);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,75 +1,48 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Ford_Pavement.ts
|
||||
* 물넘이포장 — 계획고를 파낸 노면을 횡단도에 그린다(2026-08-28 사용자 확정).
|
||||
* 물넘이포장 — 횡단도 그림(287-2 · 287-4 · 그림_183).
|
||||
*
|
||||
* 다른 시설은 구조물을 얹지만 물넘이는 **노면 자체가 내려앉는다**. 그래서 그림도 다르다.
|
||||
* · 파임 범위 = 노견까지 **전폭**.
|
||||
* · 깊이 = **노선 중심**에서 잰 월류 높이(`depth_m`).
|
||||
* · 바닥은 **유입(상류)이 높고 유출이 낮게** 기운다. 경사를 비우면 그 측점의 노면
|
||||
* 횡단경사(`cross_slope_pct`)를 쓴다 — 사용자가 폼에서 바꿀 수 있다.
|
||||
* · 기존 계획고는 **점선**, 물넘이 바닥은 **실선**.
|
||||
* · 포장 필수 — 일반 포장층과 구분되게 진한 회색 + 빗금으로 채운다.
|
||||
*
|
||||
* 유입측 판정은 `section_mode`(상단측 절토 = 등고가 높은 쪽 = 상류)를 따른다.
|
||||
* · 콘크리트 윗면 = 설계 노면 — 서버(`compute_cross_design(tilted=…)`) · 짝 TS 가 노면을 경사각도
|
||||
* (`ford_slope_deg` → `slope_pct`)로 지대 낮은 쪽 한 경사로 기울여 낸 면 · 설계 중심점이 그 위.
|
||||
* 노면 밖은 설계선 그대로 — 노면 끝에서 성토 · 절토 사면이 이어짐(늘어나지 못한 자리 = 성토).
|
||||
* · 포장층 = 노면공 포장 형상(`appendPavementOverlay`)을 노견까지.
|
||||
* · 월류 높이선(회색 점선) = 노면 위로 월류 높이(`depth_m`)만큼 · 노면과 같은 각도.
|
||||
* · 도수로 · 돌붙임 띠 = 유출(지대 낮은 쪽) 성토사면.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign } from "./B06_Section_Api_Fetch";
|
||||
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** 빗금 패턴 id는 문서 안에서 유일해야 한다 — 카드마다 하나씩 번호를 준다. */
|
||||
let hatchSeq = 0;
|
||||
import { appendPavementOverlay } from "./B06_Section_UI_Cross_Design_Surface";
|
||||
import { appendChuteBand, chuteRun } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
|
||||
/** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */
|
||||
export interface FordPavementSpec {
|
||||
/** 관 지점 누가거리 — 폭 절반 걸친 옆 카드도 같은 값. */
|
||||
chainage_m?: number;
|
||||
span_m: number;
|
||||
/** 노선 중심에서 잰 파임 깊이(m). 없으면 그리지 않는다 — 수치를 지어내지 않는다. */
|
||||
/** 월류 높이(m) — 노면 위 점선. 없으면 점선을 안 그림(수치를 지어내지 않는다). */
|
||||
depth_m: number | null;
|
||||
/** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */
|
||||
/** 노면 경사(%) — 경사각도 환산 · 서버 설계 노면이 이 값으로 기욺. */
|
||||
slope_pct: number | null;
|
||||
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
|
||||
defaulted?: string[];
|
||||
/** 도수로 · 돌붙임(`ford_chute_{n}_*` · 136-2) — 유출측 성토사면 띠. */
|
||||
chute?: { structure: string; thickness_m: number };
|
||||
/** 도수로 · 돌붙임(`ford_chute_{n}_*` · 136-2) — 유출측 성토사면 띠 · 길이 = 사면 따라(286-3 · 비면 원지반까지). */
|
||||
chute?: { structure: string; thickness_m: number; length_m?: number };
|
||||
}
|
||||
|
||||
interface Edge {
|
||||
offset_m: number;
|
||||
elevation_m: number;
|
||||
}
|
||||
|
||||
/** 유입측(상류) 부호 — 그 방향으로 갈수록 바닥이 높아진다. */
|
||||
function inflowSign(design: CrossDesign, offsetM: number): number {
|
||||
const inflowLeft = design.section_mode !== "right_cut";
|
||||
const towardLeft = offsetM > 0;
|
||||
return towardLeft === inflowLeft ? 1 : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파인 노면의 표고(m). 횡단도와 3D 코리도가 **같은 식**을 써야 두 그림이 어긋나지 않는다.
|
||||
* 중심에서 깊이만큼 내리고, 유입측으로 갈수록 경사만큼 올린다.
|
||||
*/
|
||||
export function fordDeckElevationAt(
|
||||
design: CrossDesign,
|
||||
spec: FordPavementSpec,
|
||||
offsetM: number,
|
||||
fallbackElevationM = 0,
|
||||
): number {
|
||||
const depth = spec.depth_m ?? 0;
|
||||
const slope = (spec.slope_pct ?? design.cross_slope_pct) / 100;
|
||||
const center = design.design_elevation_m ?? fallbackElevationM;
|
||||
return center - depth + slope * Math.abs(offsetM) * inflowSign(design, offsetM);
|
||||
}
|
||||
|
||||
function bottomAt(design: CrossDesign, spec: FordPavementSpec, edge: Edge): number {
|
||||
return fordDeckElevationAt(design, spec, edge.offset_m, edge.elevation_m);
|
||||
/** 유입(상류)이 좌측(+offset)인가 — 기울인 노면이면 높은 노면 끝 · 아니면 설계선 양 끝 표고가 높은
|
||||
* 쪽(286-2) · 같으면 단면유형. */
|
||||
export function inflowLeft(design: CrossDesign): boolean {
|
||||
const edges = design.road_edges;
|
||||
if (design.tilted && edges && Math.abs(edges.left.elevation_m - edges.right.elevation_m) > 1e-6)
|
||||
return edges.left.elevation_m > edges.right.elevation_m;
|
||||
const line = [...(design.design_line ?? [])].sort((a, b) => a.offset_m - b.offset_m);
|
||||
const gap = line.length >= 2 ? line[line.length - 1].elevation_m - line[0].elevation_m : 0;
|
||||
return Math.abs(gap) > 1e-3 ? gap > 0 : design.section_mode !== "right_cut";
|
||||
}
|
||||
|
||||
function line(points: string[], className: string): SVGPolylineElement {
|
||||
const polyline = document.createElementNS(SVG_NS, "polyline");
|
||||
const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
|
||||
polyline.setAttribute("points", points.join(" "));
|
||||
polyline.setAttribute("class", className);
|
||||
return polyline;
|
||||
@@ -119,8 +92,9 @@ export function appendFordSurfaceDropPlan(
|
||||
}
|
||||
|
||||
/**
|
||||
* 물넘이 파임을 그린다. 그렸으면 true — 호출부는 일반 포장층 박스를 건너뛴다
|
||||
* (같은 자리에 두 겹으로 깔리면 색 구분이 사라진다).
|
||||
* 물넘이포장을 그린다(287-2 · 287-4) — 포장층(노면공 형상 · 노견까지) · 월류 높이선 · 도수로 띠.
|
||||
* 그렸으면 true — 호출부는 일반 포장층을 건너뜀(두 겹으로 안 깔림). 포장층에 물넘이 표식
|
||||
* (`b06-chart__ford-pavement` · 287-5 고름이 노면공 포장 고름과 가름).
|
||||
*/
|
||||
export function appendFordPavementOverlay(
|
||||
svg: SVGElement,
|
||||
@@ -128,76 +102,81 @@ export function appendFordPavementOverlay(
|
||||
design: CrossDesign,
|
||||
x: (offset: number) => number,
|
||||
y: (elevation: number) => number,
|
||||
/** 원지반 — 도수로 띠가 원지반에 닿으면 끝 · 길이가 사면보다 길면 이어 그림(294-4). */
|
||||
ground?: readonly OffsetPoint[],
|
||||
): boolean {
|
||||
if (!spec || !spec.depth_m || !design.road_edges) return false;
|
||||
const { left, right } = design.road_edges;
|
||||
const bottomLeft = bottomAt(design, spec, left);
|
||||
const bottomRight = bottomAt(design, spec, right);
|
||||
|
||||
// ① 기존 계획고(점선) — 파기 전 노면이 어디였는지 남긴다.
|
||||
svg.append(
|
||||
line(
|
||||
[
|
||||
`${x(left.offset_m)},${y(left.elevation_m)}`,
|
||||
`${x(right.offset_m)},${y(right.elevation_m)}`,
|
||||
],
|
||||
"b06-chart__ford-deck-plan",
|
||||
),
|
||||
);
|
||||
|
||||
// ② 포장층 — 바닥선에서 두께만큼 아래로. 일반 포장과 색·빗금으로 구분한다.
|
||||
const thickness = design.pavement_thickness_m ?? 0.2;
|
||||
const hatchId = `b06-ford-hatch-${(hatchSeq += 1)}`;
|
||||
const defs = document.createElementNS(SVG_NS, "defs");
|
||||
const pattern = document.createElementNS(SVG_NS, "pattern");
|
||||
pattern.setAttribute("id", hatchId);
|
||||
pattern.setAttribute("patternUnits", "userSpaceOnUse");
|
||||
pattern.setAttribute("width", "6");
|
||||
pattern.setAttribute("height", "6");
|
||||
const stroke = document.createElementNS(SVG_NS, "path");
|
||||
stroke.setAttribute("d", "M0,6 L6,0");
|
||||
stroke.setAttribute("class", "b06-chart__ford-pavement-hatch");
|
||||
pattern.append(stroke);
|
||||
defs.append(pattern);
|
||||
svg.append(defs);
|
||||
|
||||
const polygon = document.createElementNS(SVG_NS, "polygon");
|
||||
polygon.setAttribute(
|
||||
"points",
|
||||
[
|
||||
`${x(left.offset_m)},${y(bottomLeft)}`,
|
||||
`${x(right.offset_m)},${y(bottomRight)}`,
|
||||
`${x(right.offset_m)},${y(bottomRight - thickness)}`,
|
||||
`${x(left.offset_m)},${y(bottomLeft - thickness)}`,
|
||||
].join(" "),
|
||||
);
|
||||
polygon.setAttribute("class", "b06-chart__ford-pavement");
|
||||
polygon.setAttribute("fill", `url(#${hatchId})`);
|
||||
svg.append(polygon);
|
||||
|
||||
// ③ 물넘이 바닥(실선) — 횡단 기준선이라 가장 위에 올린다.
|
||||
svg.append(
|
||||
line(
|
||||
[`${x(left.offset_m)},${y(bottomLeft)}`, `${x(right.offset_m)},${y(bottomRight)}`],
|
||||
"b06-chart__ford-deck",
|
||||
),
|
||||
);
|
||||
if (spec.chute) appendChuteBand(svg, [{ points: outletFillLine(design) }], spec.chute, x, y);
|
||||
const edges = fordEdges(design);
|
||||
if (!spec || !edges) return false;
|
||||
const from = svg.children.length;
|
||||
// 노면공 항목은 안 넘김 — 물넘이 자체 포장(노면공 포장 고름과 안 섞임)
|
||||
appendPavementOverlay(svg, { ...design, paved: true, carriageway_edges: edges }, x, y, null);
|
||||
[...svg.children]
|
||||
.slice(from)
|
||||
.filter((el) => el.getAttribute("class") === "b06-chart__pavement")
|
||||
.forEach((el) => {
|
||||
el.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement");
|
||||
// 형상 고름 표식(287-5) — 관 지점 알약 id 와 같은 글
|
||||
if (typeof spec.chainage_m === "number")
|
||||
el.setAttribute("data-structure-id", `pipe-${spec.chainage_m.toFixed(2)}`);
|
||||
});
|
||||
// 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183)
|
||||
const depth = spec.depth_m ?? 0;
|
||||
if (depth > 0)
|
||||
svg.append(
|
||||
line(
|
||||
[
|
||||
`${x(edges.left.offset_m)},${y(edges.left.elevation_m + depth)}`,
|
||||
`${x(edges.right.offset_m)},${y(edges.right.elevation_m + depth)}`,
|
||||
],
|
||||
"b06-chart__ford-deck-plan",
|
||||
),
|
||||
);
|
||||
if (spec.chute)
|
||||
appendChuteBand(svg, [{ points: fordChuteLine(design, spec.chute, ground) }], spec.chute, x, y);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유출측(`inflowSign` 반대) 노견 바깥 성토사면 선(136-2) — 설계선을 노견에서 바깥으로 따라
|
||||
* 내려가는 동안만(오르면 절토라 멈춤). 성토가 없으면 빈 선(띠 안 그림).
|
||||
*/
|
||||
export function outletFillLine(design: CrossDesign): OffsetPoint[] {
|
||||
/** 콘크리트 · 노면 양 끝 — 낮은 쪽을 늘였으면(294-3 `tilt_reach`) 그 쪽은 원지반과 만난 자리까지. */
|
||||
export function fordEdges(design: CrossDesign): CrossDesign["road_edges"] | undefined {
|
||||
const edges = design.road_edges;
|
||||
const reach = design.tilt_reach;
|
||||
if (!edges || !reach) return edges;
|
||||
const end = { offset_m: reach.offset_m, elevation_m: reach.elevation_m };
|
||||
return reach.side === "left" ? { ...edges, left: end } : { ...edges, right: end };
|
||||
}
|
||||
|
||||
/** 원지반 꺾은선의 그 오프셋 표고(밖은 끝값). */
|
||||
function groundZ(ground: readonly OffsetPoint[], offset: number): number | null {
|
||||
if (!ground.length) return null;
|
||||
if (offset <= ground[0].offset) return ground[0].elevation;
|
||||
for (let i = 1; i < ground.length; i += 1) {
|
||||
const [a, b] = [ground[i - 1], ground[i]];
|
||||
if (offset > b.offset) continue;
|
||||
const span = b.offset - a.offset;
|
||||
return span > 1e-9
|
||||
? a.elevation + ((b.elevation - a.elevation) * (offset - a.offset)) / span
|
||||
: b.elevation;
|
||||
}
|
||||
return ground[ground.length - 1].elevation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유출측(낮은 쪽) 성토사면 선(136-2 · 294-4) — 낮은 쪽 끝단(늘인 노면 끝 · 아니면 유출 쪽 노견 끝)에서
|
||||
* 설계선을 바깥으로 따라 내려가는 동안만(오르면 절토라 멈춤) · 원지반(`ground`)에 닿으면 끝.
|
||||
* 성토가 없으면(끝단이 이미 원지반 · 양측 절토 물넘이) 점 하나 = 띠 없음.
|
||||
*/
|
||||
export function outletFillLine(
|
||||
design: CrossDesign,
|
||||
ground?: readonly OffsetPoint[],
|
||||
): OffsetPoint[] {
|
||||
const edges = fordEdges(design);
|
||||
if (!edges || !design.design_line?.length) return [];
|
||||
const outward = design.section_mode !== "right_cut" ? -1 : 1; // +offset = 좌측 · 유입 반대
|
||||
const edge =
|
||||
outward > 0
|
||||
? Math.max(edges.left.offset_m, edges.right.offset_m)
|
||||
: Math.min(edges.left.offset_m, edges.right.offset_m);
|
||||
const reach = design.tilt_reach;
|
||||
const outLeft = reach ? reach.side === "left" : !inflowLeft(design); // +offset = 좌측
|
||||
const outward = outLeft ? 1 : -1;
|
||||
const edge = outLeft
|
||||
? Math.max(edges.left.offset_m, edges.right.offset_m)
|
||||
: Math.min(edges.left.offset_m, edges.right.offset_m);
|
||||
const points = design.design_line
|
||||
.filter((p) => (p.offset_m - edge) * outward >= -1e-6)
|
||||
.sort((a, b) => (a.offset_m - b.offset_m) * outward);
|
||||
@@ -206,61 +185,24 @@ export function outletFillLine(design: CrossDesign): OffsetPoint[] {
|
||||
const last = line[line.length - 1];
|
||||
if (last && p.elevation_m > last.elevation + 1e-6) break;
|
||||
line.push({ offset: p.offset_m, elevation: p.elevation_m });
|
||||
const z = ground ? groundZ(ground, p.offset_m) : null;
|
||||
if (z !== null && Math.abs(z - p.elevation_m) < 1e-3) break; // 원지반에 닿음 = 끝
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
/**
|
||||
* 물넘이 자리 노면 · 측구 설계선을 걷는다(66-2 · 66-3) — 노면은 파인 바닥(실선)과 옛 계획고(점선)가
|
||||
* 대신하고, 측구는 서지 않음. `from` = 설계선 그리기 앞의 자식 수(그 뒤에 붙은 선만 본다).
|
||||
* 측구 자리는 바닥 끝에서 절토 비탈 발까지 실선으로 잇는다. 면적 · 서버 설계선은 그대로(그림만).
|
||||
* 물넘이 도수로 · 돌붙임 선(286-3 · 294-4) — 낮은 쪽 끝단에서 성토사면 따라 · 길이 비움 = 원지반 닿으면 끝 ·
|
||||
* 길이 넣음 = 그 길이(사면보다 길면 원지반 따라 이어) · 양측 절토(늘인 노면 끝이 원지반) = 길이 없으면 안 그림.
|
||||
* 횡단 띠 · 3D 띠가 같이 씀(그림 전용).
|
||||
*/
|
||||
export function hideFordPavedRoad(
|
||||
layer: SVGElement,
|
||||
from: number,
|
||||
export function fordChuteLine(
|
||||
design: CrossDesign,
|
||||
spec: FordPavementSpec,
|
||||
x: (offset: number) => number,
|
||||
y: (elevation: number) => number,
|
||||
): void {
|
||||
const edges = design.road_edges;
|
||||
const designLine = design.design_line;
|
||||
if (!edges || !designLine?.length) return;
|
||||
const ditch = design.ditch;
|
||||
const ditchWidth =
|
||||
design.ditch_enabled === false || ditch.type === "none"
|
||||
? 0
|
||||
: ditch.type === "standard"
|
||||
? ditch.top_width_m
|
||||
: ditch.width_m;
|
||||
// +offset = 좌측 · 측구는 노견 바깥 그 쪽에 붙는다
|
||||
const leftEdge = Math.max(edges.left.offset_m, edges.right.offset_m);
|
||||
const rightEdge = Math.min(edges.left.offset_m, edges.right.offset_m);
|
||||
const hi = leftEdge + (design.ditch_side === "left" ? ditchWidth : 0);
|
||||
const lo = rightEdge - (design.ditch_side === "right" ? ditchWidth : 0);
|
||||
const [xa, xb] = [x(lo), x(hi)];
|
||||
const inside = (px: number): boolean =>
|
||||
px >= Math.min(xa, xb) - 0.5 && px <= Math.max(xa, xb) + 0.5;
|
||||
for (const node of [...layer.children].slice(from)) {
|
||||
if (node.tagName !== "line" || !node.classList.contains("b06-chart__design-cross")) continue;
|
||||
if (inside(Number(node.getAttribute("x1"))) && inside(Number(node.getAttribute("x2"))))
|
||||
node.remove();
|
||||
}
|
||||
if (!(ditchWidth > 0) || !design.ditch_side) return;
|
||||
const edge = design.ditch_side === "left" ? leftEdge : rightEdge;
|
||||
const foot = design.ditch_side === "left" ? hi : lo;
|
||||
const sorted = [...designLine].sort((a, b) => a.offset_m - b.offset_m);
|
||||
const at = sorted.find((point) => Math.abs(point.offset_m - foot) < 1e-3);
|
||||
if (!at) return;
|
||||
const edgeElevation = design.ditch_side === "left" ? edges.left : edges.right;
|
||||
layer.append(
|
||||
line(
|
||||
[
|
||||
`${x(edge)},${y(fordDeckElevationAt(design, spec, edge, edgeElevation.elevation_m))}`,
|
||||
`${x(foot)},${y(at.elevation_m)}`,
|
||||
],
|
||||
"b06-chart__ford-deck",
|
||||
),
|
||||
chute?: { length_m?: number },
|
||||
ground?: readonly OffsetPoint[],
|
||||
): OffsetPoint[] {
|
||||
return (
|
||||
chuteRun([{ points: outletFillLine(design, ground) }], chute?.length_m, ground)[0]?.points ?? []
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Pick_Order.ts
|
||||
* 횡단 카드 고르기 차례(295-1) — 고를 자리가 좁은 것이 위(모달 `pickAt` 과 같은 잣대).
|
||||
*
|
||||
* 판정면(구조물 · 띠 · 형상)을 다 꽂은 뒤 넓이 큰 것부터 아래로 다시 세움(작은 것 위) ·
|
||||
* 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로.
|
||||
* 끌고 뗀 누름(3 px 넘게 움직임)은 고름 아님.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 면 판정 — 구조물(벽 · 관 · 박스 · 도수로 띠 등) · 물넘이포장 · 맹암거 형상. */
|
||||
const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"];
|
||||
/** 선 판정 — 보라 설계선 조각(`_Cross_Design_Pick`). */
|
||||
const LINE_HIT_CLASS = "b06-chart__design-hit";
|
||||
/** 끌기로 볼 움직임(px) — 형상 고름(`SHAPE_DRAG_PX`)과 같은 값. */
|
||||
const DRAG_PX = 3;
|
||||
|
||||
/** `points` 속성 다각형 넓이(px²) — 점이 셋 못 되면 0. */
|
||||
export function pointsArea(points: string | null): number {
|
||||
const pts = (points ?? "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((pair) => pair.split(",").map(Number))
|
||||
.filter((p) => p.length === 2 && p.every(Number.isFinite));
|
||||
if (pts.length < 3) return 0;
|
||||
let twice = 0;
|
||||
pts.forEach(([x0, y0], i) => {
|
||||
const [x1, y1] = pts[(i + 1) % pts.length];
|
||||
twice += x0 * y1 - x1 * y0;
|
||||
});
|
||||
return Math.abs(twice) / 2;
|
||||
}
|
||||
|
||||
/** 판정면을 넓이 차례로 다시 세우고 보라 선 판정을 맨 위로 · 끌기 누름을 막음. */
|
||||
export function stackPickTargets(layer: SVGElement): void {
|
||||
const kids = [...layer.children];
|
||||
const surfaces = kids
|
||||
.filter((el) => SURFACE_HIT_CLASSES.some((name) => el.classList.contains(name)))
|
||||
.map((el) => ({ el, area: pointsArea(el.getAttribute("points")) }))
|
||||
.sort((a, b) => b.area - a.area)
|
||||
.map(({ el }) => el);
|
||||
const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS));
|
||||
layer.append(...surfaces, ...lines);
|
||||
let down: { x: number; y: number } | null = null;
|
||||
layer.addEventListener(
|
||||
"pointerdown",
|
||||
(event) => (down = { x: (event as PointerEvent).clientX, y: (event as PointerEvent).clientY }),
|
||||
true,
|
||||
);
|
||||
layer.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const { clientX, clientY } = event as MouseEvent;
|
||||
if (down && Math.hypot(clientX - down.x, clientY - down.y) > DRAG_PX) event.stopPropagation();
|
||||
down = null;
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Shape_Pick.ts
|
||||
* 물넘이포장 · 맹암거 형상 고름(287-5 · 288-2 · 요구 185).
|
||||
*
|
||||
* 그린 형상(`data-structure-id` 단 포장층 · 돌 무늬 둘레)마다 투명 판정면 — 누르면 그 구조물을 고름
|
||||
* (다른 시설 형상 고름과 같은 창구 `onPick`). 지킬 것:
|
||||
* ① 보라 손잡이(설계선 판정 · 뒤에 붙음)가 위 — 판정면은 구조물 판정면 바로 앞(아래)에 꽂고
|
||||
* 구조물 판정 클래스(`b06-chart__culvert-revet-hit`)를 안 씀(보라 판정이 그 앞에 꽂혀 형상 위로 옴).
|
||||
* ② 누름만 고름 — 눌렀다 3 px 넘게 움직이면(끌기) 고름 아님.
|
||||
* ③ 노면공 포장 고름과 안 섞임 — 노면공 범위 표식(`data-pave-type`) 없는 형상만 · 누름은 번지지 않음.
|
||||
* ④ 얇은 형상(물넘이 콘크리트 · 기본 배율 4 px)은 판정면을 아래쪽으로 `SHAPE_MIN_PX` 두께까지 늘림 —
|
||||
* 노면선 보라 판정 띠(굵기 10 · 위아래 5) 바깥 아래를 누르면 형상(294-7).
|
||||
* ========================================================================== */
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** 관 지점이 아닌 구조물(맹암거) 형상 누름 — 카드 밖(구조물 목록)이 id 로 고름(288-2). */
|
||||
export const SHAPE_PICK_EVENT = "b06-structure-shape-pick";
|
||||
/** 끌기로 볼 움직임(px) — 이보다 크면 고름 아님. */
|
||||
export const SHAPE_DRAG_PX = 3;
|
||||
/** 형상 판정면 최소 두께(그림 px) — 보라 판정 띠 아래 반(5)보다 넉넉히(294-7). */
|
||||
export const SHAPE_MIN_PX = 12;
|
||||
|
||||
type XY = [number, number];
|
||||
const parse = (points: string): XY[] =>
|
||||
points
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((pair) => pair.split(",").map(Number) as XY)
|
||||
.filter((p) => p.length === 2 && p.every(Number.isFinite));
|
||||
const insideOf = (ring: XY[], [x, y]: XY): boolean =>
|
||||
ring.reduce((inn, [ax, ay], i) => {
|
||||
const [bx, by] = ring[(i + 1) % ring.length];
|
||||
return ay > y !== by > y && x < ax + ((y - ay) * (bx - ax)) / (by - ay) ? !inn : inn;
|
||||
}, false);
|
||||
|
||||
/** 얇은 형상 → 판정면 꼭짓점 — 바로 아래가 형상 밖인 꼭짓점(아랫변)만 내려 두께를 `minPx` 로.
|
||||
* 두께 = 넓이 ÷ 가로 폭(기운 노면도 세로 두께로 잼). */
|
||||
export function thickenDown(points: string, minPx = SHAPE_MIN_PX): string {
|
||||
const ring = parse(points);
|
||||
if (ring.length < 3) return points;
|
||||
const xs = ring.map(([x]) => x);
|
||||
const area = Math.abs(
|
||||
ring.reduce((sum, [x0, y0], i) => {
|
||||
const [x1, y1] = ring[(i + 1) % ring.length];
|
||||
return sum + x0 * y1 - x1 * y0;
|
||||
}, 0) / 2,
|
||||
);
|
||||
const drop = minPx - area / Math.max(Math.max(...xs) - Math.min(...xs), 1e-9);
|
||||
if (drop <= 0) return points;
|
||||
const midX = ring.reduce((sum, [x]) => sum + x, 0) / ring.length;
|
||||
const e = 1e-3;
|
||||
return ring
|
||||
.map(([x, y]): XY => {
|
||||
const probe: XY = [x + (midX > x ? e : midX < x ? -e : 0), y + e];
|
||||
return insideOf(ring, probe) ? [x, y] : [x, y + drop];
|
||||
})
|
||||
.map((p) => p.join(","))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** 판정면을 꽂고 강조 setter 를 돌려줌 — 형상이 없으면 null. */
|
||||
export function attachShapePick(
|
||||
layer: SVGElement,
|
||||
onPick?: (structureId: string) => void,
|
||||
): ((active: boolean) => void) | null {
|
||||
const shapes = [...layer.querySelectorAll<SVGElement>("[data-structure-id]")].filter(
|
||||
(el) => !el.hasAttribute("data-pave-type"),
|
||||
);
|
||||
if (!shapes.length) return null;
|
||||
const kids = [...layer.children];
|
||||
const firstStructureHit = kids.find((el) =>
|
||||
el.classList.contains("b06-chart__culvert-revet-hit"),
|
||||
);
|
||||
for (const shape of onPick ? shapes : []) {
|
||||
const id = shape.getAttribute("data-structure-id") ?? "";
|
||||
const hit = document.createElementNS(SVG_NS, "polygon");
|
||||
hit.setAttribute("points", thickenDown(shape.getAttribute("points") ?? ""));
|
||||
hit.setAttribute("class", "b06-chart__shape-hit");
|
||||
let down: { x: number; y: number } | null = null;
|
||||
hit.addEventListener("pointerdown", (event) => {
|
||||
down = { x: event.clientX, y: event.clientY };
|
||||
});
|
||||
hit.addEventListener("click", (event) => {
|
||||
const moved =
|
||||
down && Math.hypot(event.clientX - down.x, event.clientY - down.y) > SHAPE_DRAG_PX;
|
||||
down = null;
|
||||
if (moved) return; // 끌기 = 고름 아님(②)
|
||||
event.stopPropagation();
|
||||
onPick?.(id);
|
||||
});
|
||||
layer.insertBefore(hit, firstStructureHit ?? null);
|
||||
}
|
||||
return (active) => shapes.forEach((shape) => shape.classList.toggle("is-active", active));
|
||||
}
|
||||
|
||||
/** 형상 누름 창구(287-5 · 288-2) — 관 지점(`pipe-…` · 물넘이포장) = 그 물넘이 누가거리로 고름(카드의 다른 관 ·
|
||||
* 연동 관 아님 · 294-7) · 그 밖(맹암거) = id 를 실은 알림을 위로 띄움(구조물 목록이 받음). */
|
||||
export function shapePickRoute(
|
||||
from: Element,
|
||||
pickPipe: (chainageM: number) => void,
|
||||
): (structureId: string) => void {
|
||||
return (structureId) => {
|
||||
if (structureId.startsWith("pipe-")) return pickPipe(Number(structureId.slice(5)));
|
||||
from.dispatchEvent(new CustomEvent(SHAPE_PICK_EVENT, { bubbles: true, detail: structureId }));
|
||||
};
|
||||
}
|
||||
|
||||
/** 카드 안 형상 강조 — 고른 구조물 id 와 같은 형상만(목록 · 알약 · 폼 고름이 따라옴). */
|
||||
export function highlightShapes(root: ParentNode, structureId: string | null): void {
|
||||
for (const shape of root.querySelectorAll<SVGElement>("[data-structure-id]")) {
|
||||
shape.classList.toggle(
|
||||
"is-active",
|
||||
!!structureId && shape.getAttribute("data-structure-id") === structureId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Underdrain.ts
|
||||
* 맹암거 횡단 형상(288-1 · 요구 183-3).
|
||||
*
|
||||
* · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음.
|
||||
* · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도.
|
||||
* · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남.
|
||||
* · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(흙막이 돌 그리기 `drawStones`).
|
||||
* 높이가 비면 그리지 않음(수치를 지어내지 않음).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign } from "./B06_Section_Api_Fetch";
|
||||
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { drawStones } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
import type { UnderdrainSpec } from "@util/common_util_structure_underdrain";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
let clipSeq = 0;
|
||||
|
||||
type Edge = { offset_m: number; elevation_m: number };
|
||||
|
||||
/** 한쪽 둘레(노면 끝 → 옵셋선과 만나는 자리) — `outward` = 바깥 부호(+1 좌 · −1 우). */
|
||||
function sidePath(
|
||||
edge: Edge,
|
||||
outward: 1 | -1,
|
||||
line: readonly Edge[],
|
||||
roadGrade: number,
|
||||
heightM: number,
|
||||
): OffsetPoint[] {
|
||||
const start = { offset: edge.offset_m, elevation: edge.elevation_m };
|
||||
const offZ = (o: number) => edge.elevation_m + roadGrade * (o - edge.offset_m) - heightM;
|
||||
const outside = line
|
||||
.filter((p) => (p.offset_m - edge.offset_m) * outward > 1e-6)
|
||||
.sort((a, b) => (a.offset_m - b.offset_m) * outward);
|
||||
const first = outside[0];
|
||||
if (!first) return [start, { offset: start.offset, elevation: offZ(start.offset) }];
|
||||
if (first.elevation_m >= edge.elevation_m) {
|
||||
// 절토 — 사면을 안쪽 아래로 늘여 옵셋선과 만남
|
||||
const k = (first.elevation_m - edge.elevation_m) / (first.offset_m - edge.offset_m);
|
||||
const d = Math.abs(k - roadGrade) < 1e-9 ? 0 : -heightM / (k - roadGrade);
|
||||
const offset = edge.offset_m + d;
|
||||
return [start, { offset, elevation: offZ(offset) }];
|
||||
}
|
||||
// 성토 — 사면을 따라 내려가다 옵셋선을 만나는 자리
|
||||
const path: OffsetPoint[] = [start];
|
||||
let prev = edge;
|
||||
for (const point of outside) {
|
||||
const fa = prev.elevation_m - offZ(prev.offset_m);
|
||||
const fb = point.elevation_m - offZ(point.offset_m);
|
||||
if (fb <= 0) {
|
||||
const t = fa / (fa - fb);
|
||||
const offset = prev.offset_m + (point.offset_m - prev.offset_m) * t;
|
||||
path.push({ offset, elevation: offZ(offset) });
|
||||
return path;
|
||||
}
|
||||
path.push({ offset: point.offset_m, elevation: point.elevation_m });
|
||||
prev = point;
|
||||
}
|
||||
path.push({ offset: prev.offset_m, elevation: offZ(prev.offset_m) });
|
||||
return path;
|
||||
}
|
||||
|
||||
/** 맹암거 형상 둘레 — 좌 노면 끝 → 우 노면 끝 → 우 둘레 → 옵셋선 → 좌 둘레(위로). 높이 없으면 null. */
|
||||
export function underdrainOutline(
|
||||
design: CrossDesign,
|
||||
heightM: number | null,
|
||||
): OffsetPoint[] | null {
|
||||
const edges = design.road_edges;
|
||||
if (!edges || !(heightM && heightM > 0)) return null;
|
||||
const { left, right } = edges;
|
||||
const span = left.offset_m - right.offset_m;
|
||||
if (!(Math.abs(span) > 1e-9)) return null;
|
||||
const grade = (left.elevation_m - right.elevation_m) / span;
|
||||
const line = design.design_line ?? [];
|
||||
const leftPath = sidePath(left, 1, line, grade, heightM);
|
||||
const rightPath = sidePath(right, -1, line, grade, heightM);
|
||||
return [
|
||||
{ offset: left.offset_m, elevation: left.elevation_m },
|
||||
...rightPath,
|
||||
...leftPath.slice(1).reverse(),
|
||||
];
|
||||
}
|
||||
|
||||
/** 맹암거 형상을 그린다(돌 무늬 · 둘레 클립). 그렸으면 그 무리 — 둘레에 고름 표식(`data-structure-id` · 288-2). */
|
||||
export function appendUnderdrainOverlay(
|
||||
svg: SVGElement,
|
||||
spec: UnderdrainSpec | undefined,
|
||||
design: CrossDesign,
|
||||
x: (offset: number) => number,
|
||||
y: (elevation: number) => number,
|
||||
): SVGGElement | null {
|
||||
const outline = spec ? underdrainOutline(design, spec.height_m) : null;
|
||||
if (!spec || !outline) return null;
|
||||
const px = (p: OffsetPoint): [number, number] => [x(p.offset), y(p.elevation)];
|
||||
const points = outline.map((p) => px(p).join(",")).join(" ");
|
||||
const group = document.createElementNS(SVG_NS, "g") as SVGGElement;
|
||||
group.setAttribute("class", "b06-chart__underdrain");
|
||||
const body = document.createElementNS(SVG_NS, "polygon");
|
||||
body.setAttribute("points", points);
|
||||
body.setAttribute("class", "b06-chart__underdrain-body");
|
||||
if (spec.structure_id) body.setAttribute("data-structure-id", spec.structure_id);
|
||||
clipSeq += 1;
|
||||
const clipId = `b06-underdrain-clip-${clipSeq}`;
|
||||
const clip = document.createElementNS(SVG_NS, "clipPath");
|
||||
clip.setAttribute("id", clipId);
|
||||
const clipShape = document.createElementNS(SVG_NS, "polygon");
|
||||
clipShape.setAttribute("points", points);
|
||||
clip.append(clipShape);
|
||||
const stones = document.createElementNS(SVG_NS, "g");
|
||||
stones.setAttribute("clip-path", `url(#${clipId})`);
|
||||
const height = spec.height_m!;
|
||||
const offsets = outline.map((p) => p.offset);
|
||||
const [lo, hi] = [Math.min(...offsets), Math.max(...offsets)];
|
||||
const { left, right } = design.road_edges;
|
||||
const grade = (left.elevation_m - right.elevation_m) / (left.offset_m - right.offset_m);
|
||||
const roadZ = (o: number) => left.elevation_m + grade * (o - left.offset_m);
|
||||
const at = (u: number, v: number): OffsetPoint => {
|
||||
const offset = lo + (hi - lo) * u;
|
||||
return { offset, elevation: roadZ(offset) - height * (v + 0.5) };
|
||||
};
|
||||
const [ax, ay] = px(at(0, 0));
|
||||
const [bx, by] = px(at(1, 0));
|
||||
drawStones(
|
||||
stones,
|
||||
{
|
||||
spanM: Math.hypot(hi - lo, grade * (hi - lo)),
|
||||
pixelsPerMeter: Math.abs(x(1) - x(0)) || 1,
|
||||
point: (u, v) => px(at(u, v)),
|
||||
leanDegrees: (Math.atan2(bx - ax, -(by - ay)) * 180) / Math.PI,
|
||||
},
|
||||
false,
|
||||
height,
|
||||
);
|
||||
group.append(body, clip, stones);
|
||||
svg.append(group);
|
||||
return group;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard";
|
||||
import { markLinkedFordPavement } from "./B06_Section_UI_Cross_Ford_Linked";
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
appendCrossAreaBands,
|
||||
@@ -17,12 +18,14 @@ import {
|
||||
} from "./B06_Section_UI_Cross_Areas";
|
||||
import {
|
||||
appendFordPavementOverlay,
|
||||
hideFordPavedRoad,
|
||||
appendFordSurfaceDropPlan,
|
||||
} from "./B06_Section_UI_Cross_Ford_Pavement";
|
||||
import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { attachShapePick, shapePickRoute } from "./B06_Section_UI_Cross_Shape_Pick";
|
||||
import { stackPickTargets } from "./B06_Section_UI_Cross_Pick_Order";
|
||||
import { appendUnderdrainOverlay } from "./B06_Section_UI_Cross_Underdrain";
|
||||
import { raiseWalls } from "./B06_Section_UI_Cross_Wall";
|
||||
import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
import { attachChuteBandPick, groundLine } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill";
|
||||
import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
|
||||
import {
|
||||
@@ -229,13 +232,14 @@ export function createCrossSectionCard(
|
||||
},
|
||||
selectCard: () => onSelect(section.station_id),
|
||||
});
|
||||
const toggleRevet = (key: RevetPick): void => {
|
||||
const toggleRevet = (key: RevetPick, ownerM?: number): void => {
|
||||
const wasSelected = isSelected;
|
||||
activeRevet = activeRevet === key ? null : key;
|
||||
showRevetControl(activeRevet !== null);
|
||||
// 강조는 **연동된 카드 전부**라 제어기를 거친다(제어기가 목록에 다시 칠하기를
|
||||
// 알린다 — 2026-08-30 사용자). 배선이 없어도 이 카드만은 즉시 맞춘다.
|
||||
revetOffset?.select(section.chainage_m, activeRevet);
|
||||
// `ownerM` = 폼에 올릴 시설 자리(물넘이 콘크리트 = 그 물넘이 · 294-7)
|
||||
revetOffset?.select(section.chainage_m, activeRevet, ownerM);
|
||||
card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? activeRevet);
|
||||
if (activeRevet !== null && activeArea !== null) {
|
||||
activeArea = null;
|
||||
@@ -317,7 +321,11 @@ export function createCrossSectionCard(
|
||||
setChipActive(null);
|
||||
onAreaSelect?.(section.station_id, null);
|
||||
}
|
||||
if (!isSelected) onSelect(section.station_id);
|
||||
if (!isSelected) {
|
||||
// 첫 누름 = 이 선 — 남은 부재 고름(도수로 띠 등)이 카드 고름에 되살아나 선을 풀지 않게(295-4)
|
||||
if (activeRevet === null) revetOffset?.select(section.chainage_m, null);
|
||||
onSelect(section.station_id);
|
||||
}
|
||||
}
|
||||
setLineActive(next);
|
||||
};
|
||||
@@ -445,15 +453,21 @@ export function createCrossSectionCard(
|
||||
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
|
||||
// 물넘이포장은 노면 자체가 파인 자리라 포장층도 그 바닥을 따른다 — 그렸으면 일반
|
||||
// 포장 박스는 건너뛴다(두 겹으로 깔리면 색 구분이 사라진다).
|
||||
const fordFrom = plotLayer.children.length;
|
||||
const fordPaved = appendFordPavementOverlay(
|
||||
plotLayer,
|
||||
section.ford_pavement,
|
||||
section.design,
|
||||
x,
|
||||
toDisplayY,
|
||||
groundLine(section.samples ?? []),
|
||||
);
|
||||
// 월류 폭으로 걸친 옆 카드 = 흙막이 연동 꼴 · 주인 측점 글(294-6)
|
||||
markLinkedFordPavement(plotLayer, fordFrom, section, stationInterval);
|
||||
if (!fordPaved)
|
||||
appendPavementOverlay(plotLayer, section.design, x, toDisplayY, section.pavement);
|
||||
// 맹암거(288-1) — 노면 아래 암거 높이만큼 돌 무늬 형상
|
||||
appendUnderdrainOverlay(plotLayer, section.underdrain, section.design, x, toDisplayY);
|
||||
// 세월교 측점 — 계획고는 이미 월류 높이만큼 내려와 있다. "월류가 없었다면"
|
||||
// 노면을 점선으로 남겨 얼마나 내려앉았는지 읽히게 한다(2026-08-30 사용자).
|
||||
appendFordSurfaceDropPlan(plotLayer, section.design, x, toDisplayY, fordLayout);
|
||||
@@ -513,16 +527,6 @@ export function createCrossSectionCard(
|
||||
[...plotLayer.children]
|
||||
.slice(designFrom)
|
||||
.forEach((el) => el.setAttribute(DESIGN_LINE_ATTR, ""));
|
||||
// 물넘이 자리 = 노면 · 측구 설계선 걷음(66-2 · 66-3 — 옛 계획고 점선이 드러남)
|
||||
if (fordPaved && section.ford_pavement)
|
||||
hideFordPavedRoad(
|
||||
plotLayer,
|
||||
designFrom,
|
||||
section.design,
|
||||
section.ford_pavement,
|
||||
x,
|
||||
toDisplayY,
|
||||
);
|
||||
// 사토장(유용토운반작업장) — 교본 6장 3절이 횡단도 표시를 요구한다. 값은 설계 결과에서
|
||||
// 그대로 오고 여기서 다시 세지 않는다. 선 종류는 터파기 파선과 갈라 둔다.
|
||||
appendSpoilFillOverlay(plotLayer, section.design, x, toDisplayY);
|
||||
@@ -585,6 +589,19 @@ export function createCrossSectionCard(
|
||||
};
|
||||
setChuteActive(highlightRevet === "chute");
|
||||
}
|
||||
// 물넘이포장 · 맹암거 형상 고름(287-5 · 288-2) — 다른 시설과 같은 창구 · 보라 손잡이 아래 · 누름만
|
||||
const setShapeActive = attachShapePick(
|
||||
plotLayer,
|
||||
revetOffset && shapePickRoute(svg, (at) => toggleRevet("pipe", at)),
|
||||
);
|
||||
if (setShapeActive) {
|
||||
const baseSetActive = setRevetActive;
|
||||
setRevetActive = (key) => {
|
||||
baseSetActive(key);
|
||||
setShapeActive(key === "pipe");
|
||||
};
|
||||
setShapeActive(highlightRevet === "pipe");
|
||||
}
|
||||
// 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2).
|
||||
const lineDesign = section.design;
|
||||
linePick = attachDesignLinePick({
|
||||
@@ -598,6 +615,7 @@ export function createCrossSectionCard(
|
||||
});
|
||||
setLineActive = linePick.setActive;
|
||||
setLineActive(pickedLineAt(section.station_id));
|
||||
stackPickTargets(plotLayer); // 고르기 차례 = 좁은 것 위 · 보라 선 맨 위(295-1)
|
||||
}
|
||||
|
||||
// 십자선(계획 노선 자리) · 축선 · 높이 축 이름 — `_Cross_View_Frame`(700줄 제한).
|
||||
|
||||
@@ -349,6 +349,77 @@ export function chuteBandOutline(
|
||||
return { top, normals, outline: [...top, ...bottom.reverse()] };
|
||||
}
|
||||
|
||||
/** 꺾은선 앞에서부터 `lengthM` 까지(쓴 길이와 함께). */
|
||||
function trimPolyline(
|
||||
points: readonly OffsetPoint[],
|
||||
lengthM: number,
|
||||
): { points: OffsetPoint[]; used: number } {
|
||||
const out = points.slice(0, 1);
|
||||
let used = 0;
|
||||
for (let i = 1; i < points.length && lengthM - used > 1e-9; i += 1) {
|
||||
const [a, b] = [points[i - 1], points[i]];
|
||||
const step = Math.hypot(b.offset - a.offset, b.elevation - a.elevation);
|
||||
if (step < 1e-9) continue;
|
||||
const t = Math.min((lengthM - used) / step, 1);
|
||||
out.push({
|
||||
offset: a.offset + (b.offset - a.offset) * t,
|
||||
elevation: a.elevation + (b.elevation - a.elevation) * t,
|
||||
});
|
||||
used += step * t;
|
||||
}
|
||||
return { points: out, used };
|
||||
}
|
||||
|
||||
/** 원지반 샘플 → 꺾은선(유효 점만 · 오프셋 차례). */
|
||||
export function groundLine(
|
||||
samples: ReadonlyArray<{
|
||||
offset_m?: number | null;
|
||||
elevation_m?: number | null;
|
||||
valid?: boolean;
|
||||
}>,
|
||||
): OffsetPoint[] {
|
||||
return samples
|
||||
.filter((s) => s.valid !== false && s.offset_m != null && s.elevation_m != null)
|
||||
.map((s) => ({ offset: s.offset_m as number, elevation: s.elevation_m as number }))
|
||||
.sort((a, b) => a.offset - b.offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 도수로 · 돌붙임 선 길이(294-4) — 선들을 차례로 사면 따라 `lengthM` 까지 자름 · 사면보다 길면 마지막 끝에서
|
||||
* 원지반(`ground`)을 바깥으로 따라 이어 그 길이까지 · 길이 없으면 그대로(원지반 닿으면 끝). 점 하나뿐인 선
|
||||
* (사면 없음 · 끝이 이미 원지반)은 길이가 있으면 원지반 따라 · 없으면 안 그림. 그림 전용(수량 · 내역 무관).
|
||||
*/
|
||||
export function chuteRun<L extends { points: OffsetPoint[] }>(
|
||||
lines: readonly L[],
|
||||
lengthM?: number | null,
|
||||
ground?: readonly OffsetPoint[],
|
||||
): L[] {
|
||||
const drawn = (list: L[]) => list.filter((line) => line.points.length > 1);
|
||||
if (!(lengthM && lengthM > 0) || !lines.length) return drawn([...lines]);
|
||||
let left = lengthM;
|
||||
const out: L[] = [];
|
||||
for (const line of lines) {
|
||||
if (left <= 1e-9) break;
|
||||
const cut = trimPolyline(line.points, left);
|
||||
out.push({ ...line, points: cut.points });
|
||||
left -= cut.used;
|
||||
}
|
||||
const last = out[out.length - 1];
|
||||
if (left > 1e-9 && ground?.length && last) {
|
||||
const first = lines[0].points[0];
|
||||
const end = last.points[last.points.length - 1];
|
||||
const outward = Math.sign(end.offset - first.offset) || Math.sign(end.offset) || 1;
|
||||
const along = [
|
||||
end,
|
||||
...ground
|
||||
.filter((p) => (p.offset - end.offset) * outward > 1e-6)
|
||||
.sort((a, b) => (a.offset - b.offset) * outward),
|
||||
];
|
||||
out.push({ ...last, points: trimPolyline(along, left).points });
|
||||
}
|
||||
return drawn(out);
|
||||
}
|
||||
|
||||
/** 띠가 서는 유출 조각(152-3) — 성토 조각 + 내림 · 수평 절토 조각(3도 절토선 · 끝 ≤ 시작).
|
||||
* 3도 선이 못 만나 복합경사로 오르는 절토 조각은 뺌. B06 관 · 흙막이 띠와 3D 띠가 같은 거름. */
|
||||
export function isChuteSegment(segment: { kind?: string; points: OffsetPoint[] }): boolean {
|
||||
@@ -365,13 +436,16 @@ export function isChuteSegment(segment: { kind?: string; points: OffsetPoint[] }
|
||||
*/
|
||||
export function appendChuteBand(
|
||||
layer: SVGElement,
|
||||
lines: ReadonlyArray<{ points: OffsetPoint[]; kind?: string }>,
|
||||
chute: { structure: string; thickness_m: number },
|
||||
allLines: ReadonlyArray<{ points: OffsetPoint[]; kind?: string }>,
|
||||
chute: { structure: string; thickness_m: number; length_m?: number },
|
||||
x: (offset: number) => number,
|
||||
toDisplayY: (elevation: number) => number,
|
||||
/** 원지반 — 길이가 사면보다 길면 이어 그림(294-4). */
|
||||
ground?: readonly OffsetPoint[],
|
||||
): void {
|
||||
const thickness = chute.thickness_m;
|
||||
if (!(thickness > 0)) return;
|
||||
const lines = chuteRun(allLines, chute.length_m, ground);
|
||||
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
|
||||
const px = (p: OffsetPoint): [number, number] => [x(p.offset), toDisplayY(p.elevation)];
|
||||
for (const line of lines) {
|
||||
@@ -383,7 +457,8 @@ export function appendChuteBand(
|
||||
band.setAttribute("class", "b06-chart__culvert-pitching");
|
||||
const title = document.createElementNS(SVG_NS, "title");
|
||||
const along = line.kind === "cut" ? "3도 절토선" : "성토사면";
|
||||
title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · ${along} 따라 원지반까지`;
|
||||
const reach = chute.length_m ? `길이 ${chute.length_m.toFixed(2)}m` : "원지반까지";
|
||||
title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · ${along} 따라 ${reach}`;
|
||||
band.append(title);
|
||||
clipSeq += 1;
|
||||
const clipId = `b06-chute-clip-${clipSeq}`;
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
readShoulderExtends,
|
||||
} from "./B06_Section_Cross_Design_Session";
|
||||
import { readStructureNotes } from "./B06_Section_UI_Cross_Detail";
|
||||
import { readStructureNoteCads } from "./B06_Section_UI_Cross_Detail_Cad";
|
||||
import { readDetailTables } from "./B06_Section_UI_Cross_Detail_Table";
|
||||
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
|
||||
@@ -413,6 +414,11 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
|
||||
readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => {
|
||||
patchFor(chainage).structure_note = note;
|
||||
});
|
||||
// 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 세션 초안 그대로(빈 엔티티 = 비움)
|
||||
if (ctx.projectId && routeId !== null)
|
||||
readStructureNoteCads(ctx.projectId, routeId).forEach((cad, chainage) => {
|
||||
patchFor(chainage).structure_note_cad = cad;
|
||||
});
|
||||
// [상세] 표 손질(179-4) — 세션 초안 그대로 · 값은 서버가 그릴 때 다시 셈
|
||||
if (ctx.projectId && routeId !== null)
|
||||
readDetailTables(ctx.projectId, routeId).forEach((table, chainage) => {
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
* 횡단 보기 안 누름(카드 · 목록 · 알약 · 형상)은 그 자리에서 그리고 값을 적으므로(from "b06") 되받지 않음.
|
||||
* 풀기(Esc · 카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 · 범위 테두리 ·
|
||||
* 형상까지 모두 끔(카드만 풀고 `--span` 이 남던 구멍 · a4).
|
||||
* 한 벌 배수유역도(관 마커 · 유역 · 측점 마름모)는 낸 쪽과 무관하게 늘 값대로 칠함 — 횡단 보기 안
|
||||
* 누름(카드 · 목록)도 지도에 이어짐(270 · 종단 보기 `paintMapPick` 과 같은 규칙).
|
||||
* ========================================================================== */
|
||||
|
||||
import { subscribeSelection } from "../A00_Common/design/design_selection";
|
||||
import { subscribeSelection, type DesignSelection } from "../A00_Common/design/design_selection";
|
||||
import { bindEscapeRelease } from "../A00_Common/design/design_pick_escape";
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
@@ -27,6 +29,8 @@ export interface CrossPickDeps {
|
||||
};
|
||||
/** 지금 값으로 카드 · 형상 · 폼을 다시 세움(넘김 적용 길). */
|
||||
reapply: () => void;
|
||||
/** 배수유역도를 값으로 칠함(알림 없이 · 270). */
|
||||
paintMap?: (selection: DesignSelection | null) => void;
|
||||
}
|
||||
|
||||
let releasing = false;
|
||||
@@ -65,7 +69,8 @@ function releaseAll(deps: CrossPickDeps): void {
|
||||
}
|
||||
|
||||
/** 횡단 보기를 고름 한 칸에 묶음 — 보일 때 · 다른 곳이 바꾼 값만 받음 · 보기를 떠나면 끊음.
|
||||
* `release` = 강조 모두 끔(값은 부르는 쪽이 비움). */
|
||||
* `release` = 강조 모두 끔(값은 부르는 쪽이 비움). 지도는 보일 때 모든 값을 칠함 — 보기 전환 때는
|
||||
* 종단 보기가 같은 값으로 칠해 둠 · 재계산 뒤는 횡단 보기 종단도 알림(`onPipesChanged`)이 다시 얹음. */
|
||||
export function bindCrossPick(
|
||||
projectId: string | null,
|
||||
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
|
||||
@@ -73,7 +78,9 @@ export function bindCrossPick(
|
||||
): { release: () => void } {
|
||||
host.onDispose(
|
||||
subscribeSelection(projectId, (selection, from) => {
|
||||
if (!host.visible() || from === "b06") return;
|
||||
if (!host.visible()) return;
|
||||
deps.paintMap?.(selection);
|
||||
if (from === "b06") return;
|
||||
if (selection.chainageM === null) releaseCrossHighlights(deps);
|
||||
else deps.reapply();
|
||||
}),
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { mergeFacilityOptions } from "../B05_Profile/B05_Profile_UI_Drainage_Facility_Merge";
|
||||
import { fordWaitsOf, waitsFordHeight } from "../B05_Profile/B05_Profile_UI_Drainage_Ford_Fill";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
linkFields,
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
} from "../A00_Common/design/design_selection";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { snapStructureStation } from "../common_util/common_util_structure_station";
|
||||
import { plantsStation } from "../B05_Profile/B05_Profile_UI_Profile_Structures";
|
||||
import {
|
||||
adjustDockRoot,
|
||||
refreshAdjustSlots,
|
||||
@@ -60,15 +62,14 @@ import { withSpecDefaults } from "./B06_Section_UI_Page_Structures_Spec";
|
||||
|
||||
const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요.";
|
||||
const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다.";
|
||||
/** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 저장 뒤. */
|
||||
/** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 뒤에서 바로(298-1). */
|
||||
const PIPE_ADD_NOTICE =
|
||||
"계곡 통과 시설을 넣었습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
|
||||
const PIPE_REMOVE_NOTICE =
|
||||
"계곡 통과 시설을 뺐습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
|
||||
"계곡 통과 시설을 넣었습니다 — 횡단도는 바로 보이고, 세부 배수유역은 뒤에서 다시 나눕니다.";
|
||||
const PIPE_REMOVE_NOTICE = "계곡 통과 시설을 뺐습니다 — 세부 배수유역은 뒤에서 다시 나눕니다.";
|
||||
/** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */
|
||||
const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다.";
|
||||
const PIPE_MOVE_NOTICE =
|
||||
"기준 측점을 옮겼습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
|
||||
"기준 측점을 옮겼습니다 — 횡단도는 바로 보이고, 세부 배수유역은 뒤에서 다시 나눕니다.";
|
||||
|
||||
export interface B06StructuresPanelDeps {
|
||||
projectId: string | null;
|
||||
@@ -129,6 +130,10 @@ export interface B06StructuresPanel {
|
||||
preferred: () => string | null;
|
||||
/** 구조물 하나를 폼에 올리고 그 카드로 간다 — 알약을 누른 길(목록 클릭과 같은 흐름). */
|
||||
selectStructure: (structureId: string) => void;
|
||||
/** 측점 없는 관(물넘이포장) 하나를 폼에 올림 — 카드 안 고름 · 스크롤만 · 알약 · 고름 값에 알약 id(요구 178). */
|
||||
selectPipeAt: (chainageM: number) => void;
|
||||
/** 배수유역도가 관 초안에 채운 빈 월류 높이(126-1)를 목록 · 폼에 받는다(298-1). */
|
||||
adoptFordHeights: () => void;
|
||||
/** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */
|
||||
getStructures: () => StructureInstance[];
|
||||
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */
|
||||
@@ -220,11 +225,12 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
currentChainageM = null; // 폼 = 이 구조물 — 옛 관을 붙든 채 남지 않게(165-2)
|
||||
// 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다.
|
||||
// 노면공 = 카드 하나 안 고름 · 범위 카드 전부 테두리 · 그 카드로 스크롤만 · 폼 유지(177-4)
|
||||
const pave = !coverable(structure);
|
||||
// 측점 없는 구조물(제 측점을 안 심음)도 같은 길 — 근처 카드 측점선을 켜지 않음(요구 178)
|
||||
const revealOnly = !coverable(structure) || !plantsStation(structure, markTypes);
|
||||
const id = structure.structure_id ?? null;
|
||||
preferredId = id;
|
||||
// 측점 고름(카드 하나 · 다시 세움)이 연 폼 = 누른 카드 그대로 — 구조물 기준 카드로 옮겨 가지 않음(189-1 · 231-1)
|
||||
if (!openingAtStation) deps.focusChainage(focusOf(structure), pave);
|
||||
if (!openingAtStation) deps.focusChainage(focusOf(structure), revealOnly);
|
||||
preferredId = null;
|
||||
// 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2)
|
||||
deps.markStructure?.(id, !openingAtStation); // 측점 고름이 연 것 = 카드 하나 · 범위 테두리 없음(231-1)
|
||||
@@ -255,6 +261,9 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
});
|
||||
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
|
||||
currentChainageM = at;
|
||||
// 빈 월류 높이 = 다시 나눈 유량으로 채움(126-1) — 배수유역도 판이 이 표를 보고 채움(298-1)
|
||||
if (waitsFordHeight(attributes.facility, attributes.options))
|
||||
fordWaitsOf(deps.projectId).add(at);
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
pushMarks();
|
||||
@@ -262,7 +271,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
showToast(PIPE_ADD_NOTICE, "success");
|
||||
},
|
||||
// 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다
|
||||
// (2026-09-12 일원화). 재분할은 [저장]·[확정] 뒤 서버 몫이다.
|
||||
// (2026-09-12 일원화). 세부유역은 관 초안을 쓰면 배수유역도가 뒤에서 다시 나눈다(298-1).
|
||||
onPipeUpdate: (fromChainageM, toChainageM, attributes) => {
|
||||
// 값만 고치면 폼이 옛 측점을 그대로 돌려줌(안 고친 칸) — 0.005m 넘으면 옮긴 것(80.17 → 80 도 · 234-1)
|
||||
if (Math.abs(fromChainageM - toChainageM) > 0.005) movePipeTo(fromChainageM, toChainageM);
|
||||
@@ -493,6 +502,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
pushMarks();
|
||||
deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로(273-2)
|
||||
showToast(PIPE_MOVE_NOTICE, "success");
|
||||
}
|
||||
|
||||
@@ -543,6 +553,23 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
section.selectPipeByChainage(hit.chainage_m);
|
||||
},
|
||||
getStructures: () => structures,
|
||||
adoptFordHeights: () => {
|
||||
// 배수유역도가 초안에 채운 월류 높이(126-1)를 목록 · 폼에 받음 — 빈 칸만(298-1)
|
||||
const draft = deps.projectId ? readPendingPipes(deps.projectId) : null;
|
||||
const filled = pipeFacilities.filter((entry) => {
|
||||
if (entry.options?.ford_height_m !== undefined) return false;
|
||||
const height = draft?.find(
|
||||
(pipe) => Math.abs(pipe.chainage_m - entry.chainage_m) < PIPE_MATCH_M,
|
||||
)?.options?.ford_height_m;
|
||||
if (height === undefined) return false;
|
||||
entry.options = { ...entry.options, ford_height_m: height };
|
||||
return true;
|
||||
});
|
||||
if (!filled.length) return;
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
const open = filled.find((entry) => entry.chainage_m === currentChainageM);
|
||||
if (open) section.selectPipeByChainage(open.chainage_m);
|
||||
},
|
||||
releasePipe: () => {
|
||||
currentChainageM = null;
|
||||
section.selectPipeByChainage(null);
|
||||
@@ -583,5 +610,22 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
}
|
||||
return { chainageM, structureId: hit.structureId };
|
||||
},
|
||||
selectPipeAt: (chainageM) => {
|
||||
const pipe = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (!pipe) return;
|
||||
const id = `pipe-${pipe.chainage_m.toFixed(2)}`;
|
||||
deps.focusChainage(pipe.chainage_m, true); // 카드 안 고름 · 스크롤만 — 옛 카드 · 측점선 풂(177-4 길)
|
||||
currentChainageM = pipe.chainage_m;
|
||||
refreshSpecDefaults(pipe.chainage_m);
|
||||
section.selectPipeByChainage(pipe.chainage_m);
|
||||
deps.markStructure?.(id); // 알약은 폼 뒤(165-2)
|
||||
selectDesign(
|
||||
deps.projectId,
|
||||
{ chainageM: pipe.chainage_m, structureId: id },
|
||||
{ from: "b06" },
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { refreshAdjustSlots } from "./B06_Section_UI_Adjust_Dock";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
import type { RevetPick } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type { B06StructuresPanel } from "./B06_Section_UI_Page_Structures_Panel";
|
||||
@@ -21,15 +21,19 @@ import { BERM_TYPE_ID } from "./B06_Section_UI_Page_Persist";
|
||||
* 이 값으로 "카드만 고른 경우"를 가려낸다(2026-08-29 사용자: 측점 선택 때 구조물이
|
||||
* 자동으로 골라지면 안 된다). 같은 턴에서만 참이다. */
|
||||
let wallSelecting = false;
|
||||
/** 이번 턴에 찍은 부재가 폼에 올린 시설 자리 — 안 골라진 카드 첫 누름에 카드 고름이 연동 관으로 다시 옮기지 않게(294-8). */
|
||||
let wallOwner: number | null = null;
|
||||
|
||||
export function isWallSelecting(): boolean {
|
||||
return wallSelecting;
|
||||
}
|
||||
|
||||
function markWallSelecting(): void {
|
||||
function markWallSelecting(owner: number | null = null): void {
|
||||
wallSelecting = true;
|
||||
wallOwner = owner;
|
||||
queueMicrotask(() => {
|
||||
wallSelecting = false;
|
||||
wallOwner = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,12 +48,21 @@ export function wireStructureSelection(
|
||||
refreshCard?: (chainageM: number) => void,
|
||||
/** 폼에 올린 시설로 보기를 옮김 — 목록에서 고른 길(54-47). */
|
||||
reveal?: (chainageM: number) => void,
|
||||
): { syncInletStructure: () => void } {
|
||||
const ownerChainageOf = (chainageM: number): number => {
|
||||
): { syncInletStructure: () => void; formChainageOf: (target: CrossSection) => number } {
|
||||
const ownerChainageOf = (chainageM: number, key?: RevetPick): number => {
|
||||
const sectionAt = detail()?.cross_sections.find(
|
||||
(entry) => Math.abs(entry.chainage_m - chainageM) < 0.01,
|
||||
);
|
||||
return (sectionAt && stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m) ?? chainageM;
|
||||
// 자기 관 없는 카드의 「관」 = 물넘이 콘크리트 — 걸친 연동 관보다 앞(294-7 · 되살림 길도)
|
||||
const fordM = sectionAt?.ford_pavement?.chainage_m;
|
||||
if (key === "pipe" && fordM !== undefined && !sectionAt?.culvert) return fordM;
|
||||
// 물넘이포장 범위 옆 카드 = 그 물넘이 자리(287-5 · 형상 누름이 그 시설을 엶)
|
||||
return (
|
||||
(sectionAt &&
|
||||
(stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m ??
|
||||
sectionAt.ford_pavement?.chainage_m)) ??
|
||||
chainageM
|
||||
);
|
||||
};
|
||||
|
||||
// 유입구 "구조" ↔ B06 유입측 형식 — 폼에서 고르면 조정값에 반영하고, 시설을 폼에
|
||||
@@ -69,14 +82,15 @@ export function wireStructureSelection(
|
||||
};
|
||||
|
||||
const origRevet = stationControls.revetOffset.select;
|
||||
stationControls.revetOffset.select = (chainageM, key) => {
|
||||
stationControls.revetOffset.select = (chainageM, key, ownerM) => {
|
||||
// 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1)
|
||||
const nested = wallSelecting;
|
||||
// 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1)
|
||||
if (key) markWallSelecting();
|
||||
origRevet(chainageM, key);
|
||||
// 도수로 띠는 자기 측점 시설 — 이웃 관 연장 안이어도 그 관 폼을 안 엶(152-4)
|
||||
const owner = key === "chute" ? chainageM : ownerChainageOf(chainageM);
|
||||
const owner =
|
||||
ownerM ?? (key === "chute" ? chainageM : ownerChainageOf(chainageM, key ?? undefined));
|
||||
// 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1)
|
||||
if (key) markWallSelecting(owner);
|
||||
origRevet(chainageM, key);
|
||||
if (key) remember?.(owner, key);
|
||||
if (key) panel.showAtChainage(owner);
|
||||
else if (!nested) {
|
||||
@@ -153,6 +167,18 @@ export function wireStructureSelection(
|
||||
if (!pointing) pressAt(event.target as HTMLElement);
|
||||
});
|
||||
panel.root.addEventListener("change", ({ target: el }) => pickAt(el as HTMLElement));
|
||||
// 시설을 폼에 여는 동안의 그림(종류 칸 → 값 채움 두 번)은 새 상자가 아님 — 마지막 상자(도수로)를
|
||||
// 고르면 누른 선 · 형상을 덮음(294-8 · 295-4)
|
||||
let opening = false;
|
||||
const origShow = panel.showAtChainage;
|
||||
panel.showAtChainage = (chainageM) => {
|
||||
opening = true;
|
||||
try {
|
||||
return origShow(chainageM);
|
||||
} finally {
|
||||
opening = false;
|
||||
}
|
||||
};
|
||||
// 상자가 새로 서면(단 늘림 · [추가]) 그 상자를 고름 — 같은 시설을 다시 그린 때만
|
||||
let painted: { chainageM: number | null; keys: Set<string> } | null = null;
|
||||
panel.root.addEventListener("specpaint", () => {
|
||||
@@ -161,7 +187,7 @@ export function wireStructureSelection(
|
||||
const before = painted?.chainageM === chainageM ? painted.keys : null;
|
||||
painted = { chainageM, keys: new Set(boxes.map(fieldOf)) };
|
||||
const born = before ? boxes.filter((box) => !before.has(fieldOf(box))).at(-1) : undefined;
|
||||
if (!born || chainageM === null) return;
|
||||
if (!born || chainageM === null || opening) return;
|
||||
revealAt(chainageM, null);
|
||||
pickBox(born);
|
||||
});
|
||||
@@ -217,5 +243,11 @@ export function wireStructureSelection(
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다.
|
||||
refreshAdjustSlots();
|
||||
};
|
||||
return { syncInletStructure };
|
||||
/** 카드 고름이 폼에 올릴 자리 — 찍은 부재가 연 시설 먼저(걸친 연동 관 아님 · 294-8) · 그다음 소유 측점(79-6) ·
|
||||
* 카드만 고르면 그 측점. */
|
||||
const formChainageOf = (target: CrossSection): number =>
|
||||
(wallSelecting
|
||||
? (wallOwner ?? stationControls.structureSpan.ownerOf(target)?.chainage_m)
|
||||
: undefined) ?? target.chainage_m;
|
||||
return { syncInletStructure, formChainageOf };
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user