Merge remote-tracking branch 'origin/안티그래비티' into 안티그래비티
This commit is contained in:
@@ -328,13 +328,15 @@ export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심
|
||||
* 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 ETag와 함께 내보낸다.
|
||||
* 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다.
|
||||
* `scope` = 그 화면이 보는 범위만 자른 사본(route = 노선 띠 · drainage = 유역 + 여유 · 509-1 · 509-6)
|
||||
* — 잘렸으면 `clip_lonlat`([서 · 남 · 동 · 북]) 이 붙는다. */
|
||||
* — 잘렸으면 `clip_lonlat`([서 · 남 · 동 · 북]) 이 붙는다 · `bbox` = route 의 노선 대신 편집 중 노선
|
||||
* bbox(사업지 m `x0,y0,x1,y1` · 508-2). */
|
||||
export async function fetchCachedSheetLayer<T>(
|
||||
projectId: string,
|
||||
layer: string,
|
||||
scope?: SheetScope,
|
||||
bbox?: string,
|
||||
): Promise<T> {
|
||||
const range = scope ? `&scope=${scope}` : "";
|
||||
const range = (scope ? `&scope=${scope}` : "") + (bbox ? `&bbox=${bbox}` : "");
|
||||
return fetchCachedJson<T>(
|
||||
projectId,
|
||||
`${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}${range}`,
|
||||
|
||||
@@ -106,8 +106,6 @@ export const STATE_REGISTRY = {
|
||||
"input-prefs": { bucket: "pref", scope: "global" },
|
||||
/** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */
|
||||
"b05-3d-projection": { bucket: "pref", scope: "global" },
|
||||
/** 배수유역 계산 `manual · auto`(347-2) · 없으면 수동. */
|
||||
"drainage-mode": { bucket: "pref", scope: "global" },
|
||||
"section-panel-height": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/* =============================================================================
|
||||
* design_calc_profile.ts
|
||||
* 계산 자리 「서버」 종단 길(PLAN 511-B B-3) — 판(`design_calc_sites.ts`)이 profile 묶음을 server 로 둔 때
|
||||
* 묶음 planline 의 새 지반 첫 계획선 꼭짓점(P1 · `profile.base`)과 profile 단계 계획선 짓기(P2 ~ P4 · P8 ·
|
||||
* `profile.alignment`)를 서버 Node 가 같은 TS 로 셈(창구 `POST /projects/{id}/calc`). both 면 브라우저 그대로.
|
||||
* 서버 실패(끊김 밖) = 경고 한 줄 · 브라우저 값.
|
||||
* ========================================================================== */
|
||||
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import { serverCalc } from "./design_calc_server";
|
||||
import type { StageFn } from "./logic/design_pipeline_types";
|
||||
import {
|
||||
groundFollowingBasePvi,
|
||||
groundProfile,
|
||||
type GroundFollowingInput,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Grade_Ground";
|
||||
import { profileStage } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||
import {
|
||||
emptyEdits,
|
||||
type AlignmentNode,
|
||||
type ProfileAlignment,
|
||||
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
const KEY = "profile";
|
||||
const r6 = (value: number): number => Number(value.toFixed(6));
|
||||
|
||||
/** 서버 값 — 끊김은 그대로 던짐(묶음이 버림) · 그 밖 실패는 브라우저 값. */
|
||||
async function orLocal<T>(signal: AbortSignal, remote: () => Promise<T>, local: () => T) {
|
||||
try {
|
||||
return await remote();
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error;
|
||||
console.warn("[calc-server] profile 실패 — 브라우저 값", error);
|
||||
return local();
|
||||
}
|
||||
}
|
||||
|
||||
/** planline `groundPvi` — 서버면 첫 계획선 꼭짓점 약속(0.000001 m · `groundFollowingBasePvi` 꼴) · 아니면 null
|
||||
* (브라우저 셈 · 단계 동기). 판은 앞 ground 단계가 이미 받음(`calcOnServerNow`). */
|
||||
export function groundPviAt(
|
||||
input: GroundFollowingInput,
|
||||
signal: AbortSignal,
|
||||
): Promise<AlignmentNode[] | null> | null {
|
||||
if (!calcOnServerNow(KEY)) return null;
|
||||
const local = () => groundFollowingBasePvi(input);
|
||||
const profile = groundProfile(input.samples);
|
||||
if (!profile) return null;
|
||||
return orLocal(
|
||||
signal,
|
||||
async () => {
|
||||
const follow = {
|
||||
stations: input.stations,
|
||||
start_offset_m: input.startOffsetM ?? 0,
|
||||
end_offset_m: input.endOffsetM ?? 0,
|
||||
};
|
||||
const inputs = { profile: { ...profile, policy: input.policy, ground_following: follow } };
|
||||
const results = await serverCalc(KEY, ["profile.base"], inputs, signal);
|
||||
const nodes = results["profile.base"] as AlignmentNode[] | null;
|
||||
return (
|
||||
nodes?.map((node) => ({
|
||||
chainage_m: r6(node.chainage_m),
|
||||
elevation_m: r6(node.elevation_m),
|
||||
})) ?? null
|
||||
);
|
||||
},
|
||||
local,
|
||||
);
|
||||
}
|
||||
|
||||
/** profile 단계 — 첫 계획선 + 계획고 편집 → 종단(서버면 `profile.alignment` · 아니면 동기 그대로). */
|
||||
export const profileAt: StageFn<"profile"> = (ctx) => {
|
||||
const base = ctx.out.planline;
|
||||
if (!base || !calcOnServerNow(KEY)) return profileStage(ctx);
|
||||
return orLocal(
|
||||
ctx.signal,
|
||||
async () => {
|
||||
const profile = {
|
||||
chainage: base.chainage,
|
||||
ground: base.ground,
|
||||
policy: base.policy,
|
||||
stations: base.stations,
|
||||
base_pvi: base.basePvi,
|
||||
edits: ctx.inputs.alignment ?? emptyEdits(),
|
||||
only_explicit_curves: base.onlyExplicitCurves,
|
||||
};
|
||||
const results = await serverCalc(KEY, ["profile.alignment"], { profile }, ctx.signal);
|
||||
return results["profile.alignment"] as ProfileAlignment;
|
||||
},
|
||||
() => profileStage(ctx) as ProfileAlignment,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
/* =============================================================================
|
||||
* design_calc_server.ts
|
||||
* 계산 자리 「서버」 길(PLAN 511-A) — 판(`design_calc_sites.ts`)이 묶음을 server 로 둔 때 조작 중 계산을 서버
|
||||
* 창구(`POST /projects/{id}/calc` · `B06_Section_Router_Calc.py`)가 같은 TS 로 셈. 판이 both 면 지금 그대로.
|
||||
* serverCalc 한 번 묻기(signal 로 끊음 · 왕복 흉내 `delayMs` · 기록)
|
||||
* afterQuiet 잠잠 뒤 — 같은 칸 새 부름이 앞 것을 끊음(앞 것 = undefined · 늦은 답 버림)
|
||||
* calcChecked 동기 자리 — 브라우저 값 바로 · server 면 잠잠 뒤 같은 입력 서버 셈 → 대조 · 다르면 apply
|
||||
* calcOnServerOnly 비동기 자리 — server 면 서버 값만(잠잠 뒤 · 끊긴 부름 = undefined)
|
||||
* calcFromServer 그리기 자리 — server 면 서버 값만(입력 같은 답 · 없으면 앞 답) · 답 오면 redraw
|
||||
* 기록 = `window.__aisloCalcServer`(calls · local = 브라우저 ms · delayMs) — 시간 표 · 화면 검증용.
|
||||
* 조작 중 왕복 = 지침 5장 「네트워크 왕복 금지」의 시험용 예외(판 기본값 both).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { calcOnServerNow, calcProjectId } from "./design_calc_sites";
|
||||
import { plain } from "./logic/design_calc_node_trial";
|
||||
|
||||
/** 편집이 멈춘 것으로 볼 시간(ms) — 배분 선반입과 같은 값(`B05_Profile_Api_HaulPlan.ts`). */
|
||||
export const QUIET_MS = 400;
|
||||
const LOG_SIZE = 200;
|
||||
const LOCAL_SIZE = 20;
|
||||
|
||||
export interface CalcServerCall {
|
||||
key: string;
|
||||
id: string;
|
||||
/** 브라우저 셈 ms(같이 셈한 자리만). */
|
||||
browserMs: number | null;
|
||||
/** 보내기 → 답(흉내 지연 포함). */
|
||||
roundMs: number;
|
||||
/** Node 셈 · 파이썬 ↔ Node(창구 답). */
|
||||
nodeMs: number | null;
|
||||
wallMs: number | null;
|
||||
bytes: number;
|
||||
/** 브라우저 값 = 서버 값(대조 자리만 · 아니면 null). */
|
||||
same: boolean | null;
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
interface CalcServerLog {
|
||||
calls: CalcServerCall[];
|
||||
local: Record<string, number[]>;
|
||||
delayMs: number;
|
||||
}
|
||||
|
||||
const log = (): CalcServerLog =>
|
||||
((globalThis as { __aisloCalcServer?: CalcServerLog }).__aisloCalcServer ??= {
|
||||
calls: [],
|
||||
local: {},
|
||||
delayMs: 0,
|
||||
});
|
||||
|
||||
const now = (): number => performance.now();
|
||||
const r2 = (value: number): number => Math.round(value * 100) / 100;
|
||||
|
||||
function wait(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(new DOMException("끊김", "AbortError"));
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("끊김", "AbortError"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function record(call: CalcServerCall): void {
|
||||
const calls = log().calls;
|
||||
calls.push(call);
|
||||
if (calls.length > LOG_SIZE) calls.splice(0, calls.length - LOG_SIZE);
|
||||
}
|
||||
|
||||
/** 서버 계산 한 번 — `{id: 값}` · 실패 = throw · `body` = 이미 굳힌 본문(입력이 뒤에 바뀌어도 그때 값). */
|
||||
export async function serverCalc(
|
||||
key: string,
|
||||
run: string[],
|
||||
inputs: Record<string, unknown> | string,
|
||||
signal?: AbortSignal,
|
||||
extra: Partial<CalcServerCall> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body = typeof inputs === "string" ? inputs : JSON.stringify({ run, inputs });
|
||||
const t0 = now();
|
||||
if (log().delayMs > 0) await wait(log().delayMs, signal);
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${calcProjectId()}/calc`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
signal,
|
||||
});
|
||||
const data = (await response.json()) as {
|
||||
results?: Record<string, unknown>;
|
||||
ms?: number;
|
||||
wall_ms?: number;
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok || !data.results) throw new Error(data.message ?? `HTTP ${response.status}`);
|
||||
// extra 그 객체로 기록 — 대조 뒤 같음 · apply 를 부른 쪽이 그 자리에서 채움
|
||||
record(
|
||||
Object.assign(extra, {
|
||||
key,
|
||||
id: run.join("+"),
|
||||
browserMs: null,
|
||||
roundMs: r2(now() - t0),
|
||||
nodeMs: data.ms ?? null,
|
||||
wallMs: data.wall_ms ?? null,
|
||||
bytes: body.length,
|
||||
same: null,
|
||||
applied: true,
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
|
||||
const quiet = new Map<string, AbortController>();
|
||||
|
||||
/** 잠잠 뒤 task — 같은 칸 새 부름 · outer 끊김 = 앞 것 끊음(undefined) · 실패도 undefined(경고 한 줄). */
|
||||
export async function afterQuiet<T>(
|
||||
slot: string,
|
||||
task: (signal: AbortSignal) => Promise<T>,
|
||||
outer?: AbortSignal,
|
||||
): Promise<T | undefined> {
|
||||
quiet.get(slot)?.abort();
|
||||
const controller = new AbortController();
|
||||
quiet.set(slot, controller);
|
||||
outer?.addEventListener("abort", () => controller.abort());
|
||||
try {
|
||||
await wait(QUIET_MS, controller.signal);
|
||||
return await task(controller.signal);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) console.warn(`[calc-server] ${slot} 실패`, error);
|
||||
return undefined;
|
||||
} finally {
|
||||
if (quiet.get(slot) === controller) quiet.delete(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/** 서버 묻는 꼴 — 없으면 시험 길(`inputs.args[id]` = 함수 인자 · 답 = `results[id]`). */
|
||||
export interface CalcRequest {
|
||||
run: string[];
|
||||
inputs: Record<string, unknown>;
|
||||
pick: (results: Record<string, unknown>) => unknown;
|
||||
}
|
||||
|
||||
/** F7 구조물 면적 · C4 소단 구간 — 등록부 `node:` 입력 그대로. */
|
||||
export const areasRequest = (rows: unknown[]): CalcRequest => ({
|
||||
run: ["structure.layouts"],
|
||||
inputs: { detail: { cross_sections: rows } },
|
||||
pick: (results) => (results["structure.layouts"] as { areas: unknown }).areas,
|
||||
});
|
||||
export const bermRequest = (structures: unknown[]): CalcRequest => ({
|
||||
run: ["cross.berm_spans"],
|
||||
inputs: { berm: { structures, chainages: [] } },
|
||||
pick: (results) => (results["cross.berm_spans"] as { spans: unknown }).spans,
|
||||
});
|
||||
|
||||
export const trialRequest = (id: string, args: unknown[]): CalcRequest => ({
|
||||
run: [id],
|
||||
inputs: { args: { [id]: args } },
|
||||
pick: (results) => results[id],
|
||||
});
|
||||
|
||||
/** 브라우저 셈 + 시간 기록(판 상관없이) · server 면 잠잠 뒤 서버 대조 · 다르면 apply(서버 값). */
|
||||
export function calcChecked<A extends unknown[], R>(
|
||||
key: string,
|
||||
id: string,
|
||||
fn: (...args: A) => R,
|
||||
args: A,
|
||||
options: { apply?: (value: R) => void; request?: CalcRequest } = {},
|
||||
): R {
|
||||
const t0 = now();
|
||||
const value = fn(...args);
|
||||
const browserMs = r2(now() - t0);
|
||||
const local = (log().local[id] ??= []);
|
||||
local.push(browserMs);
|
||||
if (local.length > LOCAL_SIZE) local.shift();
|
||||
if (!calcOnServerNow(key)) return value;
|
||||
const request = options.request ?? trialRequest(id, args);
|
||||
const body = JSON.stringify({ run: request.run, inputs: request.inputs });
|
||||
const mine = JSON.stringify(plain(value));
|
||||
const extra: Partial<CalcServerCall> = { browserMs, same: null, applied: false };
|
||||
void afterQuiet(id, (signal) => serverCalc(key, request.run, body, signal, extra)).then(
|
||||
(results) => {
|
||||
if (!results) return;
|
||||
const theirs = request.pick(results);
|
||||
extra.same = JSON.stringify(theirs) === mine;
|
||||
if (!extra.same && options.apply) {
|
||||
options.apply(theirs as R);
|
||||
extra.applied = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 서버 값만(비동기 자리) — 잠잠 뒤 묻고 고른 값 · 새 부름 · outer 끊김 · 실패 = undefined. */
|
||||
export async function calcOnServerOnly<R>(
|
||||
key: string,
|
||||
slot: string,
|
||||
request: CalcRequest,
|
||||
outer?: AbortSignal,
|
||||
): Promise<R | undefined> {
|
||||
const body = JSON.stringify({ run: request.run, inputs: request.inputs });
|
||||
const results = await afterQuiet(
|
||||
slot,
|
||||
(signal) => serverCalc(key, request.run, body, signal),
|
||||
outer,
|
||||
);
|
||||
return results ? (request.pick(results) as R) : undefined;
|
||||
}
|
||||
|
||||
const held = new Map<string, { body: string; value: unknown }>();
|
||||
const asked = new Map<string, string>();
|
||||
|
||||
/** 그리기 자리 서버 값 — 같은 입력 답이면 fresh · 아니면 앞 답(없으면 undefined) + 잠잠 뒤 묻고 redraw. */
|
||||
export function calcFromServer<R>(
|
||||
key: string,
|
||||
id: string,
|
||||
args: unknown[],
|
||||
redraw: () => void,
|
||||
): { value: R | undefined; fresh: boolean } {
|
||||
const request = trialRequest(id, args);
|
||||
const body = JSON.stringify({ run: request.run, inputs: request.inputs });
|
||||
const hold = held.get(id);
|
||||
if (hold?.body === body) return { value: hold.value as R, fresh: true };
|
||||
if (asked.get(id) !== body) {
|
||||
asked.set(id, body);
|
||||
void afterQuiet(id, (signal) => serverCalc(key, [id], body, signal)).then((results) => {
|
||||
if (asked.get(id) === body) asked.delete(id);
|
||||
if (!results) return;
|
||||
held.set(id, { body, value: results[id] });
|
||||
redraw();
|
||||
});
|
||||
}
|
||||
return { value: hold?.value as R | undefined, fresh: false };
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
* 「계산 자리」 창(`B01_Dashboard_UI_CalcSites.ts`). 열린 페이지는 다음 열기부터 · 못 받으면 전부 both.
|
||||
* ground_sample(G1) server = ground 단계가 격자 대신 서버 표본(`B05_Profile_Api_TerrainGrid.ts`)
|
||||
* corridor_mesh(D3) server = 3D 미리보기 워커 안 띄움 · 서버 모형만(`B05_Profile_UI_Corridor_Worker_Client.ts`)
|
||||
* quantity · carry · fixed_browser server = 조작 중 서버 창구(511-A · `design_calc_server.ts`)
|
||||
* haul(E2) 기본 server = /haul-plan · both = 브라우저가 배분 모듈을 그때 불러 셈(511-C · `B05_Profile_Api_HaulPlan.ts`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -17,6 +19,8 @@ export interface CalcSiteGroup {
|
||||
switchable: boolean;
|
||||
fixed: "both" | "server" | "browser" | null;
|
||||
why: string | null;
|
||||
/** 줄마다 까닭 열쇠(번호 → 열쇠) — 없으면 `why`. */
|
||||
whys?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CalcSitesBoard {
|
||||
@@ -43,11 +47,15 @@ export const saveCalcSites = (sites: Record<string, CalcSiteValue>): Promise<Cal
|
||||
request({ method: "PUT", body: JSON.stringify({ sites }) });
|
||||
|
||||
let opened: Promise<Record<string, CalcSiteValue>> | null = null;
|
||||
let loaded: Record<string, CalcSiteValue> = {};
|
||||
let project = "";
|
||||
|
||||
/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림). */
|
||||
export function openCalcSites(): Promise<Record<string, CalcSiteValue>> {
|
||||
/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림) · 서버 창구가 쓸 프로젝트. */
|
||||
export function openCalcSites(projectId = project): Promise<Record<string, CalcSiteValue>> {
|
||||
project = projectId;
|
||||
loaded = {};
|
||||
opened = fetchCalcSites().then(
|
||||
(board) => board.sites,
|
||||
(board) => (loaded = board.sites),
|
||||
() => ({}),
|
||||
);
|
||||
return opened;
|
||||
@@ -57,3 +65,13 @@ export function openCalcSites(): Promise<Record<string, CalcSiteValue>> {
|
||||
export async function calcOnServer(key: string): Promise<boolean> {
|
||||
return (await (opened ?? openCalcSites()))[key] === "server";
|
||||
}
|
||||
|
||||
/** 판에 both 일 때만 브라우저 셈 — 못 받음 · 받기 전 = 아님(기본 server 인 haul 줄). */
|
||||
export async function calcInBrowser(key: string): Promise<boolean> {
|
||||
return (await (opened ?? openCalcSites()))[key] === "both";
|
||||
}
|
||||
|
||||
/** 동기 자리용 — 받기 전 = both. */
|
||||
export const calcOnServerNow = (key: string): boolean => loaded[key] === "server";
|
||||
|
||||
export const calcProjectId = (): string => project;
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
type BermSpan,
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Persist";
|
||||
import { pipeIdAt, placePipeIds, prunePipeIds, withPipeIds } from "./design_pipe_ids";
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import { bermRequest, calcChecked, calcOnServerOnly } from "./design_calc_server";
|
||||
import { plannedIrregular } from "./design_structure_status";
|
||||
import { planStations } from "../../common_util/common_util_structure_station";
|
||||
import { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_Follow";
|
||||
@@ -155,8 +157,21 @@ export function createFacilityLane(
|
||||
function syncBerm(): void {
|
||||
const routeId = pageRouteId(projectId);
|
||||
if (!structuresKnown || routeId === null) return;
|
||||
// 계산 자리 quantity = server 면 잠잠 뒤 서버 값만(511-A) · 아니면 브라우저 셈
|
||||
if (calcOnServerNow("quantity")) {
|
||||
void calcOnServerOnly<BermSpan[]>("quantity", "C4.copy", bermRequest(structures)).then(
|
||||
(next) => next && putBerm(routeId, next),
|
||||
);
|
||||
return;
|
||||
}
|
||||
putBerm(
|
||||
routeId,
|
||||
calcChecked("quantity", "cross.berm_spans", bermSpansFromStructures, [structures]),
|
||||
);
|
||||
}
|
||||
|
||||
function putBerm(routeId: number, next: BermSpan[]): void {
|
||||
const copy = readState<BermSpan[]>("berm", projectId, routeId);
|
||||
const next = bermSpansFromStructures(structures);
|
||||
if (Array.isArray(copy) && JSON.stringify(copy) === JSON.stringify(next)) return;
|
||||
writeBermSpans(projectId, routeId, next);
|
||||
void Promise.all([import("./design_pipeline_cross"), import("./design_pipeline_page")])
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_follow_server.ts
|
||||
* 계산 자리 follow 「서버」(PLAN 511-B B-5) — 끌기 도구 꼴: 조작 중 = 화면 셈(미리보기) · 잠잠 뒤 서버 Node
|
||||
* route · follow 두 단계(`stage.follow` · until follow · 저장 정본 맥락은 창구가 프로젝트 id 로 붙임)를 묻고
|
||||
* 답이 오면 묶음을 노선부터 다시 돌림 → 같은 노선 초안이면 서버 값(곁 자료 다시 붙임)으로 바꿈.
|
||||
* 판이 both 면 null(화면 셈 그대로). 열쇠 = 노선 초안 + 저장 노선 계획고 편집(follow 는 노선에만 기댐).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState } from "../b_page_state";
|
||||
import {
|
||||
withFollowSides,
|
||||
type FollowSides,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import { afterQuiet, serverCalc } from "./design_calc_server";
|
||||
import type { FollowPatch, PipelineInputs } from "./logic/design_pipeline_types";
|
||||
|
||||
const ID = "stage.follow";
|
||||
let held: { key: string; patch: FollowPatch } | null = null;
|
||||
let asked: string | null = null;
|
||||
|
||||
/** 서버 follow 값 — 같은 열쇠 답이 있으면 그 값 · 없으면 null(화면 셈) + 잠잠 뒤 묻고 `redraw`. */
|
||||
export function followFromServer(
|
||||
projectId: string,
|
||||
inputs: Readonly<PipelineInputs>,
|
||||
before: unknown,
|
||||
savedEdits: unknown,
|
||||
redraw: () => void,
|
||||
): FollowPatch | null {
|
||||
if (!calcOnServerNow("follow") || !inputs.route) return null;
|
||||
const key = JSON.stringify([inputs.route, savedEdits]);
|
||||
if (held?.key === key) return held.patch;
|
||||
if (asked === key) return null;
|
||||
asked = key;
|
||||
const { route, alignment, pipes, structures, plusStations } = inputs;
|
||||
const stages = {
|
||||
until: "follow",
|
||||
input: { route, alignment, pipes, structures, plusStations },
|
||||
before_draft: before,
|
||||
pipe_ids: readState("pipe-ids", projectId),
|
||||
saved_edits: savedEdits,
|
||||
};
|
||||
const body = JSON.stringify({ run: [ID], inputs: { stages } });
|
||||
void afterQuiet(ID, (signal) => serverCalc("follow", [ID], body, signal)).then((results) => {
|
||||
if (asked === key) asked = null;
|
||||
const got = results?.[ID] as ({ patch: FollowPatch } & FollowSides) | undefined;
|
||||
if (!got) return;
|
||||
held = { key, patch: withFollowSides(got.patch, got) };
|
||||
redraw();
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
|
||||
root.replaceChildren(page);
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로
|
||||
void openCalcSites(); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터
|
||||
void openCalcSites(projectId); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터
|
||||
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
|
||||
// 관 길이 · 흙막이 개수 = 공용 캐시(페이지 노선) 한 벌 — 마지막 연 보기의 사본이 아님(416 · B05 = B06) ·
|
||||
// 노선 초안 동안 = 계산 줄 새 노선 상세(다시 센 횡단 · 428)
|
||||
|
||||
@@ -69,17 +69,9 @@ const notify = (projectId: string): void => {
|
||||
busyListeners.get(projectId)?.forEach((fn) => fn(now));
|
||||
};
|
||||
|
||||
const launchedBy = new Map<string, Event>();
|
||||
|
||||
/** 이 누름이 묶음 차례를 띄웠나 — 그 누름 안 폼 다시 실음은 좌측 패널 안 폄(`design_user_pick` · 398-1 · 505-2). */
|
||||
export const pageLaunchedBy = (projectId: string, event: Event): boolean =>
|
||||
launchedBy.get(projectId) === event;
|
||||
|
||||
/** 묶음 차례 하나가 뜸 — 띄운 누름을 적음 · 끝나면(밀림 · 실패 포함) 내림. */
|
||||
/** 묶음 차례 하나가 뜸 — 끝나면(밀림 · 실패 포함) 내림. */
|
||||
export function trackPageBusy(projectId: string, run: Promise<unknown> | null): void {
|
||||
if (!run) return;
|
||||
const event = (globalThis as { event?: Event }).event;
|
||||
if (event) launchedBy.set(projectId, event);
|
||||
busy.set(projectId, (busy.get(projectId) ?? 0) + 1);
|
||||
notify(projectId);
|
||||
const done = (): void => {
|
||||
|
||||
@@ -129,3 +129,27 @@ export function followMovedPipe(projectId: string | null, fromM: number, toM: nu
|
||||
{ partKey: picked.partKey },
|
||||
);
|
||||
}
|
||||
|
||||
/** 우클릭 삭제의 두 길 — 관 = 관 지점 정본 · 그 밖 = 좌측 칸 [삭제]와 같은 길. */
|
||||
export interface OwnerRemove {
|
||||
pipe: (chainageM: number) => void;
|
||||
structure: (structureId: string) => void;
|
||||
}
|
||||
|
||||
/** 종단 측점 하나를 지움(513-3 · 우클릭 「구조물 삭제」 종단 · 평면 공용) — 측점 id = 주인 · `주인:역할`
|
||||
* (`plannedIrregular`) · 관 기준 측점 = 그 자리 · 관 구간 끝 = 관 자리 · 그 밖 = 구조물 id(좌측 칸 [삭제]와 같은 길). */
|
||||
export function removeStationOwner(
|
||||
station: { id: string; chainage_m: number },
|
||||
pipe: boolean,
|
||||
remove: OwnerRemove,
|
||||
): void {
|
||||
if (pipe) return remove.pipe(station.chainage_m);
|
||||
removeMarkOwner(station.id.replace(/:(start|end)$/, ""), remove);
|
||||
}
|
||||
|
||||
/** 알약 하나를 지움(514-1 · 측점선 없는 구조물도) — 관 알약 = 관 세션 id 장부 자리 · 그 밖 = 구조물 id. */
|
||||
export function removeMarkOwner(structureId: string, remove: OwnerRemove): void {
|
||||
const at = pipeChainageOf(structureId);
|
||||
if (at !== null) remove.pipe(at);
|
||||
else remove.structure(structureId);
|
||||
}
|
||||
|
||||
@@ -8,29 +8,18 @@
|
||||
* 종류 = 구조물 정의 전부(보기와 같음). 화면 모듈을 끌어 묶음 대신 페이지(`design_shared`)가 옵션으로 넘김.
|
||||
* ========================================================================== */
|
||||
|
||||
import { listedBase } from "./design_profile_base";
|
||||
import { plannedIrregular } from "./design_structure_status";
|
||||
import { withPipeIds } from "./design_pipe_ids";
|
||||
import { planStations, type PlanType } from "../../common_util/common_util_structure_station";
|
||||
import type { PipelineInputs } from "./logic/design_pipeline_types";
|
||||
import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import {
|
||||
toAlignmentBase,
|
||||
type AlignmentBase,
|
||||
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { plantedBase as plantedBaseWith } from "./logic/design_planted_base";
|
||||
import type { PlanType } from "../../common_util/common_util_structure_station";
|
||||
import type { AlignmentBase } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
/** 저장 상세(종단도가 그리는 것) + 지금 입력이 심는 측점 = 저장 노선 종단 기준. 상세가 없으면 종단도 기준. */
|
||||
/** 저장 상세 + 지금 입력이 심는 측점 = 저장 노선 종단 기준(셈 = logic) · 관 id = 세션 장부(`withPipeIds`). */
|
||||
export function plantedBase(
|
||||
source: { base: AlignmentBase | null; detail?: SectionDetailResponse | null },
|
||||
inputs: Pick<PipelineInputs, "pipes" | "structures">,
|
||||
types: ReadonlyArray<PlanType>,
|
||||
): AlignmentBase | null {
|
||||
const detail = source.detail ?? null;
|
||||
const stored = detail && readAlignment(detail.longitudinal);
|
||||
if (!detail || !stored) return source.base;
|
||||
const pipes = withPipeIds((inputs.pipes ?? []) as Array<{ chainage_m: number }>);
|
||||
const structures = (inputs.structures ?? []) as Parameters<typeof planStations>[1];
|
||||
const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관
|
||||
return listedBase(toAlignmentBase(stored), detail, planted);
|
||||
return plantedBaseWith(source, inputs, types, (pipes) => withPipeIds(pipes));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_pipeline_cross_server.ts
|
||||
* 계산 자리 cross = server 의 묶음 cross 단계(PLAN 511-B B-4) — 엔진 길(노선 초안 · 저장 상세 없음)을 서버 Node
|
||||
* 단계(`stage.cross` · `design_calc_node_stages.ts`)가 같은 단계 함수 · 같은 붙임으로 셈. 저장 노선 다시 셈은
|
||||
* `B06_Section_Cross_Refresh` 의 `cross.refresh`. 판 both · DTM 격자 없음(DTM 아닌 지표면 · 받기 전) = null
|
||||
* (브라우저). 격자 경로 · 구조물 정의 = 서버 창구가 채움(`B06_Section_Router_Calc.py`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState } from "../b_page_state";
|
||||
import { designTerrainGridNow } from "../../B05_Profile/B05_Profile_Api_TerrainGrid";
|
||||
import { calcOnServerOnly } from "./design_calc_server";
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import type { StagesInputs } from "./logic/design_calc_node_stages";
|
||||
import type { CrossOutput, StageContext } from "./logic/design_pipeline_types";
|
||||
|
||||
/** 단계 입력 중 페이지 저장 맥락(묶음 입력 · 격자 · 관 세션 장부 뺌). */
|
||||
export type CrossStageContext = Omit<
|
||||
NonNullable<StagesInputs["stages"]>,
|
||||
"input" | "grid" | "pipe_ids"
|
||||
>;
|
||||
|
||||
/** 서버 단계 답 · 끊김 · 실패 = undefined(부르는 쪽이 브라우저) · server 아님 = null. */
|
||||
export function crossOnServer(
|
||||
projectId: string,
|
||||
ctx: StageContext<"cross">,
|
||||
context: () => CrossStageContext | null,
|
||||
): Promise<CrossOutput | undefined> | null {
|
||||
if (!calcOnServerNow("cross") || !designTerrainGridNow(projectId)) return null;
|
||||
const saved = context();
|
||||
if (!saved) return null;
|
||||
// ponytail: 저장 상세(1.4 MB)를 부름마다 올림 — 시험용 길 · 느리면 서버가 정본 맥락을 붙임
|
||||
return calcOnServerOnly<CrossOutput>(
|
||||
"cross",
|
||||
"C.stage",
|
||||
{
|
||||
run: ["stage.cross"],
|
||||
inputs: {
|
||||
stages: { ...saved, pipe_ids: readState("pipe-ids", projectId), input: { ...ctx.inputs } },
|
||||
},
|
||||
pick: (results) => results["stage.cross"],
|
||||
},
|
||||
ctx.signal,
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,9 @@ import { publishPagePipeline } from "./design_pipeline_page_publish";
|
||||
import { SOURCE_INPUT } from "./logic/design_pipeline_types";
|
||||
export { pipelineWork } from "./logic/design_pipeline";
|
||||
import { createStationsStage, snapOptions, stationDiff } from "./logic/design_pipeline_stations";
|
||||
import { stationsOnServer } from "./design_stations_server";
|
||||
import { routeOnServer } from "./design_route_server";
|
||||
import { followFromServer } from "./design_follow_server";
|
||||
import type {
|
||||
ChangeSource,
|
||||
FollowPatch,
|
||||
@@ -40,9 +43,9 @@ import type {
|
||||
import { useDesignStore, type DesignStoreValues } from "./design_store";
|
||||
import {
|
||||
followStructures,
|
||||
placedOnLine as placed,
|
||||
type FollowingStructure,
|
||||
} from "../../common_util/common_util_route_follow";
|
||||
import { contextCross, placedFollowInputs } from "./logic/design_pipeline_stage_inputs";
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage";
|
||||
import {
|
||||
@@ -87,20 +90,16 @@ import {
|
||||
createFollowStage,
|
||||
createPlanlineStage,
|
||||
gradeRange,
|
||||
profileStage,
|
||||
routeRange,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||
import { groundPviAt, profileAt } from "./design_calc_profile";
|
||||
import {
|
||||
crossWithContext,
|
||||
draftSectionDetail,
|
||||
shapeStatus,
|
||||
type CrossInput,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
import {
|
||||
buildStations,
|
||||
SECTION_STATION_INTERVAL_M,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import { footprintStage } from "../../B05_Profile/logic/B05_Profile_Footprint_Stage";
|
||||
import { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import { footprintStageSited } from "../../B05_Profile/B05_Profile_Footprint_Server";
|
||||
import { crossOnServer, type CrossStageContext } from "./design_pipeline_cross_server";
|
||||
import type {
|
||||
PlanPipe,
|
||||
PlanStructure,
|
||||
@@ -275,6 +274,19 @@ function createPagePipeline(
|
||||
{ intervalM: options.stationIntervalM, types: enabled, ...snapOptions(inputs.route, plan) },
|
||||
).map((station) => station.chainageM),
|
||||
});
|
||||
/** cross 서버 단계 맥락(511-B B-4) — 저장 노선 · 상세 · 시설 · 편집(`design_pipeline_cross_server`). */
|
||||
const serverContext = (): CrossStageContext | null =>
|
||||
plan && {
|
||||
plan,
|
||||
detail: savedSource().detail ?? null,
|
||||
types,
|
||||
saved_pipes: savedPipes,
|
||||
saved_structures: savedStructures,
|
||||
saved_edits: savedEdits(),
|
||||
saved_plus: savedPlusRows(projectId),
|
||||
before_draft: beforeDraft,
|
||||
interval_m: options.stationIntervalM,
|
||||
};
|
||||
/** 다시 셈 창구가 낸 cross 출력(365-4) — 엔진 재사용에서 뺌. */
|
||||
const savedOut = new WeakSet<object>();
|
||||
const pipe = createDesignPipeline({
|
||||
@@ -282,6 +294,7 @@ function createPagePipeline(
|
||||
route: createRouteStage({
|
||||
minRadiusM: () => plan?.min_radius_m ?? 0,
|
||||
savedLine: () => plan && { line: savedLine()!, curves: savedCurves() },
|
||||
remote: routeOnServer, // 계산 자리 route 「서버」(511-B B-5)
|
||||
}),
|
||||
follow: (ctx) => {
|
||||
const kept = replay && ctx.inputs.route ? readAlignmentDraft(DRAFT_ROUTE_ID) : null;
|
||||
@@ -291,73 +304,56 @@ function createPagePipeline(
|
||||
const { pipes, structures, plusStations } = inputsNow();
|
||||
return { alignment: kept, pipes, structures, plusStations };
|
||||
}
|
||||
const line = ctx.before.route?.line ?? savedLine() ?? [];
|
||||
// 자리를 읽을 선 — 초안 둔 채 열기(앞 노선 없음)의 가게 값 = 이미 초안 노선 자리(앞 따라감이 씀 · 410)
|
||||
const frame = (list: unknown, saved: unknown) =>
|
||||
!ctx.before.route && ctx.inputs.route && list !== saved
|
||||
? (ctx.out.route?.line ?? line)
|
||||
: line;
|
||||
const { pipes, structures } = ctx.inputs;
|
||||
// 좌표 없는 관 = 앞 따라감(같은 id · 같은 자리) → 정본 좌표 · 옮긴 것 = 그 자리의 앞 노선 위(410)
|
||||
const followed = (ctx.before.follow?.pipes ?? []) as FollowingStructure[];
|
||||
const inputs = {
|
||||
...ctx.inputs,
|
||||
pipes: placed(pipes, frame(pipes, savedPipes), [
|
||||
...(savedPipes as FollowingStructure[]),
|
||||
...followed,
|
||||
]),
|
||||
structures: placed(
|
||||
structures,
|
||||
frame(structures, savedStructures),
|
||||
[],
|
||||
SECTION_STATION_INTERVAL_M,
|
||||
),
|
||||
};
|
||||
return followStage({ ...ctx, inputs });
|
||||
// 계산 자리 follow 「서버」(511-B B-5) — 끌기 중 화면 셈 · 잠잠 뒤 서버 값으로 바꿈
|
||||
const before = beforeDraft ?? facilitiesNow();
|
||||
const remote = followFromServer(projectId, ctx.inputs, before, savedEdits(), () =>
|
||||
pipe.refresh("route"),
|
||||
);
|
||||
const saved = { pipes: savedPipes, structures: savedStructures };
|
||||
return (
|
||||
remote ?? followStage({ ...ctx, inputs: placedFollowInputs(ctx, savedLine(), saved) })
|
||||
);
|
||||
},
|
||||
stations: createStationsStage({
|
||||
intervalM: options.stationIntervalM,
|
||||
types: () => enabled,
|
||||
saved: () => plan,
|
||||
remote: stationsOnServer, // 계산 자리 stations 「서버」(511-B B-2)
|
||||
}),
|
||||
ground: createGroundStage(groundDeps),
|
||||
planline: createPlanlineStage({
|
||||
savedBase: (at) => options.plantedBase?.(savedSource(), at, types) ?? savedSource().base,
|
||||
savedLine,
|
||||
storedPvi,
|
||||
groundPvi: groundPviAt, // 계산 자리 「서버」 = 서버 Node(511-B B-3)
|
||||
}),
|
||||
profile: profileStage,
|
||||
profile: profileAt,
|
||||
// 단면 유형 = 새 지형 상단측 · 소단 = 지금 구조물(365-5) · 저장 노선 = 다시 셈 창구 한 벌(365-4) · 엔진은
|
||||
// 창구 출력을 앞 결과로 안 이어 씀(초안 = 새로고침)
|
||||
cross: (ctx) => {
|
||||
const stations = ctx.out.ground?.stations ?? [];
|
||||
const status = statusOf(ctx.inputs.pipes, ctx.inputs.structures);
|
||||
const engine = () =>
|
||||
crossStage.stage({
|
||||
...ctx,
|
||||
...contextCross(ctx, status, options.bermAt?.(ctx.inputs.structures) ?? null),
|
||||
prev: ctx.prev && savedOut.has(ctx.prev) ? null : ctx.prev,
|
||||
inputs: {
|
||||
...ctx.inputs,
|
||||
cross: crossWithContext(
|
||||
ctx.inputs.cross as CrossInput | null,
|
||||
stations,
|
||||
statusOf(ctx.inputs.pipes, ctx.inputs.structures),
|
||||
options.bermAt?.(ctx.inputs.structures) ?? null,
|
||||
),
|
||||
},
|
||||
});
|
||||
// 계산 자리 cross = server — 엔진 길도 서버 Node 단계(같은 TS · 511-B B-4) · 못 받음 = 브라우저
|
||||
const remote = () =>
|
||||
crossOnServer(projectId, ctx, serverContext)?.then((o) => o ?? engine());
|
||||
const { detail = null, routeId } = savedSource();
|
||||
const saved =
|
||||
!ctx.inputs.route && detail && typeof routeId === "number"
|
||||
? options.savedCross?.(detail, routeId, stations)
|
||||
: null;
|
||||
if (!saved) return engine();
|
||||
if (!saved) return remote() ?? engine();
|
||||
return saved.then((out) => {
|
||||
if (!out) return engine();
|
||||
if (!out) return remote() ?? engine();
|
||||
savedOut.add(out);
|
||||
return out;
|
||||
});
|
||||
},
|
||||
footprint: footprintStage,
|
||||
footprint: footprintStageSited, // 계산 자리 판(511-A)
|
||||
drainage: link.side?.pipelineStage,
|
||||
corridor: (ctx) => {
|
||||
if (ctx.inputs.route) options.onCorridorStale?.();
|
||||
|
||||
@@ -4,50 +4,5 @@
|
||||
* 계산 묶음 planline 단계(`design_pipeline_page`)도 같은 기준을 받음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { AlignmentBase } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
irregularGraphStations,
|
||||
maxChainageOf,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
|
||||
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
|
||||
import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import { seatBornNodes } from "../../B05_Profile/logic/B05_Profile_Grade_Seat";
|
||||
|
||||
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
|
||||
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지우고 새 측점엔 세움(`followMovedNodes` · `seatBornNodes` · 427) ·
|
||||
* 늘 저장분에서 셈. 심은 측점이 아직 없으면(null · 시설 줄 입력 전) 저장분 그대로(473). */
|
||||
export function listedBase(
|
||||
storedBase: AlignmentBase | null,
|
||||
detail: SectionDetailResponse | null,
|
||||
irregularStations: IrregularStation[] | null,
|
||||
): AlignmentBase | null {
|
||||
if (!storedBase || !detail || !irregularStations) 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)));
|
||||
const moved = followMovedNodes(
|
||||
storedBase.basePvi,
|
||||
gone,
|
||||
born,
|
||||
Object.keys(readAlignment(detail.longitudinal)?.edits.station_offsets ?? {}),
|
||||
);
|
||||
return {
|
||||
...storedBase,
|
||||
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat, moved.held),
|
||||
stations: list.map((station) => ({
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
})),
|
||||
};
|
||||
}
|
||||
// 계획선 측점 줄 = logic(511-B B-3 · 서버 Node 묶음도 같은 셈) — 부르는 쪽은 그대로 여기서 받음
|
||||
export { listedBase } from "./logic/design_planted_base";
|
||||
|
||||
@@ -10,6 +10,7 @@ import "../../B06_Section/B06_Section_UI_Style.css";
|
||||
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
|
||||
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross_Chart.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
|
||||
|
||||
export function createProfileFrame(view: string) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_route_server.ts
|
||||
* 계산 자리 route 「서버」(PLAN 511-B B-5) — 묶음 route 단계 노선(선 · 곡선)을 서버 Node
|
||||
* `route.polyline`(같은 `buildEditedPolyline` · 노선 저장과 같은 셈)으로. 판이 both 면 null(화면 셈) ·
|
||||
* 서버 값만 · 잠잠 뒤 · 끊김 · 실패 = undefined(단계가 화면 셈으로).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DraftArgs } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround";
|
||||
import type { EditedPolyline } from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import { calcOnServerOnly } from "./design_calc_server";
|
||||
|
||||
export function routeOnServer(
|
||||
args: DraftArgs,
|
||||
signal: AbortSignal,
|
||||
): Promise<EditedPolyline | undefined> | null {
|
||||
if (!calcOnServerNow("route")) return null;
|
||||
const [points, curveOn, curveRadius, minRadiusM] = args;
|
||||
const route_polyline = {
|
||||
points,
|
||||
curve_flags: curveOn,
|
||||
radii: curveRadius,
|
||||
min_radius_m: minRadiusM,
|
||||
};
|
||||
return calcOnServerOnly<EditedPolyline>(
|
||||
"route",
|
||||
"route.polyline",
|
||||
{
|
||||
run: ["route.polyline"],
|
||||
inputs: { route_polyline },
|
||||
pick: (results) => results["route.polyline"],
|
||||
},
|
||||
signal,
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,6 @@ import type { DesignView } from "./design_dock";
|
||||
import type { FacilityLane } from "./design_facility_lane";
|
||||
import { routeDraftBridge, type PagePipelineOptions } from "./design_pipeline_page";
|
||||
import { plantedBase } from "./design_pipeline_base";
|
||||
import { isUserPick } from "./design_user_pick";
|
||||
import { SECTION_STATION_INTERVAL_M } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import {
|
||||
applyStructureAreaRows,
|
||||
@@ -59,6 +58,7 @@ import {
|
||||
type CrossRefreshInput,
|
||||
} from "../../B06_Section/B06_Section_Cross_Refresh";
|
||||
import { bermSpansFromStructures, bermSpecAt } from "../../B06_Section/B06_Section_UI_Page_Persist";
|
||||
import { areasRequest, bermRequest, calcChecked } from "./design_calc_server";
|
||||
import { registeredKeys, stationIntervalOf } from "../../B06_Section/B06_Section_Station_Keys";
|
||||
import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
import type { CrossOutput, PipelineStation } from "./logic/design_pipeline_types";
|
||||
@@ -223,6 +223,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile {
|
||||
onIrregularSelect: toView(shared, "onIrregularSelect"),
|
||||
onStructureSelect: toView(shared, "onStructureSelect"),
|
||||
onStructureMarkMove: toView(shared, "onStructureMarkMove"),
|
||||
onStructureMarkRemove: toView(shared, "onStructureMarkRemove"),
|
||||
onStructureTypeAdd: toView(shared, "onStructureTypeAdd"),
|
||||
structureGroups: () => sharedStructures(shared).pickGroups(),
|
||||
onBasinSelected: toView(shared, "onBasinSelected"),
|
||||
@@ -254,7 +255,11 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo
|
||||
onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"),
|
||||
// 초안 소단 = 지금 구조물(따라간 자리)의 소단 구간(365-5 · 저장 노선은 다시 셈 창구가 세션 사본으로)
|
||||
bermAt: (structures) => {
|
||||
const spans = bermSpansFromStructures(Array.isArray(structures) ? structures : []);
|
||||
const list = Array.isArray(structures) ? structures : [];
|
||||
// 계산 자리 quantity = server 면 잠잠 뒤 서버 대조만(초안 미리보기 · 511-A)
|
||||
const spans = calcChecked("quantity", "cross.berm_spans", bermSpansFromStructures, [list], {
|
||||
request: bermRequest(list),
|
||||
});
|
||||
return (chainageM) => bermSpecAt(spans, chainageM);
|
||||
},
|
||||
// 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 늘 고치고 그
|
||||
@@ -277,7 +282,12 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo
|
||||
const routeId = routeIdOf(shared);
|
||||
if (routeId !== null) withDraftAdjusts(detail, projectId, routeId);
|
||||
const rows = detail.cross_sections;
|
||||
applyStructureAreaRows(rows, structureAreaRows(rows));
|
||||
// 계산 자리 quantity = server 면 잠잠 뒤 서버 값 대조 · 다르면 얹고 다시 그림(511-A)
|
||||
const areas = calcChecked("quantity", "structure.layouts", structureAreaRows, [rows], {
|
||||
request: areasRequest(rows),
|
||||
apply: (server) => (applyStructureAreaRows(rows, server), profile.redraw()),
|
||||
});
|
||||
applyStructureAreaRows(rows, areas);
|
||||
},
|
||||
// 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움
|
||||
holdDraft: (routeId, saved, seed) => {
|
||||
@@ -321,10 +331,8 @@ export function sharedStructures(shared: DesignPageShared): StructuresSection {
|
||||
onPipeUpdate: (from, to, attributes) => on().onPipeUpdate?.(from, to, attributes),
|
||||
onPipeRemove: (chainageM) => on().onPipeRemove?.(chainageM),
|
||||
onPipeSelect: (chainageM) => on().onPipeSelect?.(chainageM),
|
||||
// 고름은 내용만 — 좌측 패널 · 「구조물 배치」 카드 열림 · 닫힘은 사용자가 둔 그대로(513-2)
|
||||
onListRelease: () => on().onListRelease?.(),
|
||||
// 사용자 고름 = 「구조물 배치」 카드 폄 — 좌측 패널 열림 · 닫힘은 그대로(398-1 · 499-1)
|
||||
onReveal: () =>
|
||||
isUserPick(shared.projectId) && shared.structures?.root.classList.remove("is-collapsed"),
|
||||
},
|
||||
{ includeDetail: () => shared.view() === "cross", projectId: shared.projectId },
|
||||
);
|
||||
@@ -403,9 +411,22 @@ async function alreadyConfirmed(projectId: string): Promise<boolean> {
|
||||
/** B06 [>] · 단계 판 B07~B09(496-1) — 확정 뒤 그 주소 · 실패 · 자료 서기 전 = 머묾. */
|
||||
export function confirmAndGo(shared: DesignPageShared, route: RoutePath): void {
|
||||
if (!shared.ready) return void showToast(L("Design_Next_NotReady"), "info");
|
||||
void alreadyConfirmed(shared.projectId).then((skip) =>
|
||||
skip ? goToWorkflowStage(shared.projectId, route) : saveSharedDesign(shared, true, route),
|
||||
);
|
||||
const go = () =>
|
||||
alreadyConfirmed(shared.projectId).then((skip) =>
|
||||
skip ? goToWorkflowStage(shared.projectId, route) : saveSharedDesign(shared, true, route),
|
||||
);
|
||||
void drainageFirst(shared).then((ready) => (ready ? go() : undefined));
|
||||
}
|
||||
|
||||
/** 배수유역도가 낡은 채 B07 로 — 확인 창 → [업데이트] 끝난 뒤 감 · 취소 · 실패 = 머묾(요구 326-6). */
|
||||
async function drainageFirst(shared: DesignPageShared): Promise<boolean> {
|
||||
const side = shared.profile?.side;
|
||||
if (!side?.stale()) return true;
|
||||
if (!(await showConfirmDialog(L("Design_Drainage_Stale"), L("Design_Drainage_Stale_Go"))))
|
||||
return false;
|
||||
if (await side.update()) return true;
|
||||
showToast(L("Design_Drainage_Stale_Failed"), "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_stations_server.ts
|
||||
* 계산 자리 stations 「서버」(PLAN 511-B B-2) — 묶음 stations 단계 측점 목록을 서버 Node
|
||||
* `stations.list`(같은 `buildStations`)로. 판이 both 면 null(화면 셈) · 서버 값만 · 잠잠 뒤 ·
|
||||
* 끊김 · 실패 = undefined(단계가 화면 셈으로). 구조물 정의는 서버 창구가 붙임.
|
||||
* ========================================================================== */
|
||||
|
||||
import { plusStations } from "@util/common_util_structure_station";
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import { calcOnServerOnly } from "./design_calc_server";
|
||||
import type { StationsArgs } from "./logic/design_pipeline_stations";
|
||||
import type { PipelineStation } from "./logic/design_pipeline_types";
|
||||
|
||||
export function stationsOnServer(
|
||||
args: StationsArgs,
|
||||
signal: AbortSignal,
|
||||
): Promise<PipelineStation[] | undefined> | null {
|
||||
if (!calcOnServerNow("stations")) return null;
|
||||
const [line, curves, pipes, structures, options] = args;
|
||||
const stations = {
|
||||
line,
|
||||
curves,
|
||||
pipes,
|
||||
structures,
|
||||
types: options.types,
|
||||
plus: [...(options.plus ?? plusStations())],
|
||||
interval_m: options.intervalM,
|
||||
include_endpoint: options.includeEndpoint,
|
||||
station_snap: options.stationSnap ?? false,
|
||||
station_snap_step: options.stationSnapStep ?? null,
|
||||
};
|
||||
return calcOnServerOnly<PipelineStation[]>(
|
||||
"stations",
|
||||
"stations.list",
|
||||
{ run: ["stations.list"], inputs: { stations }, pick: (results) => results["stations.list"] },
|
||||
signal,
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,18 @@ export function fordHeightM(designFlowM3s: number | null, widthM: number): numbe
|
||||
return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null;
|
||||
}
|
||||
|
||||
/** 배수유역도가 낡은 동안(요구 328-3) 설계유량 = 옛 유역 측점 값이라 「낡음」으로 가림 · 높이도 안 채움.
|
||||
* 판정은 배수유역 판 하나(`B05_Profile_UI_Drainage_Live` 가 부름) · 바뀌면 열린 폼을 다시 그림. */
|
||||
let drainageStale = false;
|
||||
// ponytail: 폼마다 하나씩 쌓임(페이지 다시 열 때만 · 떨어진 폼 다시 그림은 헛일) — 많아지면 폼 닫을 때 뺌
|
||||
const staleRepaints = new Set<() => void>();
|
||||
export const FORD_FLOW_STALE_HINT = "설계유량 낡음 — 배수유역도 [업데이트] 뒤 셈";
|
||||
export function setDrainageStale(next: boolean): void {
|
||||
if (drainageStale === next) return;
|
||||
drainageStale = next;
|
||||
staleRepaints.forEach((repaint) => repaint());
|
||||
}
|
||||
|
||||
/**
|
||||
* 월류 폭 ↔ 높이 연동 — 세월교 · 물넘이 포장 한 벌(52-4 · 65-4 · 65-5 · 287-3).
|
||||
* · 폭을 바꾸면 높이 = 필요 수심 · 높이를 비우거나 그 아래로 넣으면 필요 수심으로.
|
||||
@@ -41,7 +53,8 @@ export function overflowSync(
|
||||
let pending = false;
|
||||
const values = () => form().state()[box]?.[0] ?? {};
|
||||
const width = () => Number(values().width ?? "");
|
||||
const need = (): number | null => fordHeightM(flow(), width());
|
||||
const need = (): number | null => (drainageStale ? null : fordHeightM(flow(), width()));
|
||||
staleRepaints.add(() => form()?.refresh());
|
||||
return {
|
||||
arm: (fresh: boolean): void => {
|
||||
pending = fresh;
|
||||
@@ -67,7 +80,7 @@ export function overflowSync(
|
||||
if (c.id !== box) return [];
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "b05-structure__flow-hint";
|
||||
hint.textContent = fordHeightHint(flow(), width());
|
||||
hint.textContent = drainageStale ? FORD_FLOW_STALE_HINT : fordHeightHint(flow(), width());
|
||||
return [hint];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,35 +6,10 @@
|
||||
* 구조물 id = 구조물은 structure_id · 관은 세션 id(`design_pipe_ids` · 323-5 · 파일 모양 그대로 · 저장 안 함).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import type { PlannedStation } from "../../common_util/common_util_structure_station";
|
||||
// 측점 계획 → 종단 측점 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
||||
export { plannedIrregular } from "./logic/design_planted_base";
|
||||
import { useDesignStore, type DesignStoreValues } from "./design_store";
|
||||
|
||||
/**
|
||||
* 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` ·
|
||||
* 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 =
|
||||
* 「배관」 꼴(관 끌기 · 지우기 길).
|
||||
*/
|
||||
export function plannedIrregular(
|
||||
planned: ReadonlyArray<PlannedStation>,
|
||||
intervalM: number,
|
||||
): IrregularStation[] {
|
||||
const interval = intervalM > 0 ? intervalM : 20;
|
||||
return planned.map((station) => {
|
||||
const owner = station.owners[0];
|
||||
const pipe = owner.startsWith("pipe-") && station.role === "anchor";
|
||||
const number = Math.floor(station.chainage_m / interval);
|
||||
return {
|
||||
id: station.role === "anchor" ? owner : `${owner}:${station.role}`,
|
||||
station: number,
|
||||
remainder: station.chainage_m - number * interval,
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.title,
|
||||
...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 바뀜 신호 하나(323-6) — 관 · 구조물 · 추가 측점 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 ·
|
||||
* 구간 · 잇기가 바뀔 때만(옵션만 고친 것은 폼 길 `applyPipeOptions` 가 그 카드만 고침 — 카드 줌 · 팬 지킴).
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_user_pick.ts
|
||||
* 「사용자 고름」 표시 한 칸(PLAN 398-1) — 좌측 패널 저절로 펴기(09-04 「바깥에서 고르면 좌측 패널도」)는
|
||||
* 사용자가 직접 누른 고름에서만.
|
||||
*
|
||||
* 사용자 고름 = 지금 브라우저가 진짜 누름(포인터 · 마우스 · 우클릭)을 처리하는 중 — 눈금 · 마름모 · 3D ·
|
||||
* 종단 알약 · 배수유역도 · 횡단 카드 · 우클릭 추가가 다 그 누름 안에서 폼을 엶.
|
||||
* 아님 = 누름 밖(서버 응답 · 타이머 — 배수유역 다시 나눔 · 횡단 다시 그림) · 방향키 · 묶음을 띄운 그 누름
|
||||
* (교점 끌기 · 곡선 지우기 · 되돌리기가 띄운 차례의 관 · 구조물 따라가기 · 다시 칠하기).
|
||||
* 「계산 중」 동안 새로 누른 고름은 폄(505-2).
|
||||
* 폼이 고른 것을 다시 실을 때마다 부르는 쪽을 막지 않고 펴기 한 곳(`design_shared` onReveal)이 여기만 봄.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pageLaunchedBy } from "./design_page_data";
|
||||
|
||||
const PICK_EVENTS = new Set([
|
||||
"pointerdown",
|
||||
"pointerup",
|
||||
"mousedown",
|
||||
"mouseup",
|
||||
"click",
|
||||
"dblclick",
|
||||
"contextmenu",
|
||||
]);
|
||||
|
||||
/** 지금 사용자 누름 안이고 그 누름이 묶음을 안 띄웠나 — 좌측 패널을 펴도 되는가. */
|
||||
export function isUserPick(projectId: string | null): boolean {
|
||||
const event = (globalThis as { event?: Event }).event; // 처리 중인 이벤트(그 마이크로태스크까지)
|
||||
if (!event?.isTrusted || !PICK_EVENTS.has(event.type)) return false;
|
||||
return !projectId || !pageLaunchedBy(projectId, event);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Structures_Selection";
|
||||
import "../../B06_Section/B06_Section_UI_Style.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross_Chart.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross_Controls.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
|
||||
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
||||
|
||||
@@ -66,7 +66,7 @@ import "../../B05_Profile/B05_Profile_UI_Style.css";
|
||||
import "../../B05_Profile/B05_Profile_UI_Style_Table.css";
|
||||
import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
|
||||
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
|
||||
import { pipeIdAt } from "./design_pipe_ids";
|
||||
import { pipeIdAt, removeMarkOwner, removeStationOwner } from "./design_pipe_ids";
|
||||
import { useDesignStore } from "./design_store";
|
||||
import { pageSettingsPanelValues } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
|
||||
import { setPlusStations } from "../../common_util/common_util_structure_station";
|
||||
@@ -172,6 +172,10 @@ export async function mountProfileView(
|
||||
}
|
||||
panel.structures.moveById(structureId, toChainage);
|
||||
};
|
||||
const removeOwner = {
|
||||
pipe: (chainage: number) => profilePanel.drainage.removePipe(chainage),
|
||||
structure: (structureId: string) => void panel.structures.removeById(structureId),
|
||||
};
|
||||
// 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b).
|
||||
const profilePanel = createRouteProfilePanel(
|
||||
shared,
|
||||
@@ -194,9 +198,10 @@ export async function mountProfileView(
|
||||
onStructureMove: (from, to, station) => {
|
||||
if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to);
|
||||
},
|
||||
onStructureRemove: (station) => {
|
||||
if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m);
|
||||
},
|
||||
// 우클릭 삭제 — 관 = 관 지점 정본 · 그 밖 = 좌측 칸 [삭제]와 같은 길(513-3 · 알약 514-1)
|
||||
onStructureRemove: (station) =>
|
||||
removeStationOwner(station, isPipeStation(station), removeOwner),
|
||||
onStructureMarkRemove: (structureId) => removeMarkOwner(structureId, removeOwner),
|
||||
// 우클릭 [배관 추가] — 사이드 폼에 종류·측점을 실어 주고 [추가]로 확정(2026-08-18).
|
||||
onPipeAdd: (chainage) => panel.structures.addAt(chainage, "pipe"),
|
||||
onStructureAdd: (chainage, type) =>
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { DesignPageHost } from "./design_page";
|
||||
import type { createProfileSections, ProfileViewState } from "./design_view_profile_sections";
|
||||
import { createViewer3dModal } from "./design_view_profile_3d_modal";
|
||||
import { pageRouteId } from "./design_page_route";
|
||||
import { calcOnServer } from "./design_calc_sites";
|
||||
|
||||
export interface Profile3dDeps {
|
||||
projectId: string;
|
||||
@@ -67,7 +68,7 @@ export function createProfile3d(deps: Profile3dDeps) {
|
||||
if (!detail || !latest?.route || routeId === null) return;
|
||||
const routePoints = latest.route_points ?? [];
|
||||
if (routePoints.length <= 1) return;
|
||||
await refreshCorridor(
|
||||
const fresh = await refreshCorridor(
|
||||
viewer,
|
||||
projectId,
|
||||
routeId,
|
||||
@@ -77,7 +78,7 @@ export function createProfile3d(deps: Profile3dDeps) {
|
||||
);
|
||||
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
|
||||
deps.sections().renderStationLines(detail);
|
||||
deps.sections().setCorridorPending(false);
|
||||
deps.sections().setCorridorPending(!fresh); // D3 = 서버 짓기 실패 = 늦음 남김(508-2)
|
||||
}
|
||||
|
||||
async function applyContours(interval: number): Promise<void> {
|
||||
@@ -134,8 +135,10 @@ export function createProfile3d(deps: Profile3dDeps) {
|
||||
loadTerrain,
|
||||
visible: () => deps.host.visible(),
|
||||
// 낡은 채 열면 그때 한 번 다시 짓는다(워커 · 345-4) — 닫혀 있는 동안은 안 짓는다.
|
||||
// D3 = 서버면 [3D 업데이트]만 다시 지음(요구 324) — 열 때는 옛 모형 그대로.
|
||||
onOpen: () => {
|
||||
if (state.corridorPending) void refreshOnce();
|
||||
if (state.corridorPending)
|
||||
void calcOnServer("corridor_mesh").then((server) => void (server || refreshOnce()));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,26 +5,34 @@
|
||||
* 브라우저가 쓰는 같은 TS 를 그대로 부름 — 계산을 두 벌로 짜지 않음.
|
||||
*
|
||||
* 실행: node <묶음> <입력.json> <출력.json> (package.json `build:design-calc`)
|
||||
* node <묶음> --serve 늘 떠 있음 — 표준 입력 한 줄 = 요청 JSON · 표준 출력 한 줄 = 출력 JSON(511-A)
|
||||
* 입력 { run: [id…], inputs: { detail?: 종횡단 상세(API 꼴) · route_points?: 노선 폴리라인 ·
|
||||
* context?: { earthwork_conversion · natural_spoil_min_ground_slope ·
|
||||
* haul_equipment_limits · 채집석 · 구조물 잔토(B08) } ·
|
||||
* excavation?: { pipes: [{ diameter_mm }] · walls: [{ height_m · average_thickness_m ·
|
||||
* has_foundation }] } ·
|
||||
* cross_design?: { standard: 표준 횡단(config 위에 저장분 덮은 한 벌) ·
|
||||
* stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } ·
|
||||
* stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 ·
|
||||
* chainage_m? }] · facilities?: { pipes · structures }(시설 구간 · 511-B B-4) } ·
|
||||
* berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } ·
|
||||
* rebase? · carry?(노선 바뀐 [저장] · `design_calc_node_carry.ts`) } }
|
||||
* rebase? · carry?(노선 바뀐 [저장] · `design_calc_node_carry.ts`) ·
|
||||
* profile? · carry_profile?(종단 · `design_calc_node_profile.ts` · 511-B B-3) ·
|
||||
* definition? · attach? · cross_enforce?(형상 얹기 · [저장] 횡단 · `design_calc_node_cross.ts` · 511-B B-4) ·
|
||||
* stages?(묶음 단계 · `design_calc_node_stages.ts` · 511-B) ·
|
||||
* stations?(측점 · `design_calc_node_sections.ts` · 511-B B-2) ·
|
||||
* route_polyline?(노선 · `design_calc_node_route.ts` · 511-B B-5) } }
|
||||
* 출력 { results: { [id]: 값 }, available: [id…] }
|
||||
* — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 ·
|
||||
* server-calc 묶음과 같은 차례).
|
||||
* 끝 코드: 0 성공 / 2 인자 오류 / 4 모르는 id
|
||||
* [저장] 재계산과 같은 차례).
|
||||
* — 시험 길 id(`design_calc_node_trial.ts`) · 단계 id(`stage.*`)도 돌지만 `available` 에는 없음.
|
||||
* 끝 코드: 0 성공 / 1 셈 실패 / 2 인자 오류 / 4 모르는 id(--serve 는 그 줄 { error })
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { bermSpansFromStructures, bermSpecAt } from "@util/common_util_berm_spans";
|
||||
import type { BermSpec } from "@util/common_util_cross_berm";
|
||||
import { computeCrossDesign } from "@util/common_util_cross_design";
|
||||
import type { CrossDesignOptions, CrossGroundSample } from "@util/common_util_cross_design";
|
||||
import type { CulvertPipePoint } from "@util/common_util_culvert_sets";
|
||||
import { pipeTrenchWidthM, wallTrenchAreaM2, wallTrenchWidthM } from "@util/common_util_excavation";
|
||||
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
|
||||
import type { MassHaulResult } from "@util/common_util_mass_haul";
|
||||
@@ -33,17 +41,38 @@ import type { RoutePoint } from "../../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { buildCorridor } from "../../../B05_Profile/B05_Profile_UI_Corridor_Build";
|
||||
import { corridorHash, serialize } from "../../../B05_Profile/B05_Profile_UI_Corridor_Envelope";
|
||||
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
|
||||
import {
|
||||
facilityAt,
|
||||
facilityRanges,
|
||||
type FacilityStructure,
|
||||
} from "../../../B06_Section/logic/B06_Section_Cross_Enforce";
|
||||
import { crossOptions } from "../../../B06_Section/logic/B06_Section_Cross_Save";
|
||||
import {
|
||||
applyStructureAreaRows,
|
||||
extraWallRows,
|
||||
structureAreaRows,
|
||||
} from "../../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import { carryRunners, type CarryInputs } from "./design_calc_node_carry";
|
||||
import { crossRunners, PAGE_IDS, useDefinition, type CrossInputs } from "./design_calc_node_cross";
|
||||
import { followRunners, type FollowInputs } from "./design_calc_node_follow";
|
||||
import { profileRunners, type ProfileInputs } from "./design_calc_node_profile";
|
||||
import { routeRunners, type RouteInputs } from "./design_calc_node_route";
|
||||
import { sectionsRunners, type SectionsInputs } from "./design_calc_node_sections";
|
||||
import { STAGE_IDS, stageRunners, type StagesInputs } from "./design_calc_node_stages";
|
||||
import { plain, TRIAL_IDS, trialRunners, type TrialInputs } from "./design_calc_node_trial";
|
||||
|
||||
type HaulOptions = NonNullable<Parameters<typeof computeHaulPlan>[2]>;
|
||||
|
||||
interface CalcInputs extends FollowInputs, CarryInputs {
|
||||
interface CalcInputs
|
||||
extends
|
||||
FollowInputs,
|
||||
ProfileInputs,
|
||||
CarryInputs,
|
||||
CrossInputs,
|
||||
TrialInputs,
|
||||
StagesInputs,
|
||||
SectionsInputs,
|
||||
RouteInputs {
|
||||
detail?: SectionDetailResponse;
|
||||
route_points?: RoutePoint[];
|
||||
context?: Partial<
|
||||
@@ -78,69 +107,31 @@ interface CalcInputs extends FollowInputs, CarryInputs {
|
||||
samples: CrossGroundSample[];
|
||||
design_elevation_m: number | null;
|
||||
options: Record<string, unknown>;
|
||||
/** 시설 구간을 얹을 누가거리(`facilities` 와 함께 · 511-B B-4) */
|
||||
chainage_m?: number;
|
||||
}>;
|
||||
/** 관 지점 · 구조물 정본 → 측점마다 포장 · 기울임 · 세월교 하강(`facilityRanges` · C3) */
|
||||
facilities?: { pipes: CulvertPipePoint[]; structures: FacilityStructure[] | null };
|
||||
};
|
||||
}
|
||||
|
||||
/** 파이썬 `compute_cross_design` 키 → TS 짝 키(거울 시험 test_b06_cross_design_mirror 표와 같음). */
|
||||
const CROSS_OPTION_KEYS: Record<string, keyof CrossDesignOptions> = {
|
||||
ground_type: "groundType",
|
||||
section_mode: "sectionMode",
|
||||
ditch_side: "ditchSide",
|
||||
ditch_type: "ditchType",
|
||||
paved: "paved",
|
||||
rock_boundary_offset_m: "rockBoundaryOffsetM",
|
||||
two_stage_slope: "twoStageSlope",
|
||||
cut_slope_ratio: "cutSlopeRatio",
|
||||
ditch_enabled: "ditchEnabled",
|
||||
ditch_choice: "ditchChoice",
|
||||
surface_drop_m: "surfaceDropM",
|
||||
plan_radius_m: "planRadiusM",
|
||||
curve_outer_side: "curveOuterSide",
|
||||
curve_widening_m: "curveWideningM",
|
||||
shoulder_extend_left_m: "shoulderExtendLeftM",
|
||||
shoulder_extend_right_m: "shoulderExtendRightM",
|
||||
tilted: "tilted",
|
||||
tilt_pct: "tiltPct",
|
||||
};
|
||||
|
||||
/** 파이썬 BermSpec(NamedTuple → dict) → TS BermSpec. */
|
||||
function bermOf(value: unknown): BermSpec | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const berm = value as Record<string, number | null>;
|
||||
return {
|
||||
widthM: berm.width_m as number,
|
||||
intervalM: berm.interval_m as number,
|
||||
slopeDeg: berm.slope_deg as number,
|
||||
depthM: berm.depth_m as number,
|
||||
depthLeftM: berm.depth_left_m ?? null,
|
||||
depthRightM: berm.depth_right_m ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function crossOptions(
|
||||
raw: Record<string, unknown>,
|
||||
standard: CrossDesignOptions["standard"],
|
||||
): CrossDesignOptions {
|
||||
const options: Record<string, unknown> = { standard };
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (key === "berm") options.berm = bermOf(value);
|
||||
else if (key in CROSS_OPTION_KEYS) options[CROSS_OPTION_KEYS[key]] = value;
|
||||
else throw new Error(`모르는 횡단 설계 키: ${key}`);
|
||||
}
|
||||
return options as unknown as CrossDesignOptions;
|
||||
}
|
||||
|
||||
/** 한 번 돌림 — 같은 id 는 한 번만 셈(memo) · 앞 id 는 `need` 로. */
|
||||
function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
/** 한 번 돌림 — 같은 id 는 한 번만 셈(memo) · 앞 id 는 `need` 로 · 단계 id 는 약속(기다림). */
|
||||
async function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
const memo = new Map<string, unknown>();
|
||||
useDefinition(inputs.definition); // 구조물 표 = 서버 정의(M02 반영 · 511-B B-4)
|
||||
const sections = inputs.detail?.cross_sections ?? [];
|
||||
const context = inputs.context ?? {};
|
||||
let massHaul: MassHaulResult | null = null;
|
||||
|
||||
const RUNNERS: Record<string, () => unknown> = {
|
||||
...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①)
|
||||
...profileRunners(inputs), // 첫 계획선 · 계획선 짓기 · 계획고 옮김(511-B B-3)
|
||||
...carryRunners(inputs), // 노선 바뀐 [저장] 다시 얹기 — 관 · 구조물 초안 · 횡단 고침(472-6 6-B)
|
||||
...trialRunners(inputs), // 계산 자리 「서버」 시험 길(511-A)
|
||||
...stageRunners(inputs), // 묶음 단계 노선 ~ 횡단(511-B B-1)
|
||||
...crossRunners(inputs), // 형상 얹기 · [저장] 횡단 한 바퀴(511-B B-4)
|
||||
...sectionsRunners(inputs), // 측점 목록 · 서버 측점 생성(511-B B-2)
|
||||
...routeRunners(inputs), // 노선 폴리라인 · 곡선표(511-B B-5)
|
||||
// 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임.
|
||||
"structure.layouts": () => {
|
||||
const areas = structureAreaRows(sections);
|
||||
@@ -155,6 +146,14 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
: null;
|
||||
return massHaul ? massHaulPayload(massHaul) : null;
|
||||
},
|
||||
// 유토 배분 화면 꼴(/haul-plan 창구 · 511-C) — 인자 그대로(args) · 등록부 밖. 시험 길 표에 두면
|
||||
// 브라우저가 끄는 파일(`plain`)이라 배분 모듈이 첫 묶음에 실림 — 그래서 이 입구에만.
|
||||
"haul.view": () =>
|
||||
plain(
|
||||
computeHaulPlan(
|
||||
...((inputs.args?.["haul.view"] ?? []) as Parameters<typeof computeHaulPlan>),
|
||||
),
|
||||
),
|
||||
// 두 벌 — haul_plan = 거르지 않은 수량 정본(B08) · haul_plan_drawing = 잔진동 거른 그림.
|
||||
"haul.plan": () => {
|
||||
need("masshaul.cumulative");
|
||||
@@ -198,9 +197,15 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
// 측점마다 TS 짝(`computeCrossDesign`) — 못 세우면 그 자리 { error }(파이썬은 ValueError 로 되던짐).
|
||||
"cross.design": () => {
|
||||
const batch = inputs.cross_design;
|
||||
const ranges = batch?.facilities
|
||||
? facilityRanges(batch.facilities.pipes, batch.facilities.structures ?? [])
|
||||
: null;
|
||||
return (batch?.stations ?? []).map((station) => {
|
||||
try {
|
||||
const options = crossOptions(station.options, batch!.standard);
|
||||
const options = {
|
||||
...crossOptions(station.options, batch!.standard),
|
||||
...(ranges && station.chainage_m != null ? facilityAt(ranges, station.chainage_m) : {}),
|
||||
};
|
||||
return computeCrossDesign(station.samples, station.design_elevation_m, options);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
@@ -225,22 +230,64 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
|
||||
const unknown = run.filter((id) => !(id in RUNNERS));
|
||||
const results: Record<string, unknown> = {};
|
||||
if (!unknown.length) for (const id of run) results[id] = need(id);
|
||||
return { results, unknown, available: Object.keys(RUNNERS) };
|
||||
if (!unknown.length) for (const id of run) results[id] = await need(id);
|
||||
const outside = [...TRIAL_IDS, ...STAGE_IDS, ...PAGE_IDS, "haul.view"];
|
||||
const available = Object.keys(RUNNERS).filter((id) => !outside.includes(id));
|
||||
return { results, unknown, available };
|
||||
}
|
||||
|
||||
type CalcRequest = { run?: string[]; inputs?: CalcInputs };
|
||||
|
||||
/** 한 줄 요청 → 한 줄 답 · 실패는 그 줄 { error }(프로세스는 삶). */
|
||||
async function answerLine(line: string): Promise<unknown> {
|
||||
try {
|
||||
const request = JSON.parse(line) as CalcRequest;
|
||||
const t0 = performance.now();
|
||||
const { results, unknown } = await runDesignCalc(request.run ?? [], request.inputs ?? {});
|
||||
const ms = Math.round((performance.now() - t0) * 100) / 100;
|
||||
return unknown.length ? { error: `모르는 id: ${unknown.join(", ")}` } : { results, ms };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/** 늘 떠 있음(511-A) — 한 줄 받아 한 줄 냄 · 받은 차례대로(단계 id 는 기다림). */
|
||||
function serve(): void {
|
||||
let buffer = "";
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
for (let at = buffer.indexOf("\n"); at >= 0; at = buffer.indexOf("\n")) {
|
||||
const line = buffer.slice(0, at);
|
||||
buffer = buffer.slice(at + 1);
|
||||
queue = queue.then(async () => {
|
||||
process.stdout.write(`${JSON.stringify(await answerLine(line))}\n`);
|
||||
});
|
||||
}
|
||||
});
|
||||
process.stdin.on("end", () => void queue.then(() => process.exit(0)));
|
||||
}
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("사용법: node <묶음> <입력.json> <출력.json>");
|
||||
if (inputPath === "--serve") serve();
|
||||
else if (!inputPath || !outputPath) {
|
||||
console.error("사용법: node <묶음> <입력.json> <출력.json> | node <묶음> --serve");
|
||||
process.exit(2);
|
||||
} else {
|
||||
const request = JSON.parse(readFileSync(inputPath, "utf8")) as CalcRequest;
|
||||
void runDesignCalc(request.run ?? [], request.inputs ?? {}).then(
|
||||
({ results, unknown, available }) => {
|
||||
if (unknown.length) {
|
||||
console.error(`모르는 id: ${unknown.join(", ")}`);
|
||||
process.exit(4);
|
||||
}
|
||||
writeFileSync(outputPath, JSON.stringify({ results, available }));
|
||||
process.exit(0); // 묶음 타이머를 기다리지 않음
|
||||
},
|
||||
(error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
}
|
||||
const request = JSON.parse(readFileSync(inputPath, "utf8")) as {
|
||||
run?: string[];
|
||||
inputs?: CalcInputs;
|
||||
};
|
||||
const { results, unknown, available } = runDesignCalc(request.run ?? [], request.inputs ?? {});
|
||||
if (unknown.length) {
|
||||
console.error(`모르는 id: ${unknown.join(", ")}`);
|
||||
process.exit(4);
|
||||
}
|
||||
writeFileSync(outputPath, JSON.stringify({ results, available }));
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_cross.ts
|
||||
* 계산 Node 입구(`design_calc_node.ts`)의 횡단 몫(PLAN 511-B B-4) — 서버 GET · [저장] 이 부르는 횡단 셈을
|
||||
* 브라우저와 같은 TS 로(파이썬 짝을 걷는 자리).
|
||||
*
|
||||
* facility.attach 측점 그림 자료(관 세트 · 벽 · 포장 · 맹암거 · 빈칸 경고 · `attachShapes` · F4 F5 F6 C6)
|
||||
* 입력 attach { sections: [{ chainage_m · center_x · center_y }] · pipes · structures · types }
|
||||
* 출력 { shapes: 측점마다 그림 자료 칸만 · misses: { outside · far } }
|
||||
* cross.enforce [저장] 횡단 한 바퀴(`enforceSavedCross` · 설계선 → 미교차 넓힘 → 포장 → 세월교 하강 · G2 C3)
|
||||
* 입력 cross_enforce = `SaveCrossInput` + grid(DTM 격자 이진 경로 · 넓힘 때만 읽음)
|
||||
* 출력 측점마다 { design · samples?(넓힘) · extended? } | null
|
||||
* cross.refresh 계산 자리 cross = server 의 저장 노선 횡단 다시 셈(`refreshDesigns` + 구조물 폐회로 면적)
|
||||
* 입력 cross_refresh { detail · inputs: `RefreshInputs` · grid(서버 창구가 채움) }
|
||||
* 출력 { design_profiles · sections: [{ design · samples?(넓힘) }] · rows: 구조물 면적 줄 }
|
||||
* — 등록부 밖(`PAGE_IDS` · 파이썬 `calc_runner.PAGE_IDS`) · 화면 cross 단계 길
|
||||
* 구조물 표 = 서버 정의(`definition` · M02 반영) — 받으면 갈아 끼움(파이썬 `catalog_row` 와 같은 표).
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachShapes, SHAPE_KEYS, type AttachType } from "@util/common_util_structure_attach";
|
||||
import { useCatalogDefinition, type CatalogDefinition } from "@util/common_util_structure_catalog";
|
||||
import type { PlanPipe, PlanStructure } from "@util/common_util_structure_station";
|
||||
import {
|
||||
enforceSavedCross,
|
||||
type SaveCrossInput,
|
||||
} from "../../../B06_Section/logic/B06_Section_Cross_Save";
|
||||
import {
|
||||
refreshDesigns,
|
||||
type RefreshInputs,
|
||||
} from "../../../B06_Section/logic/B06_Section_Cross_Refresh_Calc";
|
||||
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { structureAreaRows } from "../../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import { gridAt } from "./design_calc_node_stages";
|
||||
|
||||
export interface CrossInputs {
|
||||
definition?: CatalogDefinition | null;
|
||||
attach?: {
|
||||
sections: Array<{ chainage_m: number; center_x?: number | null; center_y?: number | null }>;
|
||||
pipes: PlanPipe[];
|
||||
structures: PlanStructure[];
|
||||
types: AttachType[];
|
||||
};
|
||||
cross_enforce?: SaveCrossInput & { grid?: string | null };
|
||||
cross_refresh?: { detail: SectionDetailResponse; inputs: RefreshInputs; grid?: string | null };
|
||||
}
|
||||
|
||||
/** 등록부 밖 화면 길 id(`available` 에 안 넣음 · 파이썬 `PAGE_IDS` 와 같음). */
|
||||
export const PAGE_IDS = ["cross.refresh"];
|
||||
|
||||
let defined = "";
|
||||
|
||||
/** 서버 정의로 구조물 표를 갈아 끼움 — 같은 정의면 그대로(늘 떠 있는 Node · 요청마다 입구가 부름). */
|
||||
export function useDefinition(definition: CatalogDefinition | null | undefined): void {
|
||||
if (!definition) return;
|
||||
const key = JSON.stringify(definition);
|
||||
if (key === defined) return;
|
||||
useCatalogDefinition(definition);
|
||||
defined = key;
|
||||
}
|
||||
|
||||
const SHAPE_FIELDS = [...SHAPE_KEYS, "blank"];
|
||||
|
||||
export function crossRunners(inputs: CrossInputs): Record<string, () => unknown> {
|
||||
return {
|
||||
"facility.attach": () => {
|
||||
const attach = inputs.attach;
|
||||
if (!attach) throw new Error("형상 얹기 입력(attach) 없음");
|
||||
const sections = attach.sections.map((section) => ({ ...section })) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const misses = attachShapes(sections, attach.pipes, attach.structures, attach.types);
|
||||
const shapes = sections.map((section) =>
|
||||
Object.fromEntries(
|
||||
SHAPE_FIELDS.filter((key) => key in section).map((k) => [k, section[k]]),
|
||||
),
|
||||
);
|
||||
return { shapes, misses };
|
||||
},
|
||||
"cross.enforce": () => {
|
||||
const input = inputs.cross_enforce;
|
||||
if (!input) throw new Error("횡단 한 바퀴 입력(cross_enforce) 없음");
|
||||
return enforceSavedCross(input, () => (input.grid ? gridAt(input.grid) : null));
|
||||
},
|
||||
"cross.refresh": () => {
|
||||
const job = inputs.cross_refresh;
|
||||
if (!job) throw new Error("횡단 다시 셈 입력(cross_refresh) 없음");
|
||||
const { detail } = job;
|
||||
const before = detail.cross_sections.map((section) => section.samples);
|
||||
refreshDesigns(detail, job.inputs, job.grid ? gridAt(job.grid) : null);
|
||||
return {
|
||||
design_profiles: detail.longitudinal.design_profiles,
|
||||
sections: detail.cross_sections.map((section, index) => ({
|
||||
design: section.design,
|
||||
...(section.samples !== before[index] ? { samples: section.samples } : {}),
|
||||
})),
|
||||
rows: structureAreaRows(detail.cross_sections),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,12 +7,15 @@
|
||||
* base_pvi(옮기기 전) · base(옮긴 꼭짓점) · born · held } → 꼭짓점 목록(`seatBornNodes`)
|
||||
* facility.follow 입력 follow { items: [{ placement · chainage_m · start_m · end_m · x · y }] · line ·
|
||||
* max_drift_m · interval_m(site 맞춤 간격) } → [{ item · drift_m · far }](`followStructures`)
|
||||
* — follow.fill { previous?: { structure_id: 항목 } · only_missing } 있으면 = 저장 좌표 맞춤(같은 F1 ·
|
||||
* 511-B B-5) → { xy: [{ x · y } | null(그대로)] · filled }(`fillStructureCoordinates` · items 에 structure_id)
|
||||
* facility.plus_follow 입력 plus { rows: [{ chainage_m · x · y }] · line: [[x, y]](새 노선) } →
|
||||
* { rows: [{ chainage_m · x · y }](`followPlusRows` · 정수 m · 같은 m 하나) · moved: 옮긴 수 }
|
||||
* ========================================================================== */
|
||||
|
||||
import { seatBornNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Seat";
|
||||
import {
|
||||
fillStructureCoordinates,
|
||||
followPlusRows,
|
||||
followStructures,
|
||||
type FollowingStructure,
|
||||
@@ -27,10 +30,11 @@ const xyOf = (line: Line) => line.map(([x, y]) => ({ x, y }));
|
||||
|
||||
export interface FollowInputs {
|
||||
follow?: {
|
||||
items: FollowingStructure[];
|
||||
items: Array<FollowingStructure & { structure_id?: string | null }>;
|
||||
line: Line;
|
||||
max_drift_m?: number;
|
||||
interval_m?: number | null;
|
||||
fill?: { previous?: Record<string, FollowingStructure> | null; only_missing?: boolean };
|
||||
};
|
||||
plus?: { rows: Array<{ chainage_m: number; x?: number | null; y?: number | null }>; line: Line };
|
||||
seat?: {
|
||||
@@ -61,6 +65,14 @@ export function followRunners(inputs: FollowInputs): Record<string, () => unknow
|
||||
"facility.follow": () => {
|
||||
const follow = inputs.follow;
|
||||
if (!follow) return null;
|
||||
if (follow.fill)
|
||||
return fillStructureCoordinates(
|
||||
follow.items,
|
||||
xyOf(follow.line),
|
||||
follow.fill.previous ?? null,
|
||||
follow.fill.only_missing ?? false,
|
||||
follow.interval_m ?? null,
|
||||
);
|
||||
return followStructures(
|
||||
follow.items,
|
||||
xyOf(follow.line),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_profile.ts
|
||||
* 계산 Node 입구(`design_calc_node.ts`)의 종단 몫(PLAN 511-B B-3) — 첫 계획선 꼭짓점 · 계획선 짓기 ·
|
||||
* 노선 바뀜 계획고 옮김. 브라우저 묶음이 쓰는 같은 TS · 파이썬은 `B05_Profile_Engine_Grade_Node.py` 로
|
||||
* (늘 떠 있는 Node · 출력 = 반올림 전 값 · 정본 꼴 0.000001 m 는 파이썬이 적음).
|
||||
*
|
||||
* profile.base 입력 profile { chainage · ground(파이썬 `ground_profile`) · policy(`AlignmentPolicy.as_dict`) ·
|
||||
* ground_following { stations: [측점 m] · start_offset_m · end_offset_m } } →
|
||||
* 꼭짓점(`groundFollowingNodes` · 반올림 전) · 못 서는 땅 null(파이썬 2~4 순위)
|
||||
* profile.alignment 입력 profile { chainage · ground · stations: [{ station_id · chainage_m }] · policy ·
|
||||
* base_pvi(없으면 profile.base 출력) · edits · only_explicit_curves } → `buildAlignment`
|
||||
* profile.carry 입력 carry_profile { old_xy · new_xy · old_base · edits · stations · half_m ·
|
||||
* seat?{ ground_s · ground_z · policy · base_pvi · born } } →
|
||||
* { edits(`carryEdits`) · base_pvi(`seatRouteMoved` · 높이 고친 측점 = 옮긴 편집 열쇠) }
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
buildAlignment,
|
||||
emptyEdits,
|
||||
type AlignmentEdits,
|
||||
type AlignmentNode,
|
||||
type AlignmentPolicy,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { groundFollowingNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Ground";
|
||||
import { carryEdits } from "../../../B05_Profile/logic/B05_Profile_Grade_Carry";
|
||||
import { seatRouteMoved } from "../../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||
|
||||
type Line = Array<[number, number]>;
|
||||
const xyOf = (line: Line) => line.map(([x, y]) => ({ x, y }));
|
||||
|
||||
export interface ProfileInputs {
|
||||
profile?: {
|
||||
chainage: number[];
|
||||
ground: number[];
|
||||
policy: AlignmentPolicy;
|
||||
stations?: Array<{ station_id: string | null; chainage_m: number }>;
|
||||
base_pvi?: AlignmentNode[] | null;
|
||||
edits?: AlignmentEdits | null;
|
||||
only_explicit_curves?: boolean;
|
||||
ground_following?: { stations: number[]; start_offset_m?: number; end_offset_m?: number };
|
||||
};
|
||||
carry_profile?: {
|
||||
old_xy: Line;
|
||||
new_xy: Line;
|
||||
old_base: AlignmentNode[];
|
||||
edits?: Partial<AlignmentEdits> | null;
|
||||
stations: number[];
|
||||
half_m: number;
|
||||
seat?: {
|
||||
ground_s: number[];
|
||||
ground_z: number[];
|
||||
policy: AlignmentPolicy;
|
||||
base_pvi: AlignmentNode[];
|
||||
born: number[];
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음) — 계획선은 꼭짓점이 없으면 같은 요청의 첫 계획선 꼭짓점 위. */
|
||||
export function profileRunners(inputs: ProfileInputs): Record<string, () => unknown> {
|
||||
let nodes: AlignmentNode[] | null | undefined;
|
||||
const base = (): AlignmentNode[] | null => {
|
||||
const profile = inputs.profile;
|
||||
const follow = profile?.ground_following;
|
||||
if (nodes === undefined)
|
||||
nodes =
|
||||
profile && follow
|
||||
? groundFollowingNodes(profile.chainage, profile.ground, {
|
||||
stations: follow.stations,
|
||||
policy: profile.policy,
|
||||
startOffsetM: follow.start_offset_m ?? 0,
|
||||
endOffsetM: follow.end_offset_m ?? 0,
|
||||
})
|
||||
: null;
|
||||
return nodes;
|
||||
};
|
||||
return {
|
||||
"profile.base": base,
|
||||
"profile.alignment": () => {
|
||||
const profile = inputs.profile;
|
||||
const basePvi = profile?.base_pvi ?? base();
|
||||
if (!profile || !basePvi) return null;
|
||||
return buildAlignment(
|
||||
{
|
||||
policy: profile.policy,
|
||||
onlyExplicitCurves: profile.only_explicit_curves === true,
|
||||
basePvi,
|
||||
chainage: profile.chainage,
|
||||
ground: profile.ground,
|
||||
stations: profile.stations ?? [],
|
||||
},
|
||||
profile.edits ?? emptyEdits(),
|
||||
);
|
||||
},
|
||||
"profile.carry": () => {
|
||||
const carry = inputs.carry_profile;
|
||||
if (!carry) return null;
|
||||
const edits = carryEdits(
|
||||
xyOf(carry.old_xy),
|
||||
xyOf(carry.new_xy),
|
||||
carry.old_base,
|
||||
carry.edits,
|
||||
carry.stations,
|
||||
carry.half_m,
|
||||
);
|
||||
const seat = carry.seat;
|
||||
if (!seat) return { edits, base_pvi: null };
|
||||
const held = Object.keys(edits.station_offsets).map(Number);
|
||||
const base = {
|
||||
chainage: seat.ground_s,
|
||||
ground: seat.ground_z,
|
||||
policy: seat.policy,
|
||||
basePvi: seat.base_pvi,
|
||||
};
|
||||
return { edits, base_pvi: seatRouteMoved(base, seat.born, held) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_route.ts
|
||||
* 계산 Node 입구(`design_calc_node.ts`)의 노선 몫(PLAN 511-B B-5) — 화면 노선 편집과 같은 TS.
|
||||
*
|
||||
* route.polyline 입력 route_polyline { points · curve_flags · radii · locks · arcs · min_radius_m } →
|
||||
* { cleaned: 겹친 점(0.5 m) 뺀 노드 · nodes: 노드 요약(cleaned 자리) · vertices: 선 ·
|
||||
* curves: 곡선(node_first · last = cleaned 자리) · edits: 곡선표 편집표 }
|
||||
* — 서버 노선 저장 `_write_planned_polyline`(편집 갈래 · 파이썬은 파일 꼴 반올림만)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
buildEditedPolyline,
|
||||
DUPLICATE_TOLERANCE_M,
|
||||
type Vertex,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import { nodeEdits } from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits";
|
||||
|
||||
export interface RouteInputs {
|
||||
route_polyline?: {
|
||||
points: Vertex[];
|
||||
curve_flags?: Array<boolean | null> | null;
|
||||
radii?: Array<number | null> | null;
|
||||
locks?: Array<string | null> | null;
|
||||
arcs?: Array<number | null> | null;
|
||||
min_radius_m: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음). */
|
||||
export function routeRunners(inputs: RouteInputs): Record<string, () => unknown> {
|
||||
return {
|
||||
"route.polyline": () => {
|
||||
const at = inputs.route_polyline;
|
||||
if (!at) throw new Error("노선 입력(route_polyline) 없음");
|
||||
const flags = at.curve_flags ?? null;
|
||||
const radii = at.radii ?? null;
|
||||
// 겹친 점을 편집값과 함께 거름 — 곡선 자리 번호 = 저장 노드 자리(파이썬 `build_planned_polyline`)
|
||||
const cleaned: Vertex[] = [];
|
||||
const on: boolean[] = [];
|
||||
const forced: Array<number | null> = [];
|
||||
at.points.forEach((point, index) => {
|
||||
const last = cleaned[cleaned.length - 1];
|
||||
if (last && Math.hypot(point[0] - last[0], point[1] - last[1]) <= DUPLICATE_TOLERANCE_M)
|
||||
return;
|
||||
cleaned.push(point);
|
||||
on.push(flags && index < flags.length ? Boolean(flags[index]) : true);
|
||||
forced.push(radii && index < radii.length ? radii[index] : null);
|
||||
});
|
||||
const line = buildEditedPolyline(cleaned, on, forced, at.min_radius_m);
|
||||
return {
|
||||
cleaned,
|
||||
nodes: line.nodes,
|
||||
vertices: line.vertices,
|
||||
curves: line.curves,
|
||||
edits: nodeEdits(at.points, flags, radii, at.locks ?? null, at.arcs ?? null),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_sections.ts
|
||||
* 계산 Node 입구(`design_calc_node.ts`)의 측점 몫(PLAN 511-B B-2) — 화면 묶음 stations 단계와 같은 TS.
|
||||
*
|
||||
* stations.list 입력 stations { line · curves · pipes · structures · types · definition? · plus ·
|
||||
* interval_m · include_endpoint? · station_snap · station_snap_step } →
|
||||
* 측점 목록(`buildStations` · 열쇠 · 종류 · 반경 · 확폭 · 지반 null) — 계산 자리 「서버」 ·
|
||||
* 노선 저장 옛 노선 열쇠
|
||||
* stations.sections 같은 입력 + grid(DTM 격자 이진 경로) · 표본 간격 → 서버 `generate_sections` 꼴
|
||||
* (`generateSections`) — 서버 측점 생성 길만(창구 `/calc` 는 막음 · 경로 입력)
|
||||
* stations.plan 입력 stations { types · definition? · plan: [{ pipes · structures · plus }] } → 묶음마다
|
||||
* 측점 계획(`planStations`) — 서버 남은 부름(이월 열쇠 주인 · 옛 측점 옮기기 · 511-B B-5)
|
||||
* definition = 구조물 정의(`{types, …정의}` · 화면 `fetchStructureTypes` 와 같은 꼴) — 심는 측점 · 카드 이름 표.
|
||||
* ========================================================================== */
|
||||
|
||||
import { useCatalogDefinition, type CatalogDefinition } from "@util/common_util_structure_catalog";
|
||||
import {
|
||||
planStations,
|
||||
type PlanPipe,
|
||||
type PlanStructure,
|
||||
type PlanType,
|
||||
} from "@util/common_util_structure_station";
|
||||
import { buildStations } from "../../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import { generateSections } from "../../../B05_Profile/logic/B05_Profile_Sections_Generate";
|
||||
import type { DesignCurve, Vertex } from "./design_pipeline_types";
|
||||
import { gridAt } from "./design_calc_node_stages";
|
||||
|
||||
export interface SectionsInputs {
|
||||
stations?: {
|
||||
line: Vertex[];
|
||||
curves?: DesignCurve[];
|
||||
pipes?: PlanPipe[];
|
||||
structures?: PlanStructure[];
|
||||
types: PlanType[];
|
||||
definition?: CatalogDefinition | null;
|
||||
plus?: number[];
|
||||
interval_m?: number;
|
||||
include_endpoint?: boolean;
|
||||
station_snap?: boolean;
|
||||
station_snap_step?: number | null;
|
||||
grid?: string;
|
||||
cross_half_width_m?: number;
|
||||
cross_sample_interval_m?: number;
|
||||
long_sample_interval_m?: number;
|
||||
plan?: Array<{ pipes?: PlanPipe[]; structures?: PlanStructure[]; plus?: number[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음). */
|
||||
export function sectionsRunners(inputs: SectionsInputs): Record<string, () => unknown> {
|
||||
const args = () => {
|
||||
const at = inputs.stations;
|
||||
if (!at) throw new Error("측점 입력(stations) 없음");
|
||||
if (at.definition !== undefined) useCatalogDefinition(at.definition);
|
||||
const options = {
|
||||
intervalM: at.interval_m,
|
||||
includeEndpoint: at.include_endpoint,
|
||||
stationSnap: at.station_snap ?? false,
|
||||
stationSnapStep: at.station_snap_step ?? null,
|
||||
types: at.types,
|
||||
plus: at.plus ?? [],
|
||||
crossHalfWidthM: at.cross_half_width_m,
|
||||
crossSampleIntervalM: at.cross_sample_interval_m,
|
||||
longSampleIntervalM: at.long_sample_interval_m,
|
||||
};
|
||||
return { at, options };
|
||||
};
|
||||
return {
|
||||
"stations.list": () => {
|
||||
const { at, options } = args();
|
||||
return buildStations(at.line, at.curves ?? [], at.pipes ?? [], at.structures ?? [], options);
|
||||
},
|
||||
"stations.plan": () => {
|
||||
const { at } = args();
|
||||
return (at.plan ?? []).map((group) =>
|
||||
planStations(
|
||||
group.pipes ?? [],
|
||||
group.structures ?? [],
|
||||
at.types,
|
||||
undefined,
|
||||
group.plus ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
"stations.sections": () => {
|
||||
const { at, options } = args();
|
||||
if (!at.grid) throw new Error("DTM 격자 없음 — DTM 아닌 지표면은 파이썬 길");
|
||||
const grid = gridAt(at.grid);
|
||||
return generateSections(
|
||||
at.line,
|
||||
at.curves ?? [],
|
||||
at.pipes ?? [],
|
||||
at.structures ?? [],
|
||||
grid,
|
||||
options,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_stages.ts
|
||||
* 계산 Node 입구(`design_calc_node.ts`)의 단계 몫(PLAN 511-B B-1) — 페이지 묶음 단계(노선 · 따라감 · 측점 ·
|
||||
* 종단 · 횡단)를 서버 Node 가 **같은 단계 함수 · 같은 붙임**으로 셈. 한 요청 = 묶음 한 바퀴(앞 단계도 다시 셈) ·
|
||||
* id 여럿이면 그 바퀴 하나에서 꺼냄. 등록부 `node:` 밖(파이썬 `STAGE_IDS`) — 서버 정본 길은 B-2 ~ B-5 몫.
|
||||
*
|
||||
* 입력 stages { plan: 저장 노선(planned · curves · min_radius_m · station_snap · station_snap_step) ·
|
||||
* detail: 저장 상세(종단 기준 · 저장 PVI) · types: 구조물 정의 전부 · saved_pipes · saved_structures ·
|
||||
* saved_edits(노선 초안 없을 때 계획고 · 없으면 input.alignment) · saved_plus(추가 측점 정본 좌표) ·
|
||||
* before_draft(노선 초안 전 관 · 구조물 초안 · null 칸 = 저장분 · 없으면 input) ·
|
||||
* pipe_ids(관 세션 장부 `pipe-ids` 사본 · 종단 심는 측점 id · 없으면 새 차례) ·
|
||||
* interval_m · grid: { path: `dtm_grid_file` 이진 · design: las | sheet } ·
|
||||
* input: 묶음 입력(`PipelineInputs` · pipes · structures null = 저장분) }
|
||||
* 출력 stage.<단계> = 그 단계 출력(JSON 꼴 · `plain`)
|
||||
* 지반 = DTM 격자만(경로 · 수정 시각 기억) · 격자 없음 · 서버 표본 길 = 실패(DTM 아닌 지표면은 파이썬 길).
|
||||
* 붙임 = 페이지 `design_pipeline_page.ts` 와 같음 — 다른 점: 저장 노선 횡단도 엔진(페이지는 다시 셈 창구) ·
|
||||
* 소단 = 브라우저 값(계산 자리 대조 없음) · 되돌리기 사진 · 가게 메아리 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { bermSpansFromStructures, bermSpecAt } from "@util/common_util_berm_spans";
|
||||
import type { FollowingStructure } from "@util/common_util_route_follow";
|
||||
import { useCatalogDefinition, type CatalogDefinition } from "@util/common_util_structure_catalog";
|
||||
import type { PlanPipe, PlanStructure, PlanType } from "@util/common_util_structure_station";
|
||||
import { parseDtmGrid, type DtmGrid } from "@util/common_util_surface_sampler";
|
||||
import type { RoutePlanResponse } from "../../../B05_Profile/B05_Profile_Api_Replan";
|
||||
import {
|
||||
createFollowStage,
|
||||
createPlanlineStage,
|
||||
followSides,
|
||||
profileStage,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
|
||||
import { shapeStatus } from "../../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
import {
|
||||
createGroundStage,
|
||||
createRouteStage,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround";
|
||||
import { buildStations } from "../../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import {
|
||||
emptyEdits,
|
||||
toAlignmentBase,
|
||||
type AlignmentEdits,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { readAlignment } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import type {
|
||||
LongitudinalSection,
|
||||
SectionDetailResponse,
|
||||
} from "../../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { createCrossStage } from "../../../B06_Section/logic/B06_Section_Cross_Stage";
|
||||
import { pipeBook } from "../../../B06_Section/B06_Section_Route_Carry_Patch";
|
||||
import { plantedBase } from "./design_planted_base";
|
||||
import { AFTER_STAGES, BROWSER_STAGES, createDesignPipeline } from "./design_pipeline";
|
||||
import { contextCross, placedFollowInputs } from "./design_pipeline_stage_inputs";
|
||||
import { createStationsStage, snapOptions } from "./design_pipeline_stations";
|
||||
import type {
|
||||
PipelineInputs,
|
||||
PipelineStages,
|
||||
StageContext,
|
||||
StageName,
|
||||
Vertex,
|
||||
} from "./design_pipeline_types";
|
||||
import { plain } from "./design_calc_node_trial";
|
||||
|
||||
export interface StagesInputs {
|
||||
stages?: {
|
||||
plan: Pick<
|
||||
RoutePlanResponse,
|
||||
"planned" | "curves" | "min_radius_m" | "station_snap" | "station_snap_step"
|
||||
>;
|
||||
detail?: SectionDetailResponse | null;
|
||||
types: PlanType[];
|
||||
saved_pipes: PlanPipe[];
|
||||
saved_structures: PlanStructure[];
|
||||
saved_edits?: AlignmentEdits | null;
|
||||
saved_plus?: FollowingStructure[] | null;
|
||||
before_draft?: { pipes: unknown; structures: unknown } | null;
|
||||
pipe_ids?: Parameters<typeof pipeBook>[0];
|
||||
interval_m?: number;
|
||||
grid?: { path: string; design: "las" | "sheet" } | null;
|
||||
input: Partial<PipelineInputs>;
|
||||
/** 구조물 정의(서버 창구가 붙임 · 화면 `useCatalogDefinition` 꼴) — 측점 · 카드 이름 표. */
|
||||
definition?: CatalogDefinition | null;
|
||||
/** 저장 종단(계획고 편집 = `readAlignment(·).edits` · 세션 초안 `saved_edits` 없을 때 · 창구가 붙임). */
|
||||
longitudinal?: LongitudinalSection | null;
|
||||
/** "follow" = route · follow 두 단계만(격자 · 상세 안 씀 · 계산 자리 follow 「서버」 · 511-B B-5) —
|
||||
* stage.follow = { patch · plus · followed }(곁 자료 = 페이지가 다시 붙임). */
|
||||
until?: "follow";
|
||||
};
|
||||
}
|
||||
|
||||
/** 묶음 출력으로 내는 단계 다섯(판 줄과 같은 이름 · ground · planline 은 안에서만). */
|
||||
export const STAGE_NAMES = ["route", "follow", "stations", "profile", "cross"] as const;
|
||||
export const STAGE_IDS = STAGE_NAMES.map((name) => `stage.${name}`);
|
||||
|
||||
let gridMemo: { path: string; mtime: number; grid: DtmGrid } | null = null;
|
||||
|
||||
/** DTM 격자 이진 — 같은 경로 · 같은 수정 시각이면 앞에 읽은 것(늘 떠 있는 Node). */
|
||||
export function gridAt(path: string): DtmGrid {
|
||||
const mtime = statSync(path).mtimeMs;
|
||||
if (gridMemo?.path !== path || gridMemo.mtime !== mtime) {
|
||||
const bytes = readFileSync(path);
|
||||
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
gridMemo = { path, mtime, grid: parseDtmGrid(buffer as ArrayBuffer) };
|
||||
}
|
||||
return gridMemo.grid;
|
||||
}
|
||||
|
||||
async function runStages(
|
||||
stages: NonNullable<StagesInputs["stages"]>,
|
||||
): Promise<Record<StageName, unknown>> {
|
||||
const { plan, detail = null, types } = stages;
|
||||
const savedPipes = stages.saved_pipes;
|
||||
const savedStructures = stages.saved_structures;
|
||||
const enabled = types.filter((type) => (type as { enabled?: boolean }).enabled !== false);
|
||||
const grid = stages.grid ? { grid: gridAt(stages.grid.path), design: stages.grid.design } : null;
|
||||
const savedLine = (): Vertex[] => plan.planned.map(([x, y]): Vertex => [x, y]);
|
||||
const savedCurves = () =>
|
||||
(plan.curves ?? []).map(({ start, apex, end, radius_m }) => ({ start, apex, end, radius_m }));
|
||||
const inputs: PipelineInputs = {
|
||||
route: stages.input.route ?? null,
|
||||
alignment: stages.input.alignment ?? null,
|
||||
cross: stages.input.cross ?? null,
|
||||
pipes: stages.input.pipes ?? savedPipes, // 저장분 = 같은 객체(자리 맞춤이 초안과 가름)
|
||||
structures: stages.input.structures ?? savedStructures,
|
||||
plusStations: stages.input.plusStations ?? null,
|
||||
};
|
||||
const stored = detail && readAlignment(detail.longitudinal);
|
||||
if (stages.definition) useCatalogDefinition(stages.definition);
|
||||
// 페이지 `savedEdits` 짝 — 세션 초안 없으면 저장 종단 편집 · 없으면 빈 편집(창구가 longitudinal 을 붙인 때)
|
||||
const storedEdits = () =>
|
||||
stages.longitudinal === undefined
|
||||
? null
|
||||
: ((stages.longitudinal && readAlignment(stages.longitudinal)?.edits) ?? emptyEdits());
|
||||
const book = pipeBook(stages.pipe_ids ?? null);
|
||||
// 따라감 기준(페이지 `beforeDraft ?? 가게`) — 초안 전 관 · 구조물(null = 저장분)
|
||||
const before = stages.before_draft ?? stages.input;
|
||||
const followStage = createFollowStage({
|
||||
savedLine,
|
||||
savedPlus: () => stages.saved_plus ?? [],
|
||||
saved: () => ({
|
||||
pipes: before.pipes ?? savedPipes,
|
||||
structures: before.structures ?? savedStructures,
|
||||
alignment: stages.saved_edits ?? storedEdits() ?? inputs.alignment ?? emptyEdits(),
|
||||
}),
|
||||
stationsAt: (route, patch, at) =>
|
||||
buildStations(
|
||||
route.line,
|
||||
route.curves,
|
||||
(patch.pipes ?? at.pipes ?? []) as PlanPipe[],
|
||||
(patch.structures ?? at.structures ?? []) as PlanStructure[],
|
||||
{ intervalM: stages.interval_m, types: enabled, ...snapOptions(at.route, plan) },
|
||||
).map((station) => station.chainageM),
|
||||
});
|
||||
const crossStage = createCrossStage({ grid: () => grid });
|
||||
const table: PipelineStages = {
|
||||
route: createRouteStage({
|
||||
minRadiusM: () => plan.min_radius_m ?? 0,
|
||||
savedLine: () => ({ line: savedLine(), curves: savedCurves() }),
|
||||
}),
|
||||
follow: (ctx) =>
|
||||
followStage({
|
||||
...ctx,
|
||||
inputs: placedFollowInputs(ctx, savedLine(), {
|
||||
pipes: savedPipes,
|
||||
structures: savedStructures,
|
||||
}),
|
||||
}),
|
||||
stations: createStationsStage({
|
||||
intervalM: stages.interval_m,
|
||||
types: () => enabled,
|
||||
saved: () => plan,
|
||||
}),
|
||||
ground: createGroundStage({
|
||||
grid: () => grid,
|
||||
remote: () => Promise.reject(new Error("DTM 격자 없음 — 서버 표본 길은 파이썬")),
|
||||
}),
|
||||
planline: createPlanlineStage({
|
||||
savedBase: (at) =>
|
||||
plantedBase({ base: null, detail }, at, types, (pipes) =>
|
||||
pipes.map((pipe) => ({ ...pipe, id: book.at(pipe.chainage_m) })),
|
||||
),
|
||||
savedLine,
|
||||
storedPvi: () => (stored ? toAlignmentBase(stored).basePvi : null),
|
||||
}),
|
||||
profile: profileStage,
|
||||
cross: (ctx) => {
|
||||
const status = shapeStatus(
|
||||
ctx.inputs.pipes ?? savedPipes,
|
||||
ctx.inputs.structures ?? savedStructures,
|
||||
types,
|
||||
);
|
||||
const list = Array.isArray(ctx.inputs.structures) ? ctx.inputs.structures : [];
|
||||
const spans = bermSpansFromStructures(list);
|
||||
return crossStage.stage(contextCross(ctx, status, (at) => bermSpecAt(spans, at)));
|
||||
},
|
||||
};
|
||||
if (stages.until === "follow") return followOnly(table, inputs);
|
||||
const pipe = createDesignPipeline({ stages: table });
|
||||
try {
|
||||
const record = await pipe.start(inputs);
|
||||
await pipe.settled();
|
||||
if (record.outcome !== "done") throw new Error(`단계 묶음 ${record.outcome}`);
|
||||
return Object.fromEntries(
|
||||
STAGE_NAMES.map((name) => [name, plain(pipe.output(name))]),
|
||||
) as Record<StageName, unknown>;
|
||||
} finally {
|
||||
pipe.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** route · follow 두 단계만 — 묶음 첫 바퀴와 같은 맥락(앞 출력 없음 = 저장분에서 · 페이지 「다시 열기」 꼴). */
|
||||
async function followOnly(
|
||||
table: PipelineStages,
|
||||
inputs: PipelineInputs,
|
||||
): Promise<Record<StageName, unknown>> {
|
||||
const none = Object.fromEntries(
|
||||
[...BROWSER_STAGES, ...AFTER_STAGES].map((name) => [name, null]),
|
||||
) as StageContext<"route">["out"];
|
||||
const at = <K extends StageName>(out: StageContext<K>["out"]): StageContext<K> => ({
|
||||
generation: 1,
|
||||
range: null,
|
||||
signal: new AbortController().signal,
|
||||
inputs,
|
||||
out,
|
||||
before: none,
|
||||
prev: null,
|
||||
});
|
||||
const route = await table.route!(at<"route">(none));
|
||||
const follow = await table.follow!(at<"follow">({ ...none, route }));
|
||||
const out = { route: plain(route), follow: { patch: plain(follow), ...followSides(follow) } };
|
||||
return { stations: null, profile: null, cross: null, ...out } as Record<StageName, unknown>;
|
||||
}
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음 · `available` 에는 안 넣음) — 바퀴 하나를 id 들이 나눠 씀. */
|
||||
export function stageRunners(inputs: StagesInputs): Record<string, () => Promise<unknown>> {
|
||||
let run: Promise<Record<StageName, unknown>> | null = null;
|
||||
const once = () => {
|
||||
if (!inputs.stages) throw new Error("단계 입력(stages) 없음");
|
||||
return (run ??= runStages(inputs.stages));
|
||||
};
|
||||
return Object.fromEntries(
|
||||
STAGE_NAMES.map((name) => [`stage.${name}`, () => once().then((out) => out[name])]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_trial.ts
|
||||
* 계산 자리 「서버」 시험 길(PLAN 511-A) — 조작 중 브라우저가 부르는 함수를 서버 Node 가 **같은 TS 로** 부름.
|
||||
* 등록부 `node:` 실행기 밖(파이썬 `TRIAL_IDS` 와 같은 표) · 입력 = `inputs.args[id]`(그 함수 인자 그대로 · JSON).
|
||||
* masshaul.series E1 유토곡선 그리기(`computeMassHaulSeries`)
|
||||
* stations.carry_keyed R9 측점 열쇠 넘김(`carryKeyedStates` · 읽기 = 칸 이름 → 값 표)
|
||||
* route.handle R2 · route.station_fit R4 · stations.swap R8 · profile.tools P7 · cross.fit C7 · footprint.plan D4
|
||||
* Map · Set 은 목록으로(JSON) — 브라우저 대조도 같은 꼴(`design_calc_server.ts` `plain`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { computeMassHaulSeries } from "@util/common_util_mass_haul";
|
||||
import { footprintStage } from "../../../B05_Profile/logic/B05_Profile_Footprint_Stage";
|
||||
import { fitAfter } from "../../../B05_Profile/logic/B05_Profile_RouteEdit_StationFit";
|
||||
import { straightenBetween } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten";
|
||||
import { dragHandleTo } from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import {
|
||||
carryKeyedStates,
|
||||
type KeyedState,
|
||||
type Raw,
|
||||
} from "../../../B06_Section/logic/B06_Section_Route_Carry";
|
||||
import { swapCrossSections } from "../../../B06_Section/logic/B06_Section_Station_Swap";
|
||||
import { toeOffsets } from "../../../B06_Section/logic/B06_Section_UI_Cross_Fit";
|
||||
import type { StageContext } from "./design_pipeline_types";
|
||||
|
||||
export interface TrialInputs {
|
||||
args?: Record<string, unknown[]>;
|
||||
}
|
||||
|
||||
type Call = (...args: never[]) => unknown;
|
||||
|
||||
/** Map · Set → 목록 — JSON 으로 오가는 꼴. */
|
||||
export function plain(value: unknown): unknown {
|
||||
return JSON.parse(
|
||||
JSON.stringify(value, (_key, item: unknown) =>
|
||||
item instanceof Map ? [...item.entries()] : item instanceof Set ? [...item] : item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const TRIAL: Record<string, Call> = {
|
||||
"masshaul.series": computeMassHaulSeries as Call,
|
||||
"stations.carry_keyed": ((states: Record<string, Raw>, from, to) =>
|
||||
carryKeyedStates((name: KeyedState) => states[name] ?? {}, from, to)) as Call,
|
||||
"route.handle": dragHandleTo as Call,
|
||||
"route.station_fit": fitAfter as Call, // 편집 한 걸음 뒤 맞춤(fitCurveStations 를 부름)
|
||||
"stations.swap": swapCrossSections as Call,
|
||||
"profile.tools": straightenBetween as Call,
|
||||
"cross.fit": toeOffsets as Call,
|
||||
// 발자국 단계 — 앞 결과 없이 전 구간(브라우저 범위 밖 재사용과 같은 값)
|
||||
"footprint.plan": ((out: StageContext<"footprint">["out"]) =>
|
||||
footprintStage({ out, range: null, prev: null } as StageContext<"footprint">)) as Call,
|
||||
};
|
||||
|
||||
export const TRIAL_IDS = Object.keys(TRIAL);
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음 · `available` 에는 안 넣음). */
|
||||
export function trialRunners(inputs: TrialInputs): Record<string, () => unknown> {
|
||||
return Object.fromEntries(
|
||||
TRIAL_IDS.map((id) => [id, () => plain(TRIAL[id](...((inputs.args?.[id] ?? []) as never[])))]),
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,6 @@
|
||||
* ts — TS 셈 자리 `<경로>#<함수>` · 없으면 null
|
||||
* runner — 실행기: `node:<id>`(서버 Node 입구) · `py:<경로>#<함수>`(지금 파이썬) ·
|
||||
* `browser:<경로>#<함수>`(browser_only 만)
|
||||
* until — "472-4" = 파이썬 실행기는 472-4 짝 모으기 동안만(그 뒤 node:)
|
||||
* pair — 파이썬 짝 `<경로>#<함수>` · 짝 아니면 null
|
||||
* mirror — 거울 · 단위 시험(`resources/tester` 파일 이름 · .py 뺌)
|
||||
*
|
||||
@@ -41,7 +40,6 @@ export interface CalcEntry {
|
||||
stage: StageName | null;
|
||||
ts: string | null;
|
||||
runner: string;
|
||||
until?: "472-4";
|
||||
pair: string | null;
|
||||
mirror: string[];
|
||||
note?: string;
|
||||
@@ -64,11 +62,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "route",
|
||||
ts: `${B05L}B05_Profile_UI_RouteEdit_Curve.ts#buildEditedPolyline`,
|
||||
runner: `py:${U}common_util_route_polyline.py#build_planned_polyline`,
|
||||
until: "472-4",
|
||||
runner: "node:route.polyline",
|
||||
pair: `${U}common_util_route_polyline.py#build_planned_polyline`,
|
||||
mirror: ["test_route_polyline_browser_mirror"],
|
||||
note: "파이썬 짝은 추천 엔진 · B04 배수 · 노선 확인이 부름 — 짝 남음",
|
||||
mirror: ["test_route_polyline_browser_mirror", "test_511_b5_route_node"],
|
||||
note: "서버 노선 저장(편집 갈래 · `_write_planned_polyline`) = Node · 파이썬 짝은 첫 폴리라인(단순화) · 추천 엔진 · B04 배수 · 노선 열기가 부름 — 짝 남음 · 알려진 차 = 교각 딱 5° 배수면 원호 점 ±1(libm 끝자리 · 실데이터 0 차)",
|
||||
},
|
||||
{
|
||||
id: "route.handle",
|
||||
@@ -92,10 +89,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "route",
|
||||
ts: `${B05L}B05_Profile_UI_RouteEdit_Edits.ts#applyCurveLimits`,
|
||||
runner: `py:${B05}B05_Profile_Route_NodeEdits.py#node_edits`,
|
||||
until: "472-4",
|
||||
runner: "node:route.polyline",
|
||||
pair: `${B05}B05_Profile_Route_NodeEdits.py#node_edits`,
|
||||
mirror: ["test_430_route_edit_rules"],
|
||||
mirror: ["test_430_route_edit_rules", "test_511_b5_route_node"],
|
||||
note: "곡선표 편집표 = Node `route.polyline` 의 `nodeEdits` · 파이썬 짝 = Node 못 돌릴 때",
|
||||
},
|
||||
{
|
||||
id: "route.station_fit",
|
||||
@@ -121,10 +118,14 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "stations",
|
||||
ts: `${B05L}B05_Profile_UI_RouteEdit_Frame.ts#stationFrameAt`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`,
|
||||
until: "472-4",
|
||||
runner: "node:stations.list",
|
||||
pair: `${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`,
|
||||
mirror: ["test_b05_routeedit_frame_mirror", "test_224_2_station_snap"],
|
||||
mirror: [
|
||||
"test_b05_routeedit_frame_mirror",
|
||||
"test_224_2_station_snap",
|
||||
"test_511_b2_stations_node",
|
||||
],
|
||||
note: "서버 측점 생성 = Node `stations.sections`(DTM) · 파이썬 짝 = DTM 아닌 지표면 · 추천 엔진 · B06 측점 맞춤",
|
||||
},
|
||||
{
|
||||
id: "stations.list",
|
||||
@@ -143,11 +144,14 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "stations",
|
||||
ts: `${B05L}B05_Profile_Sections_Stations.ts#buildStations`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`,
|
||||
until: "472-4",
|
||||
runner: "node:stations.list",
|
||||
pair: `${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`,
|
||||
mirror: ["test_344_3_pipeline_stations_mirror", "test_407_curve_station"],
|
||||
note: "파이썬 짝은 B03 체인 · B07 IP 표 · 추천도 부름",
|
||||
mirror: [
|
||||
"test_344_3_pipeline_stations_mirror",
|
||||
"test_407_curve_station",
|
||||
"test_511_b2_stations_node",
|
||||
],
|
||||
note: "서버 측점 생성 길(B03 체인 · 노선 저장 · 재생성 · 추천 초기 종단) = Node `stations.sections`(DTM) · 파이썬 짝 = DTM 아닌 지표면 · B07 IP 표 · 추천 엔진",
|
||||
},
|
||||
{
|
||||
id: "stations.key",
|
||||
@@ -158,11 +162,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "stations",
|
||||
ts: `${U}common_util_station_key.ts#stationKey`,
|
||||
runner: `py:${U}common_util_station_key.py#station_key`,
|
||||
until: "472-4",
|
||||
runner: "node:stations.list",
|
||||
pair: `${U}common_util_station_key.py#station_key`,
|
||||
mirror: ["test_344_3_station_key_mirror"],
|
||||
note: "서버 열쇠(Route_Save_Parts.station_keys)는 주인 = 파이썬 plan_stations(F3 · 카탈로그) · 부르는 곳 = 노선 저장 이월(③) — ③ · F3 와 같이 Node 로",
|
||||
mirror: ["test_344_3_station_key_mirror", "test_511_b2_stations_node"],
|
||||
note: "노선 저장 이월 열쇠 = Node 측점 출력(`key` · 옛 노선 = `stations.list`) · 파이썬 짝 = 옛 열쇠 꼴 읽기(at: · 누가거리)",
|
||||
},
|
||||
{
|
||||
id: "stations.swap",
|
||||
@@ -203,10 +206,9 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
stage: "ground",
|
||||
ts: `${U}common_util_surface_sampler.ts#heightAt`,
|
||||
runner: `py:${U}common_util_surface_sampler.py#build_surface_sampler`,
|
||||
until: "472-4",
|
||||
pair: `${U}common_util_surface_sampler.py#build_surface_sampler`,
|
||||
mirror: ["test_344_1_surface_sampler_mirror"],
|
||||
note: "X2 — 브라우저 격자 .bin · 서버 npz(값 협의)",
|
||||
mirror: ["test_344_1_surface_sampler_mirror", "test_511_b2_stations_node"],
|
||||
note: "서버 측점 생성(DTM) = Node `stations.sections` 격자 표본 · 파이썬 표본기 남김 = DTM 아닌 지표면 · 조작 중 「서버」 표본 창구 · B04 · B07 · 추천 · X2 = 브라우저 격자 .bin · 서버 npz(값 협의)",
|
||||
},
|
||||
{
|
||||
id: "ground.extend",
|
||||
@@ -217,10 +219,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "cross",
|
||||
ts: `${B05L}B05_Profile_Sections_Ground.ts#extendUnclosed`,
|
||||
runner: `py:${B06}B06_Section_Engine_SampleExtend.py#extend_unclosed`,
|
||||
until: "472-4",
|
||||
runner: "node:cross.enforce",
|
||||
pair: `${B06}B06_Section_Engine_SampleExtend.py#extend_unclosed`,
|
||||
mirror: ["test_344_1_sections_ground_mirror"],
|
||||
mirror: ["test_344_1_sections_ground_mirror", "test_511_b4_cross_save_mirror"],
|
||||
note: "[저장] = Node cross.enforce 안(DTM 격자 · 511-B B-4) · DTM 아닌 지표면 = 파이썬 짝(표본기)",
|
||||
},
|
||||
{
|
||||
id: "ground.widen",
|
||||
@@ -245,10 +247,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "cross",
|
||||
ts: `${B05L}B05_Profile_Pipeline_Detail.ts#uphillSide`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`,
|
||||
until: "472-4",
|
||||
runner: "node:stations.sections",
|
||||
pair: `${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`,
|
||||
mirror: ["test_346_2_pipeline_cross_page"],
|
||||
mirror: ["test_346_2_pipeline_cross_page", "test_511_b2_stations_node"],
|
||||
note: "서버 측점 생성(DTM) = Node `stations.sections` 의 상단측 · DTM 아닌 지표면 = 파이썬 짝",
|
||||
},
|
||||
/* ── 종단 ─────────────────────────────────────────────────────────────── */
|
||||
{
|
||||
@@ -260,11 +262,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "planline",
|
||||
ts: `${B05L}B05_Profile_Grade_Ground.ts#groundFollowingBasePvi`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_Profile.py#rebuild_alignment_profile`,
|
||||
until: "472-4",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Profile.py#rebuild_alignment_profile`,
|
||||
mirror: ["test_344_2_route_pairs", "test_432_1_curve_law"],
|
||||
note: "파이썬 짝은 추천 엔진(RouteInitial_Profile)도 씀 — 짝 남음",
|
||||
runner: "node:profile.base",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Profile.py#straight_pass_through`,
|
||||
mirror: ["test_344_2_route_pairs", "test_432_1_curve_law", "test_511_b3_profile_node"],
|
||||
note: "1순위 = Node(511-B B-3) · 못 서면 파이썬 2~4 순위(관 정착 · 직선 분할 · 정함) · 파이썬 짝 = 추천 엔진(RouteInitial_Profile) 몫",
|
||||
},
|
||||
{
|
||||
id: "profile.alignment",
|
||||
@@ -275,15 +276,16 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "profile",
|
||||
ts: `${B05L}B05_Profile_UI_Profile_Alignment.ts#buildAlignment`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_Alignment.py#evaluate`,
|
||||
until: "472-4",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Alignment.py#evaluate`,
|
||||
runner: "node:profile.alignment",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Alignment.py#build_alignment`,
|
||||
mirror: [
|
||||
"test_426_profile_straight",
|
||||
"test_432_1_curve_law",
|
||||
"test_213_1_hidden_station_pvi",
|
||||
"test_438_1_initial_straighten",
|
||||
"test_511_b3_profile_node",
|
||||
],
|
||||
note: "정본 꼴(0.000001 m) = 파이썬 `Engine_Grade_Node.alignment_record` · 파이썬 짝 = 2~4 순위 폴백 몫",
|
||||
},
|
||||
{
|
||||
id: "profile.curve_law",
|
||||
@@ -294,8 +296,7 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "profile",
|
||||
ts: `${B05L}B05_Profile_Profile_CurveLaw.ts#flatBends`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_CurveLaw.py#flat_bends`,
|
||||
until: "472-4",
|
||||
runner: "node:profile.alignment",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_CurveLaw.py#flat_bends`,
|
||||
mirror: ["test_432_1_curve_law"],
|
||||
},
|
||||
@@ -308,8 +309,7 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "profile",
|
||||
ts: `${B05L}B05_Profile_Profile_Straight.ts#isStraightBend`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_Alignment.py#is_straight_bend`,
|
||||
until: "472-4",
|
||||
runner: "node:profile.alignment",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Alignment.py#is_straight_bend`,
|
||||
mirror: ["test_426_profile_straight", "test_271_straighten_pick"],
|
||||
},
|
||||
@@ -322,10 +322,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "follow",
|
||||
ts: `${B05L}B05_Profile_Grade_Carry.ts#carryEdits`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade_Carry.py#carry_edits`,
|
||||
until: "472-4",
|
||||
pair: `${B05}B05_Profile_Engine_Grade_Carry.py#carry_edits`,
|
||||
mirror: ["test_400_profile_carry", "test_349_0v_route_save_span"],
|
||||
runner: "node:profile.carry",
|
||||
pair: null,
|
||||
mirror: ["test_400_profile_carry", "test_349_0v_route_save_span", "test_511_b3_profile_node"],
|
||||
note: "노선 [저장] = 꼭짓점 세움(`seatRouteMoved`)과 Node 한 부름(`Engine_Grade_Node.carry_seat`)",
|
||||
},
|
||||
{
|
||||
id: "profile.seat",
|
||||
@@ -364,11 +364,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: null,
|
||||
ts: `${B05L}B05_Profile_Profile_CurveLaw.ts#verticalCurveShortfalls`,
|
||||
runner: `py:${B05}B05_Profile_Engine_Grade.py#resolve_grade_options`,
|
||||
until: "472-4",
|
||||
runner: "node:profile.alignment",
|
||||
pair: `${B05}B05_Profile_Engine_Grade.py#resolve_grade_options`,
|
||||
mirror: ["test_442_2_grade_limit_mirror"],
|
||||
note: "X5 — 기준표 두 벌(화면 PROFILE_CRITERIA · 서버 FOREST_ROAD_PROFILE_CRITERIA)",
|
||||
note: "경고 = 계획선 짓기 안 · 기준표 TS = `logic/B05_Profile_Grade_Criteria.ts`(X5 · 파이썬 표는 추천 · 폴백 몫 · 거울 442_2)",
|
||||
},
|
||||
/* ── 횡단 ─────────────────────────────────────────────────────────────── */
|
||||
{
|
||||
@@ -408,11 +407,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "cross",
|
||||
ts: `${B06L}B06_Section_Cross_Enforce.ts#facilityRanges`,
|
||||
runner: `py:${B06}B06_Section_Router_Design.py#enforce_design_lines`,
|
||||
until: "472-4",
|
||||
runner: "node:cross.enforce",
|
||||
pair: `${B06}B06_Section_Router_Design.py#enforce_design_lines`,
|
||||
note: "구간 판정 = 파이썬 짝(Router_Design_Ranges) · 다시 세운 설계 = Node cross.design",
|
||||
mirror: ["test_349_0n_cross_enforce_mirror"],
|
||||
note: "[저장] 횡단 한 바퀴 = Node cross.enforce · 기본 설계 구간 = cross.design facilities(511-B B-4) · 파이썬 짝 = DTM 아닌 지표면 · Node 못 부를 때",
|
||||
mirror: ["test_349_0n_cross_enforce_mirror", "test_511_b4_cross_save_mirror"],
|
||||
},
|
||||
{
|
||||
id: "cross.berm_spans",
|
||||
@@ -451,11 +449,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "cross",
|
||||
ts: `${U}common_util_structure_underdrain.ts#attachUnderdrainSpecs`,
|
||||
runner: `py:${B06}B06_Section_Engine_Tilt.py#attach_underdrains`,
|
||||
until: "472-4",
|
||||
pair: `${B06}B06_Section_Engine_Tilt.py#attach_underdrains`,
|
||||
runner: "node:facility.attach",
|
||||
pair: `${B06}B06_Section_Engine_Tilt.py#attach_underdrain_specs`,
|
||||
mirror: ["test_472_4_underdrain_mirror"],
|
||||
note: "얹기 = GET 상세(파이썬) · 알려진 차 = 옛 underdrain 걷음 TS 만 · 기울임은 cross.design 안",
|
||||
note: "얹기 = GET 상세 Node facility.attach 안(511-B B-4) · 파이썬 짝 = Node 못 부를 때(`_attach_python`) · 알려진 차 = 옛 underdrain 걷음 TS 만 · 기울임은 cross.design 안",
|
||||
},
|
||||
{
|
||||
id: "cross.fit",
|
||||
|
||||
@@ -30,8 +30,9 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
|
||||
"test_459_route_seat_mirror",
|
||||
"test_380_3_site_follow",
|
||||
"test_472_4_follow_node",
|
||||
"test_511_b5_route_node",
|
||||
],
|
||||
note: "파이썬 `common_util_route_follow.follow_structures` = Node 부름 겉 · 저장 때 좌표 맞춤 `fill_structure_coordinates` 는 파이썬 남음(옛 좌표 지킴 · 0.1 mm 반올림 — TS `placeOnLine` 과 규칙 다름)",
|
||||
note: "파이썬 `common_util_route_follow.follow_structures` = Node 부름 겉 · 저장 때 좌표 맞춤 `fill_structure_coordinates` = 같은 Node 입구 + fill(`fillStructureCoordinates` · 옛 좌표 지킴 · 0.1 mm · `placeOnLine` 노선 위 자리와 한 벌 · 511-B B-5)",
|
||||
},
|
||||
{
|
||||
id: "facility.plus_follow",
|
||||
@@ -70,11 +71,10 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "stations",
|
||||
ts: `${U}common_util_structure_station.ts#planStations`,
|
||||
runner: `py:${U}common_util_structure_station.py#plan_stations`,
|
||||
until: "472-4",
|
||||
runner: "node:stations.plan",
|
||||
pair: `${U}common_util_structure_station.py#plan_stations`,
|
||||
mirror: ["test_322_3_plan_mirror", "test_234_1_structure_station"],
|
||||
note: "파이썬 짝은 B07 · 배수가 넓게 부름",
|
||||
mirror: ["test_322_3_plan_mirror", "test_234_1_structure_station", "test_511_b5_route_node"],
|
||||
note: "서버 측점 생성 = Node `stations.sections` · 남은 부름(이월 열쇠 주인 · 옛 측점 옮기기) = Node `stations.plan` · 파이썬 짝은 B06 형상 얹기 · B07 · 배수 · Node 못 돌릴 때",
|
||||
},
|
||||
{
|
||||
id: "facility.attach",
|
||||
@@ -85,10 +85,10 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
|
||||
site: "browser_now",
|
||||
stage: "cross",
|
||||
ts: `${U}common_util_structure_attach.ts#attachShapes`,
|
||||
runner: `py:${B06}B06_Section_Engine_Attach.py#attach_shapes`,
|
||||
until: "472-4",
|
||||
runner: "node:facility.attach",
|
||||
pair: `${B06}B06_Section_Engine_Attach.py#attach_shapes`,
|
||||
mirror: ["test_323_4_attach_mirror"],
|
||||
note: "GET 상세 · 재생성 = Node facility.attach(511-B B-4) · 파이썬 짝 = Node 못 부를 때",
|
||||
},
|
||||
{
|
||||
id: "facility.culvert_sets",
|
||||
@@ -141,9 +141,9 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
|
||||
stage: null,
|
||||
ts: `${U}common_util_excavation.ts#wallTrenchAreaM2`,
|
||||
runner: "node:excavation.trench",
|
||||
pair: `${U}common_util_excavation.py#wall_trench_area_m2`,
|
||||
pair: null,
|
||||
mirror: ["test_excavation_mirror"],
|
||||
note: "파이썬 짝 운영 부름 0 — 걷음 후보(472-7)",
|
||||
note: "서버 = Node 한 벌 · 파이썬 짝 운영 부름 0 → 걷음(511-B B-6)",
|
||||
},
|
||||
{
|
||||
id: "facility.ford",
|
||||
@@ -185,8 +185,8 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
|
||||
ts: `${U}common_util_mass_haul_balance.ts#computeHaulPlan`,
|
||||
runner: "node:haul.plan",
|
||||
pair: null,
|
||||
mirror: [],
|
||||
note: "브라우저 번들 밖(/haul-plan 요청) · 브라우저 실행 여부 협의",
|
||||
mirror: ["test_511_c_haul_node"],
|
||||
note: "기본 /haul-plan(늘 떠 있는 Node haul.view) · 판 haul both = 브라우저 dynamic import(511-C)",
|
||||
},
|
||||
/* ── 배수 · 3D · 추천 ────────────────────────────────────────────────── */
|
||||
{
|
||||
@@ -212,10 +212,10 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
|
||||
site: "server_only",
|
||||
stage: null,
|
||||
ts: null,
|
||||
runner: `py:${U}common_util_drainage_detail.py#suggest_pipe_positions`,
|
||||
runner: `py:${U}common_util_stream_crossing.py#propose_pipes`,
|
||||
pair: null,
|
||||
mirror: [],
|
||||
note: "TS 는 모으기만(B05_Profile/logic/B05_Profile_Drainage_Suggest.ts)",
|
||||
note: "[자동 제안](510) — TS 는 후보 그리기 · 고름만(B05_Profile/B05_Profile_UI_Drainage_Propose.ts)",
|
||||
},
|
||||
{
|
||||
id: "corridor.mesh",
|
||||
|
||||
@@ -83,6 +83,8 @@ export interface DesignPipeline {
|
||||
value: unknown,
|
||||
part?: RangePart | null,
|
||||
): Promise<PipelineRunRecord> | null;
|
||||
/** 입력 그대로 그 출처부터 다시 셈 — 늦게 온 서버 값(계산 자리 follow 「서버」 · 511-B B-5). */
|
||||
refresh(source: ChangeSource): Promise<PipelineRunRecord> | null;
|
||||
/** `name` = 기록 `work` 칸 이름(알림 받는 쪽 몫 ms · 349-0s). */
|
||||
subscribe(fn: (event: PipelineEvent) => void, name?: string): () => void;
|
||||
output<K extends StageName>(name: K): PipelineOutputs[K] | null;
|
||||
@@ -353,6 +355,11 @@ export function createDesignPipeline(options: { stages: PipelineStages }): Desig
|
||||
: { sources: [source], fromM: 0, toM: Infinity, keys: [] };
|
||||
return begin(range);
|
||||
},
|
||||
refresh(source) {
|
||||
if (busy) return null;
|
||||
inputVer[SOURCE_INPUT[source]] += 1;
|
||||
return begin({ sources: [source], fromM: 0, toM: Infinity, keys: [] });
|
||||
},
|
||||
subscribe(fn, name = "알림") {
|
||||
listeners.set(fn, name);
|
||||
return () => listeners.delete(fn);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_pipeline_stage_inputs.ts
|
||||
* 페이지 묶음 follow · cross 단계 입력 맞춤(PLAN 511-B B-1) — 페이지(`design_pipeline_page.ts`)와 서버 Node
|
||||
* 단계 입구(`design_calc_node_stages.ts`)가 같은 함수로 단계에 넣음. 화면 · 가게 · 세션 안 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
placedOnLine as placed,
|
||||
type FollowingStructure,
|
||||
} from "../../../common_util/common_util_route_follow";
|
||||
import {
|
||||
crossWithContext,
|
||||
type BermAt,
|
||||
type CrossInput,
|
||||
type ShapeStatus,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
import { SECTION_STATION_INTERVAL_M } from "../../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import type { PipelineInputs, StageContext, Vertex } from "./design_pipeline_types";
|
||||
|
||||
/** follow 입력 관 · 구조물 자리 — 좌표 없는 관 = 앞 따라감(같은 id · 같은 자리) → 정본 좌표 · 옮긴 것 = 그 자리의
|
||||
* 앞 노선 위(410). 자리를 읽을 선 — 초안 둔 채 열기(앞 노선 없음)의 초안 값(저장분과 다른 객체) = 이미 초안
|
||||
* 노선 자리(앞 따라감이 씀). */
|
||||
export function placedFollowInputs(
|
||||
ctx: StageContext<"follow">,
|
||||
savedLine: Vertex[] | null,
|
||||
saved: { pipes: unknown; structures: unknown },
|
||||
): PipelineInputs {
|
||||
const line = ctx.before.route?.line ?? savedLine ?? [];
|
||||
const frame = (list: unknown, stored: unknown) =>
|
||||
!ctx.before.route && ctx.inputs.route && list !== stored ? (ctx.out.route?.line ?? line) : line;
|
||||
const { pipes, structures } = ctx.inputs;
|
||||
const followed = (ctx.before.follow?.pipes ?? []) as FollowingStructure[];
|
||||
return {
|
||||
...ctx.inputs,
|
||||
pipes: placed(pipes, frame(pipes, saved.pipes), [
|
||||
...(saved.pipes as FollowingStructure[]),
|
||||
...followed,
|
||||
]),
|
||||
structures: placed(
|
||||
structures,
|
||||
frame(structures, saved.structures),
|
||||
[],
|
||||
SECTION_STATION_INTERVAL_M,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** cross 단계 입력 = 시설 맥락(새 자리 그림) · 단면 유형 기본값(상단측) · 소단(지금 구조물) 얹음(365-5 · 457). */
|
||||
export const contextCross = (
|
||||
ctx: StageContext<"cross">,
|
||||
status: ShapeStatus | null,
|
||||
bermAt: BermAt | null,
|
||||
): StageContext<"cross"> => ({
|
||||
...ctx,
|
||||
inputs: {
|
||||
...ctx.inputs,
|
||||
cross: crossWithContext(
|
||||
ctx.inputs.cross as CrossInput | null,
|
||||
ctx.out.ground?.stations ?? [],
|
||||
status,
|
||||
bermAt,
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -61,18 +61,27 @@ export function snapOptions(
|
||||
};
|
||||
}
|
||||
|
||||
/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선). */
|
||||
/** `buildStations` 인자 한 벌 — 화면 셈 · 서버 셈(계산 자리 「서버」)이 같은 값을 받음. */
|
||||
export type StationsArgs = Parameters<typeof buildStations>;
|
||||
|
||||
/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 스냅(초안 · 없으면 저장 노선).
|
||||
* `remote` = 계산 자리 「서버」면 서버 Node 값(같은 `buildStations` · 511-B B-2) · null = 화면 셈 · 서버가
|
||||
* 못 내면(끊김 · 실패) 화면 셈. */
|
||||
export function createStationsStage(
|
||||
options: Omit<StationsOptions, "stationSnap" | "stationSnapStep" | "types"> & {
|
||||
types: () => ReadonlyArray<PlanType>;
|
||||
saved?: () => SavedSnap | null;
|
||||
remote?: (
|
||||
args: StationsArgs,
|
||||
signal: AbortSignal,
|
||||
) => Promise<PipelineStation[] | undefined> | null;
|
||||
},
|
||||
): StageFn<"stations"> {
|
||||
const { saved, ...rest } = options;
|
||||
const { saved, remote, ...rest } = options;
|
||||
return (ctx: StageContext<"stations">) => {
|
||||
const route = ctx.out.route;
|
||||
if (!route) return [];
|
||||
const next = buildStations(
|
||||
const args: StationsArgs = [
|
||||
route.line,
|
||||
route.curves,
|
||||
(ctx.inputs.pipes as PlanPipe[] | null) ?? [],
|
||||
@@ -83,8 +92,11 @@ export function createStationsStage(
|
||||
...snapOptions(ctx.inputs.route, saved?.()),
|
||||
plus: ctx.inputs.plusStations ?? undefined, // follow 가 옮긴 추가 측점(459 · 가게보다 먼저)
|
||||
},
|
||||
);
|
||||
return reuseOutside(next, ctx.prev, ctx.range);
|
||||
];
|
||||
const done = (next: PipelineStation[] | undefined) =>
|
||||
reuseOutside(next ?? buildStations(...args), ctx.prev, ctx.range);
|
||||
const asked = remote?.(args, ctx.signal);
|
||||
return asked ? asked.then(done) : done(undefined); // 화면 셈 = 동기 그대로
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_planted_base.ts
|
||||
* 묶음 planline 의 저장 노선 종단 기준(PLAN 386-3) 셈 몫 — 화면 · 가게 · 세션 없는 순수(511-B B-3 · 서버 Node
|
||||
* 묶음 `design_calc_node_stages.ts` 도 같은 셈). 페이지 겉 = `design_pipeline_base.ts`(관 id = 세션 장부) ·
|
||||
* `design_profile_base.ts` · `design_structure_status.ts` 가 다시 내보냄.
|
||||
*
|
||||
* 종단도 `listed()`(design_profile)와 같은 셈 — 저장 상세 측점 + 구조물 · 관이 심는 측점(`listedBase`).
|
||||
* 심는 측점 = 종단 보기 구조물 칸(`syncCrossDrainStations`) · 횡단 보기(`planMarkStations`)와 같은
|
||||
* `planStations` → `plannedIrregular` — 보기가 종단도에 늦게 심어도 묶음은 다시 안 돎.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { AlignmentBase } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { toAlignmentBase } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import type { IrregularStation } from "../../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
irregularGraphStations,
|
||||
maxChainageOf,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_Profile_Planted";
|
||||
import {
|
||||
followMovedNodes,
|
||||
oneStationList,
|
||||
planStations,
|
||||
type PlannedStation,
|
||||
type PlanType,
|
||||
} from "../../../common_util/common_util_structure_station";
|
||||
import { readAlignment } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import { seatBornNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Seat";
|
||||
import type { PipelineInputs } from "./design_pipeline_types";
|
||||
|
||||
/**
|
||||
* 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` ·
|
||||
* 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 =
|
||||
* 「배관」 꼴(관 끌기 · 지우기 길).
|
||||
*/
|
||||
export function plannedIrregular(
|
||||
planned: ReadonlyArray<PlannedStation>,
|
||||
intervalM: number,
|
||||
): IrregularStation[] {
|
||||
const interval = intervalM > 0 ? intervalM : 20;
|
||||
return planned.map((station) => {
|
||||
const owner = station.owners[0];
|
||||
const pipe = owner.startsWith("pipe-") && station.role === "anchor";
|
||||
const number = Math.floor(station.chainage_m / interval);
|
||||
return {
|
||||
id: station.role === "anchor" ? owner : `${owner}:${station.role}`,
|
||||
station: number,
|
||||
remainder: station.chainage_m - number * interval,
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.title,
|
||||
...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
|
||||
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지우고 새 측점엔 세움(`followMovedNodes` · `seatBornNodes` · 427) ·
|
||||
* 늘 저장분에서 셈. 심은 측점이 아직 없으면(null · 시설 줄 입력 전) 저장분 그대로(473). */
|
||||
export function listedBase(
|
||||
storedBase: AlignmentBase | null,
|
||||
detail: SectionDetailResponse | null,
|
||||
irregularStations: IrregularStation[] | null,
|
||||
): AlignmentBase | null {
|
||||
if (!storedBase || !detail || !irregularStations) 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)));
|
||||
const moved = followMovedNodes(
|
||||
storedBase.basePvi,
|
||||
gone,
|
||||
born,
|
||||
Object.keys(readAlignment(detail.longitudinal)?.edits.station_offsets ?? {}),
|
||||
);
|
||||
return {
|
||||
...storedBase,
|
||||
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat, moved.held),
|
||||
stations: list.map((station) => ({
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** 저장 상세(종단도가 그리는 것) + 지금 입력이 심는 측점 = 저장 노선 종단 기준. 상세가 없으면 종단도 기준.
|
||||
* `ids` = 관에 관 id 붙임(페이지 = 세션 장부 · 서버 Node = 장부 사본 · 511-B). */
|
||||
export function plantedBase(
|
||||
source: { base: AlignmentBase | null; detail?: SectionDetailResponse | null },
|
||||
inputs: Pick<PipelineInputs, "pipes" | "structures">,
|
||||
types: ReadonlyArray<PlanType>,
|
||||
ids: (pipes: Array<{ chainage_m: number }>) => Array<{ chainage_m: number; id: string }>,
|
||||
): AlignmentBase | null {
|
||||
const detail = source.detail ?? null;
|
||||
const stored = detail && readAlignment(detail.longitudinal);
|
||||
if (!detail || !stored) return source.base;
|
||||
const pipes = ids((inputs.pipes ?? []) as Array<{ chainage_m: number }>);
|
||||
const structures = (inputs.structures ?? []) as Parameters<typeof planStations>[1];
|
||||
const planted = plannedIrregular(planStations(pipes, structures, types), 20); // 간격 = 이름 몫 · 기준 무관
|
||||
return listedBase(toAlignmentBase(stored), detail, planted);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_CalcSites.ts
|
||||
* 「계산 자리」 창(PLAN 504-2 · 504-3 · 요구 309-2 · 312) — 시스템 관리자만(카드 = `settingLinks` · 쓰기 = 서버
|
||||
* `require_system_admin`). 묶음 8 머리 + 그 아래 계산 45 줄(번호 · 이름 = 등록부 `label`) · 칸 둘(「조작 중
|
||||
* `require_system_admin`). 묶음 12 머리 + 그 아래 계산 45 줄(번호 · 이름 = 등록부 `label`) · 칸 둘(「조작 중
|
||||
* 브라우저 셈」 · 「[저장] 서버 정본」 늘 켬 · 잠금). 묶음 머리 체크 = 그 묶음 전부.
|
||||
* 바꿀 수 있는 줄 = 서버 판 `groups[].switchable`(판 열쇠 = 묶음) · 나머지는 잠긴 체크 + 까닭 글.
|
||||
* 고침은 창 안 사본에만 · [저장] 때만 바뀐 줄을 PUT(선례 `app_settings.ts`).
|
||||
@@ -40,7 +40,7 @@ function groupRows(
|
||||
changed: () => void,
|
||||
): HTMLElement[][] {
|
||||
const server = (): HTMLInputElement => check(group.fixed !== "browser", true);
|
||||
const why = K(`Why_${group.why ?? group.key}`);
|
||||
const why = (no: string): string => K(`Why_${group.whys?.[no] ?? group.why ?? group.key}`);
|
||||
const head = el("span", {
|
||||
children: [el("strong", { text: K(group.key) }), ` (${group.nos.length})`],
|
||||
});
|
||||
@@ -48,7 +48,7 @@ function groupRows(
|
||||
const browser = (): HTMLInputElement => check(group.fixed !== "server", true);
|
||||
return [
|
||||
[head, browser(), server(), el("span")],
|
||||
...group.nos.map((no) => [calcName(no), browser(), server(), el("span", { text: why })]),
|
||||
...group.nos.map((no) => [calcName(no), browser(), server(), el("span", { text: why(no) })]),
|
||||
];
|
||||
}
|
||||
const boxes = [group.key, ...group.nos].map((id, index) => {
|
||||
@@ -70,7 +70,7 @@ function groupRows(
|
||||
calcName(no),
|
||||
boxes[index + 1],
|
||||
server(),
|
||||
el("span", { text: why }),
|
||||
el("span", { text: why(no) }),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
|
||||
import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload";
|
||||
import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat";
|
||||
import "./B01_Dashboard_UI_Style.css";
|
||||
import "./B01_Dashboard_UI_Style_Company.css";
|
||||
|
||||
export interface DashboardState {
|
||||
user: DashboardUser;
|
||||
|
||||
@@ -385,428 +385,3 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 회사 주소 입력 — 검색해서 고르는 칸 (2026-09-13).
|
||||
라벨·입력·버튼을 한 줄에 세운다. ui-field 가 기본이 세로(라벨 위·입력 아래·오류 슬롯)라
|
||||
그대로 두면 버튼이 오류 슬롯 높이만큼 밀려 수평이 어긋난다 — 이 칸에서만 가로로 눕힌다. */
|
||||
.b01-dashboard__address {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row > .ui-field {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8, 8px);
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-field__label {
|
||||
flex: 0 0 64px;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-field__error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row > .ui-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-zip {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-muted, #666);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-8, 8px);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item:hover,
|
||||
.b01-dashboard__address-item:focus-visible {
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item small {
|
||||
color: var(--color-text-muted, #666);
|
||||
}
|
||||
|
||||
.b01-dashboard__map-zoom {
|
||||
display: flex;
|
||||
gap: var(--spacing-8, 8px);
|
||||
margin-bottom: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
/* 회사 주소 지도 미리보기 — 타일 3×3 을 붙이고 가운데 표식을 찍는다 (2026-09-06). */
|
||||
.b01-dashboard__map-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 384px;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__map-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 256px);
|
||||
transform: scale(0.5);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.b01-dashboard__map-marker {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: -6px 0 0 -6px;
|
||||
border: 2px solid var(--color-surface, #fff);
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger, #d33);
|
||||
}
|
||||
|
||||
/* 프로젝트 참여자 고르기 (2026-09-06). */
|
||||
.b01-dashboard__members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4, 4px);
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.b01-dashboard__member-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle,
|
||||
.b01-knowledge-chat__form button {
|
||||
border: 0;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 16px #0004;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(380px, calc(100vw - 32px));
|
||||
height: min(680px, calc(100dvh - 104px));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface, #fff);
|
||||
box-shadow: 0 8px 28px #0003;
|
||||
color: var(--color-text-body);
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__avatar {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__avatar svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__close {
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 26px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__messages {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 16px 12px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__user,
|
||||
.b01-knowledge-chat__reply {
|
||||
max-width: 88%;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__user {
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 4px;
|
||||
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-surface-raised);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply details {
|
||||
margin-top: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply summary {
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply pre {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__sources {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__sources > summary {
|
||||
margin-bottom: 3px;
|
||||
color: var(--color-text-secondary, #666);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__source {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__source span {
|
||||
color: var(--color-text-secondary, #666);
|
||||
font-size: 0.8em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal.ui-modal {
|
||||
width: min(900px, 94vw);
|
||||
--modal-max: 88vh;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content {
|
||||
max-height: calc(88vh - 100px);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 8px 16px;
|
||||
line-height: 1.75;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content h2,
|
||||
.b01-knowledge-source-modal__content h3,
|
||||
.b01-knowledge-source-modal__content h4,
|
||||
.b01-knowledge-source-modal__content h5 {
|
||||
margin: 1.4em 0 0.5em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content p,
|
||||
.b01-knowledge-source-modal__content ul,
|
||||
.b01-knowledge-source-modal__content ol {
|
||||
margin: 0.6em 0 1em;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content code {
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content .ui-markdown-table {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content th,
|
||||
.b01-knowledge-source-modal__content td {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content th {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form textarea {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
min-height: 56px;
|
||||
max-height: 140px;
|
||||
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;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat button:focus-visible,
|
||||
.b01-knowledge-chat textarea:focus-visible {
|
||||
outline: 2px solid var(--color-royal-amethyst, #6843a7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 계산 자리 창(504-2) — 묶음 · 까닭 글은 줄바꿈 · 체크 칸은 가운데 */
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td {
|
||||
white-space: normal;
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td:nth-child(2),
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td:nth-child(3) {
|
||||
text-align: center;
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-group td {
|
||||
background: var(--color-surface);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-name {
|
||||
padding-left: var(--spacing-16);
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-group .b01-dashboard__calc-name {
|
||||
padding-left: 0;
|
||||
}
|
||||
.b01-dashboard__calc-count,
|
||||
.b01-dashboard__calc-note {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
/* 회사 주소 입력 — 검색해서 고르는 칸 (2026-09-13).
|
||||
라벨·입력·버튼을 한 줄에 세운다. ui-field 가 기본이 세로(라벨 위·입력 아래·오류 슬롯)라
|
||||
그대로 두면 버튼이 오류 슬롯 높이만큼 밀려 수평이 어긋난다 — 이 칸에서만 가로로 눕힌다. */
|
||||
.b01-dashboard__address {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row > .ui-field {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8, 8px);
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-field__label {
|
||||
flex: 0 0 64px;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-field__error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row > .ui-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-zip {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-muted, #666);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-8, 8px);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item:hover,
|
||||
.b01-dashboard__address-item:focus-visible {
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item small {
|
||||
color: var(--color-text-muted, #666);
|
||||
}
|
||||
|
||||
.b01-dashboard__map-zoom {
|
||||
display: flex;
|
||||
gap: var(--spacing-8, 8px);
|
||||
margin-bottom: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
/* 회사 주소 지도 미리보기 — 타일 3×3 을 붙이고 가운데 표식을 찍는다 (2026-09-06). */
|
||||
.b01-dashboard__map-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 384px;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__map-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 256px);
|
||||
transform: scale(0.5);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.b01-dashboard__map-marker {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: -6px 0 0 -6px;
|
||||
border: 2px solid var(--color-surface, #fff);
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger, #d33);
|
||||
}
|
||||
|
||||
/* 프로젝트 참여자 고르기 (2026-09-06). */
|
||||
.b01-dashboard__members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4, 4px);
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.b01-dashboard__member-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle,
|
||||
.b01-knowledge-chat__form button {
|
||||
border: 0;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 16px #0004;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__toggle svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(380px, calc(100vw - 32px));
|
||||
height: min(680px, calc(100dvh - 104px));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface, #fff);
|
||||
box-shadow: 0 8px 28px #0003;
|
||||
color: var(--color-text-body);
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__panel header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__avatar {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__avatar svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__close {
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 26px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__messages {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 16px 12px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__user,
|
||||
.b01-knowledge-chat__reply {
|
||||
max-width: 88%;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__user {
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 4px;
|
||||
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-surface-raised);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply details {
|
||||
margin-top: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply summary {
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__reply pre {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__sources {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__sources > summary {
|
||||
margin-bottom: 3px;
|
||||
color: var(--color-text-secondary, #666);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__source {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__source span {
|
||||
color: var(--color-text-secondary, #666);
|
||||
font-size: 0.8em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal.ui-modal {
|
||||
width: min(900px, 94vw);
|
||||
--modal-max: 88vh;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content {
|
||||
max-height: calc(88vh - 100px);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 8px 16px;
|
||||
line-height: 1.75;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content h2,
|
||||
.b01-knowledge-source-modal__content h3,
|
||||
.b01-knowledge-source-modal__content h4,
|
||||
.b01-knowledge-source-modal__content h5 {
|
||||
margin: 1.4em 0 0.5em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content p,
|
||||
.b01-knowledge-source-modal__content ul,
|
||||
.b01-knowledge-source-modal__content ol {
|
||||
margin: 0.6em 0 1em;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content code {
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content .ui-markdown-table {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content th,
|
||||
.b01-knowledge-source-modal__content td {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.b01-knowledge-source-modal__content th {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b01-knowledge-chat__form textarea {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
min-height: 56px;
|
||||
max-height: 140px;
|
||||
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;
|
||||
}
|
||||
|
||||
.b01-knowledge-chat button:focus-visible,
|
||||
.b01-knowledge-chat textarea:focus-visible {
|
||||
outline: 2px solid var(--color-royal-amethyst, #6843a7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 계산 자리 창(504-2) — 묶음 · 까닭 글은 줄바꿈 · 체크 칸은 가운데 */
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td {
|
||||
white-space: normal;
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td:nth-child(2),
|
||||
.b01-dashboard__calc-sites .b01-dashboard__table td:nth-child(3) {
|
||||
text-align: center;
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-group td {
|
||||
background: var(--color-surface);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-name {
|
||||
padding-left: var(--spacing-16);
|
||||
}
|
||||
.b01-dashboard__calc-sites .b01-dashboard__calc-group .b01-dashboard__calc-name {
|
||||
padding-left: 0;
|
||||
}
|
||||
.b01-dashboard__calc-count,
|
||||
.b01-dashboard__calc-note {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
type UploadStatus,
|
||||
} from "./B03_FileInput_UI_Support";
|
||||
import "./B03_FileInput_UI_Style.css";
|
||||
import "./B03_FileInput_UI_Style_Card.css";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
|
||||
@@ -348,367 +348,3 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
/* 업로드 중에는 화면 조작을 막는다 — 중복 업로드·단계 이동 방지(2026-08-08 사용자 지시). */
|
||||
.b03-file--busy {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b03-file--busy .b03-file__card-select,
|
||||
.b03-file--busy .b03-file__card-remove,
|
||||
.b03-file--busy .b03-file__dropzone {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 업로드 버튼 안에서 도는 원 — 공용 스피너를 버튼 글자 크기에 맞춰 줄인다. */
|
||||
.b03-file__upload-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 2px;
|
||||
border-color: rgb(255 255 255 / 40%);
|
||||
border-top-color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
/* Wiza 8px radius 카드 */
|
||||
/* 카드 한 장 = 파일 한 칸. 빈 칸은 **한 줄**로 끝난다(2026-09-03 사용자 지시) —
|
||||
파일이 붙거나 올라가는 중에만 아래 내용(파일명·진행바)이 펼쳐진다. */
|
||||
.b03-file__card {
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
transition:
|
||||
transform var(--transition-base, 0.2s),
|
||||
border-color var(--transition-base, 0.2s),
|
||||
box-shadow var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
}
|
||||
|
||||
.b03-file__card--selected {
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-paper, #f6f7fa);
|
||||
}
|
||||
|
||||
.b03-file__card--uploading {
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
box-shadow: var(--shadow-lg-2);
|
||||
}
|
||||
|
||||
.b03-file__card--completed {
|
||||
border-color: #10b981; /* Success Green */
|
||||
}
|
||||
|
||||
.b03-file__card--error {
|
||||
border-color: var(--color-danger, #dc2626);
|
||||
}
|
||||
|
||||
/* 카드가 좁아졌다(컨테이너 2열 × 카드 2열, 2026-08-31). 격자로 고정하면 제목이
|
||||
글자 단위로 접히므로, 자리가 모자라면 배지·삭제 버튼이 다음 줄로 내려가게 한다. */
|
||||
/* 제목·상태·[선택]이 **한 줄**에 선다(2026-09-03) — 줄바꿈을 허용하면 [선택]이 아래로
|
||||
내려가 카드가 두 줄이 됐다. 자리가 모자라면 제목이 줄어든다(min-width: 0). */
|
||||
/* ⚠ 좁은 폭에서 배지·[선택]·[×] 가 밀려 나가지 않게 **줄 자체가 넘치지 않도록** 막는다
|
||||
(2026-09-08). 제목은 이미 말줄임이라, 넘치는 것은 늘 오른쪽 붙박이들이었다. */
|
||||
.b03-file__card-header {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.b03-file__card-header > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b03-file__card-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-icons, 8px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-mist-violet, #edecff);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
|
||||
}
|
||||
|
||||
.b03-file__card-heading {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.b03-file__card-label {
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
/* 제목은 한 줄로 두고 모자라면 말줄임 — 접히면 카드가 세로로 길어진다(2026-09-03). */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.b03-file__card-ext,
|
||||
.b03-file__file-size,
|
||||
.b03-file__progress-info {
|
||||
color: var(--color-slate, #615e6e);
|
||||
font-size: var(--text-caption, 12px);
|
||||
}
|
||||
|
||||
.b03-file__card-badge-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b03-file__card-remove {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-buttons, 8px);
|
||||
color: var(--color-slate, #615e6e);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
|
||||
transition: all var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__card-remove:hover {
|
||||
color: var(--color-danger, #dc2626);
|
||||
border-color: var(--color-danger, #dc2626);
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.b03-file__card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 빈 칸은 제목 줄만 남긴다 — 파일이 붙으면 이름·크기가, 올라가는 중에는 진행바가 선다. */
|
||||
.b03-file__card--empty .b03-file__card-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* [선택]은 제목 줄 오른쪽 끝의 작은 버튼 — 종전 전폭 40px 버튼이 카드 높이를 키웠다. */
|
||||
.b03-file__card-select {
|
||||
flex: 0 0 auto;
|
||||
min-height: 26px;
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-buttons, 8px);
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
font-size: var(--text-caption, 12px);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-base, 0.2s),
|
||||
border-color var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b03-file__file-name {
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 진행바·속도·예상완료는 **올라가는 동안만** 보인다(2026-09-03) — 끝난 카드에 남겨 두면
|
||||
읽을 일 없는 세 줄이 카드 높이를 그대로 차지했다. */
|
||||
.b03-file__progress-section {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b03-file__card--uploading .b03-file__progress-section {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.b03-file__progress-bar-container {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-pills, 1440px);
|
||||
background: var(--color-mist, #e6e2e3);
|
||||
}
|
||||
|
||||
.b03-file__progress-bar {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-pills, 1440px);
|
||||
background: var(--color-royal-amethyst, #3e0079);
|
||||
transition: width var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__progress-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.b03-file__error-message {
|
||||
display: none;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
color: var(--color-danger, #dc2626);
|
||||
background: #fef2f2;
|
||||
font-size: var(--text-caption, 12px);
|
||||
border: 1px solid rgba(220, 38, 38, 0.15);
|
||||
}
|
||||
|
||||
.b03-file__card--empty .b03-file__file-info,
|
||||
.b03-file__card--empty .b03-file__preview,
|
||||
.b03-file__card--empty .b03-file__progress-section,
|
||||
.b03-file__card--empty .b03-file__error-message {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 업로드 자료 미리보기(2026-09-04) — 분석 메타데이터의 범위만으로 그리는 작은 도형.
|
||||
값이 없으면 `is-visible` 이 안 붙어 자리를 차지하지 않는다. */
|
||||
.b03-file__preview {
|
||||
display: none;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* 카드 한 행을 전부 쓰고 높이는 고정한다 (2026-09-04 사용자 지시) — 카드 높이가
|
||||
파일마다 들쭉날쭉하지 않게. 그림·썸네일·값 표시가 모두 같은 상자를 쓴다. */
|
||||
.b03-file__preview.is-visible {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border, #dcdce6);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.b03-file__preview svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 라이다 탑뷰 점 그림 — 점이 5천 개라 선 하나로 묶어 그린다. */
|
||||
.b03-file__preview-cloud {
|
||||
fill: none;
|
||||
stroke: var(--color-text-muted, #5a5a72);
|
||||
stroke-linecap: round;
|
||||
stroke-opacity: 0.55;
|
||||
stroke-width: 0.7;
|
||||
}
|
||||
|
||||
/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. 썸네일이 곧 자료 범위라
|
||||
다른 그림의 점선 사각형과 같은 테두리를 둘러 셋이 같은 모양으로 보이게 한다
|
||||
(2026-09-04 사용자 지적). */
|
||||
.b03-file__preview-thumb {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border: 1px dashed var(--color-border, #9aa);
|
||||
image-rendering: pixelated;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 그릴 도형이 없는 부속 파일 — 구분되는 값을 큰 글씨로. */
|
||||
.b03-file__preview-facts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b03-file__preview-fact {
|
||||
color: var(--color-text, #1c1c28);
|
||||
font-size: var(--text-body, 15px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b03-file__preview-fact-sub {
|
||||
color: var(--color-text-muted, #5a5a72);
|
||||
font-size: var(--text-caption, 12px);
|
||||
}
|
||||
|
||||
.b03-file__preview-empty {
|
||||
color: var(--color-text-muted, #8a8aa0);
|
||||
font-size: var(--text-caption, 12px);
|
||||
}
|
||||
|
||||
.b03-file__preview-extent {
|
||||
fill: color-mix(in srgb, var(--color-border, #9aa) 12%, transparent);
|
||||
stroke: var(--color-border, #9aa);
|
||||
stroke-dasharray: 4 3;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b03-file__preview-route {
|
||||
fill: none;
|
||||
/* 계획노선 선 색 — 녹색(2026-09-04 사용자 지시). 지형 범위를 벗어나면 아래에서
|
||||
붉은색으로 덮어써 경고로 쓴다. */
|
||||
stroke: var(--color-success, #16a34a);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
/* 계획노선이 지형 자료 범위를 벗어난 경우 — 눈으로 바로 잡히게 경고색. */
|
||||
.b03-file__preview.is-warning .b03-file__preview-route {
|
||||
stroke: var(--color-danger, #d33);
|
||||
}
|
||||
|
||||
.b03-file__preview.is-warning .b03-file__preview-extent {
|
||||
stroke: var(--color-danger, #d33);
|
||||
stroke-dasharray: 3 2;
|
||||
}
|
||||
|
||||
.b03-file__card--selected .b03-file__progress-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 올리는 중에만 [파일 선택]을 감춘다. 끝난 카드에도 남겨 두어야 파일 하나를 갈아
|
||||
끼우고 [파일 업로드]로 다시 돌릴 수 있다(2026-09-04 사용자 지시). */
|
||||
.b03-file__card--uploading .b03-file__card-select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b03-file__card--error .b03-file__error-message {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.b03-file__card--error .b03-file__progress-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/* 업로드 중에는 화면 조작을 막는다 — 중복 업로드·단계 이동 방지(2026-08-08 사용자 지시). */
|
||||
.b03-file--busy {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b03-file--busy .b03-file__card-select,
|
||||
.b03-file--busy .b03-file__card-remove,
|
||||
.b03-file--busy .b03-file__dropzone {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 업로드 버튼 안에서 도는 원 — 공용 스피너를 버튼 글자 크기에 맞춰 줄인다. */
|
||||
.b03-file__upload-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 2px;
|
||||
border-color: rgb(255 255 255 / 40%);
|
||||
border-top-color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
/* Wiza 8px radius 카드 */
|
||||
/* 카드 한 장 = 파일 한 칸. 빈 칸은 **한 줄**로 끝난다(2026-09-03 사용자 지시) —
|
||||
파일이 붙거나 올라가는 중에만 아래 내용(파일명·진행바)이 펼쳐진다. */
|
||||
.b03-file__card {
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
transition:
|
||||
transform var(--transition-base, 0.2s),
|
||||
border-color var(--transition-base, 0.2s),
|
||||
box-shadow var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
}
|
||||
|
||||
.b03-file__card--selected {
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-paper, #f6f7fa);
|
||||
}
|
||||
|
||||
.b03-file__card--uploading {
|
||||
border-color: var(--color-royal-amethyst, #3e0079);
|
||||
box-shadow: var(--shadow-lg-2);
|
||||
}
|
||||
|
||||
.b03-file__card--completed {
|
||||
border-color: #10b981; /* Success Green */
|
||||
}
|
||||
|
||||
.b03-file__card--error {
|
||||
border-color: var(--color-danger, #dc2626);
|
||||
}
|
||||
|
||||
/* 카드가 좁아졌다(컨테이너 2열 × 카드 2열, 2026-08-31). 격자로 고정하면 제목이
|
||||
글자 단위로 접히므로, 자리가 모자라면 배지·삭제 버튼이 다음 줄로 내려가게 한다. */
|
||||
/* 제목·상태·[선택]이 **한 줄**에 선다(2026-09-03) — 줄바꿈을 허용하면 [선택]이 아래로
|
||||
내려가 카드가 두 줄이 됐다. 자리가 모자라면 제목이 줄어든다(min-width: 0). */
|
||||
/* ⚠ 좁은 폭에서 배지·[선택]·[×] 가 밀려 나가지 않게 **줄 자체가 넘치지 않도록** 막는다
|
||||
(2026-09-08). 제목은 이미 말줄임이라, 넘치는 것은 늘 오른쪽 붙박이들이었다. */
|
||||
.b03-file__card-header {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.b03-file__card-header > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b03-file__card-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-icons, 8px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-mist-violet, #edecff);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
|
||||
}
|
||||
|
||||
.b03-file__card-heading {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.b03-file__card-label {
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
/* 제목은 한 줄로 두고 모자라면 말줄임 — 접히면 카드가 세로로 길어진다(2026-09-03). */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.b03-file__card-ext,
|
||||
.b03-file__file-size,
|
||||
.b03-file__progress-info {
|
||||
color: var(--color-slate, #615e6e);
|
||||
font-size: var(--text-caption, 12px);
|
||||
}
|
||||
|
||||
.b03-file__card-badge-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b03-file__card-remove {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-buttons, 8px);
|
||||
color: var(--color-slate, #615e6e);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
|
||||
transition: all var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__card-remove:hover {
|
||||
color: var(--color-danger, #dc2626);
|
||||
border-color: var(--color-danger, #dc2626);
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.b03-file__card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 빈 칸은 제목 줄만 남긴다 — 파일이 붙으면 이름·크기가, 올라가는 중에는 진행바가 선다. */
|
||||
.b03-file__card--empty .b03-file__card-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* [선택]은 제목 줄 오른쪽 끝의 작은 버튼 — 종전 전폭 40px 버튼이 카드 높이를 키웠다. */
|
||||
.b03-file__card-select {
|
||||
flex: 0 0 auto;
|
||||
min-height: 26px;
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--color-mist, #e6e2e3);
|
||||
border-radius: var(--radius-buttons, 8px);
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
background: var(--color-canvas, #ffffff);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
font-size: var(--text-caption, 12px);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-base, 0.2s),
|
||||
border-color var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b03-file__file-name {
|
||||
color: var(--color-deep-iris, #26114a);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 진행바·속도·예상완료는 **올라가는 동안만** 보인다(2026-09-03) — 끝난 카드에 남겨 두면
|
||||
읽을 일 없는 세 줄이 카드 높이를 그대로 차지했다. */
|
||||
.b03-file__progress-section {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b03-file__card--uploading .b03-file__progress-section {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.b03-file__progress-bar-container {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-pills, 1440px);
|
||||
background: var(--color-mist, #e6e2e3);
|
||||
}
|
||||
|
||||
.b03-file__progress-bar {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-pills, 1440px);
|
||||
background: var(--color-royal-amethyst, #3e0079);
|
||||
transition: width var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
.b03-file__progress-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.b03-file__error-message {
|
||||
display: none;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
color: var(--color-danger, #dc2626);
|
||||
background: #fef2f2;
|
||||
font-size: var(--text-caption, 12px);
|
||||
border: 1px solid rgba(220, 38, 38, 0.15);
|
||||
}
|
||||
|
||||
.b03-file__card--empty .b03-file__file-info,
|
||||
.b03-file__card--empty .b03-file__preview,
|
||||
.b03-file__card--empty .b03-file__progress-section,
|
||||
.b03-file__card--empty .b03-file__error-message {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 업로드 자료 미리보기(2026-09-04) — 분석 메타데이터의 범위만으로 그리는 작은 도형.
|
||||
값이 없으면 `is-visible` 이 안 붙어 자리를 차지하지 않는다. */
|
||||
.b03-file__preview {
|
||||
display: none;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* 카드 한 행을 전부 쓰고 높이는 고정한다 (2026-09-04 사용자 지시) — 카드 높이가
|
||||
파일마다 들쭉날쭉하지 않게. 그림·썸네일·값 표시가 모두 같은 상자를 쓴다. */
|
||||
.b03-file__preview.is-visible {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border, #dcdce6);
|
||||
border-radius: var(--radius-cards, 8px);
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.b03-file__preview svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 라이다 탑뷰 점 그림 — 점이 5천 개라 선 하나로 묶어 그린다. */
|
||||
.b03-file__preview-cloud {
|
||||
fill: none;
|
||||
stroke: var(--color-text-muted, #5a5a72);
|
||||
stroke-linecap: round;
|
||||
stroke-opacity: 0.55;
|
||||
stroke-width: 0.7;
|
||||
}
|
||||
|
||||
/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. 썸네일이 곧 자료 범위라
|
||||
다른 그림의 점선 사각형과 같은 테두리를 둘러 셋이 같은 모양으로 보이게 한다
|
||||
(2026-09-04 사용자 지적). */
|
||||
.b03-file__preview-thumb {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border: 1px dashed var(--color-border, #9aa);
|
||||
image-rendering: pixelated;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 그릴 도형이 없는 부속 파일 — 구분되는 값을 큰 글씨로. */
|
||||
.b03-file__preview-facts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b03-file__preview-fact {
|
||||
color: var(--color-text, #1c1c28);
|
||||
font-size: var(--text-body, 15px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b03-file__preview-fact-sub {
|
||||
color: var(--color-text-muted, #5a5a72);
|
||||
font-size: var(--text-caption, 12px);
|
||||
}
|
||||
|
||||
.b03-file__preview-empty {
|
||||
color: var(--color-text-muted, #8a8aa0);
|
||||
font-size: var(--text-caption, 12px);
|
||||
}
|
||||
|
||||
.b03-file__preview-extent {
|
||||
fill: color-mix(in srgb, var(--color-border, #9aa) 12%, transparent);
|
||||
stroke: var(--color-border, #9aa);
|
||||
stroke-dasharray: 4 3;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b03-file__preview-route {
|
||||
fill: none;
|
||||
/* 계획노선 선 색 — 녹색(2026-09-04 사용자 지시). 지형 범위를 벗어나면 아래에서
|
||||
붉은색으로 덮어써 경고로 쓴다. */
|
||||
stroke: var(--color-success, #16a34a);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
/* 계획노선이 지형 자료 범위를 벗어난 경우 — 눈으로 바로 잡히게 경고색. */
|
||||
.b03-file__preview.is-warning .b03-file__preview-route {
|
||||
stroke: var(--color-danger, #d33);
|
||||
}
|
||||
|
||||
.b03-file__preview.is-warning .b03-file__preview-extent {
|
||||
stroke: var(--color-danger, #d33);
|
||||
stroke-dasharray: 3 2;
|
||||
}
|
||||
|
||||
.b03-file__card--selected .b03-file__progress-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 올리는 중에만 [파일 선택]을 감춘다. 끝난 카드에도 남겨 두어야 파일 하나를 갈아
|
||||
끼우고 [파일 업로드]로 다시 돌릴 수 있다(2026-09-04 사용자 지시). */
|
||||
.b03-file__card--uploading .b03-file__card-select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b03-file__card--error .b03-file__error-message {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.b03-file__card--error .b03-file__progress-section {
|
||||
display: none;
|
||||
}
|
||||
@@ -525,6 +525,8 @@ export interface DetailPipePoint {
|
||||
summary_item?: SummaryItem | null;
|
||||
/** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */
|
||||
group_items?: GroupItems | null;
|
||||
/** 자동 세류선 관 라벨(507-2 · `{stream, order, x, y}`) — 서버 정본 · 화면은 실어 나르기만(510). */
|
||||
stream_label?: Record<string, string | number> | null;
|
||||
}
|
||||
|
||||
/** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */
|
||||
@@ -597,6 +599,8 @@ export interface DetailBasinResponse {
|
||||
stale?: string | null;
|
||||
/** 관 자리 후보(347-3) — 제안만 · [적용] 때 관 초안에. */
|
||||
pipe_candidates?: Array<{ chainage_m: number; lonlat: [number, number] }>;
|
||||
/** 이 답을 셈한 노선(사업지 CRS m) — 배수유역도 노선선 · 관 자리가 유역과 짝(요구 326-6). */
|
||||
route_xy?: Array<[number, number]>;
|
||||
}
|
||||
|
||||
let lastNote = { text: "", at: 0 };
|
||||
@@ -622,16 +626,14 @@ export async function fetchDetailPipePoints(projectId: string): Promise<DetailBa
|
||||
|
||||
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
|
||||
*
|
||||
* 빈 목록 = 관 0개(312) · null = 세류선 교차 관만(369) · `auto`([자동 제안]) = "all" 전체 자동
|
||||
* (같은 자리 관은 속성 유지) · "add" 지금 관 + 빈 구간 보충. */
|
||||
* 빈 목록 = 관 0개(312) · null = 세류선 교차 관만(369). */
|
||||
export async function computeDetailBasins(
|
||||
projectId: string,
|
||||
points: DetailPipeInput[] | null,
|
||||
auto?: "all" | "add",
|
||||
): Promise<DetailBasinResponse> {
|
||||
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/detail-basins`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(auto ? { points, auto } : { points }),
|
||||
body: JSON.stringify({ points }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
- 범위(사업지 m · 바깥으로 `SNAP_M` 눈금에 맞춤 → 노선을 조금 옮겨도 같은 열쇠)
|
||||
- `route` = 저장 노선 bbox + 띠 `ROUTE_BAND_M` + 여유 `ROUTE_SPARE_M` — 편집이 이 밖으로
|
||||
나가면 화면이 통째 사본을 받는다(`clip_lonlat` 으로 판정).
|
||||
나가면 화면이 놓은 뒤 편집 중 노선 bbox(`route_bounds` · 508-2)로 다시 받는다
|
||||
(`clip_lonlat` 으로 판정).
|
||||
- `drainage` = 세부 유역(노선 교차 관으로 모이는 유역) ∪ 줌아웃 한계(노선 bbox ×2)
|
||||
+ `DRAIN_SPARE_M`.
|
||||
- 파생 저장본 = `_derived/sheet_<범위>/<열쇠>.json`(GeoJSON 그대로 · 열쇠 = 사본 지문 + 범위)
|
||||
@@ -144,9 +145,25 @@ def _basin_points(project_root: Path, meta: dict[str, Any]) -> list[tuple[float,
|
||||
return points
|
||||
|
||||
|
||||
def clip_rect_m(project_root: Path, scope: str, meta: dict[str, Any]) -> Rect | None:
|
||||
"""범위 사각형(사업지 m · 눈금 맞춤). 노선이 없으면(또는 배수유역도에 유역이 없으면) None."""
|
||||
route = _bounds(_route_points(project_root))
|
||||
def parse_route_bbox(text: str | None) -> Rect | None:
|
||||
"""화면이 보낸 편집 중 노선 bbox(`x0,y0,x1,y1` m) — 숫자 넷 · 차례 맞음만 · 아니면 None."""
|
||||
try:
|
||||
values = [float(part) for part in (text or "").split(",")]
|
||||
except ValueError:
|
||||
return None
|
||||
if len(values) != 4 or not all(math.isfinite(v) for v in values):
|
||||
return None
|
||||
if values[0] > values[2] or values[1] > values[3]:
|
||||
return None
|
||||
return values[0], values[1], values[2], values[3]
|
||||
|
||||
|
||||
def clip_rect_m(
|
||||
project_root: Path, scope: str, meta: dict[str, Any], route_bounds: Rect | None = None
|
||||
) -> Rect | None:
|
||||
"""범위 사각형(사업지 m · 눈금 맞춤). 노선이 없으면(또는 배수유역도에 유역이 없으면) None.
|
||||
`route_bounds` = 저장 노선 대신 쓸 노선 bbox(편집 중 · 508-2)."""
|
||||
route = route_bounds or _bounds(_route_points(project_root))
|
||||
if route is None:
|
||||
return None
|
||||
if scope == "route":
|
||||
@@ -214,11 +231,18 @@ def _write_compact(path: Path, value: Any) -> None:
|
||||
raise
|
||||
|
||||
|
||||
def clipped_sheet(project_root: Path, source: Path, scope: str) -> Path:
|
||||
"""`scope` 범위로 자른 표시본 경로. 범위를 못 정하거나 실패하면 `source` 그대로."""
|
||||
def clipped_sheet(
|
||||
project_root: Path, source: Path, scope: str, route_bounds: Rect | None = None
|
||||
) -> Path:
|
||||
"""`scope` 범위로 자른 표시본 경로. 범위를 못 정하거나 실패하면 `source` 그대로.
|
||||
열쇠 = 사본 지문 + 범위 — 같은 범위면 노선을 어디서 재든 같은 저장본(다시 안 셈)."""
|
||||
try:
|
||||
meta = _read_meta(project_root / "B04_PreProcess" / "processed")
|
||||
rect_m = clip_rect_m(project_root, scope, meta) if meta and scope in SCOPES else None
|
||||
rect_m = (
|
||||
clip_rect_m(project_root, scope, meta, route_bounds)
|
||||
if meta and scope in SCOPES
|
||||
else None
|
||||
)
|
||||
if rect_m is None or meta is None:
|
||||
return source
|
||||
west, south = _to_lonlat(meta, rect_m[0], rect_m[1])
|
||||
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -27,6 +28,7 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pyproj import Transformer
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import STAGES, drainage_dir
|
||||
from B05_Profile.B05_Profile_Structures_Schema import DEFINITION_PATH, structure_type_map
|
||||
@@ -37,6 +39,7 @@ from common_util.common_util_drainage_detail import (
|
||||
DrainageDetail,
|
||||
build_detail,
|
||||
pipes_from_chainages,
|
||||
read_road_routing,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PIPE_SOURCE_USER,
|
||||
@@ -57,7 +60,7 @@ from common_util.common_util_drainage_pipes import (
|
||||
)
|
||||
from common_util.common_util_json import LONLAT_DIGITS, round_floats
|
||||
from common_util.common_util_route_geometry import StructureCandidate
|
||||
from common_util.common_util_stream_crossing import keep_labels
|
||||
from common_util.common_util_stream_crossing import keep_labels, propose_pipes
|
||||
from config.config_system import (
|
||||
DRAINAGE_PIPE_MAX_SPACING_M,
|
||||
DRAINAGE_PIPE_MIN_SPACING_M,
|
||||
@@ -72,8 +75,6 @@ router = APIRouter(prefix="/api/projects", tags=["B04 Surface Drainage Basins"])
|
||||
_NO_ANALYSIS = "배수유역 분석 결과가 없습니다. [유역 분석]을 먼저 실행하세요."
|
||||
# 노선 지문이 분석 때와 다름(옛 저장분은 지문 없음) — 옛 격자로 나누면 틀린 유역이 나온다(342-3).
|
||||
# 머리 「배수유역 낡음」 = 화면 `STALE_PREFIX`(B05 Drainage_Live · 369) 짝.
|
||||
# [자동 제안] 방식(369) — None = 지금 목록 그대로(없으면 세류선 교차만) · all · add
|
||||
AUTO_MODES = (None, "all", "add")
|
||||
STALE_MESSAGE = (
|
||||
"배수유역 낡음 — 노선이 분석 때와 다릅니다. 유역을 다시 분석해야 세부 유역을 나눕니다."
|
||||
)
|
||||
@@ -85,22 +86,20 @@ def _build(
|
||||
points: list[PipePoint] | None,
|
||||
routing: Any = None,
|
||||
upstream_lonlat: list | None = None,
|
||||
auto: str | None = None,
|
||||
) -> tuple[DrainageDetail | None, list[PipePoint]]:
|
||||
"""세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다.
|
||||
|
||||
`points`가 None 이면 B04 기본 관(세류선 교차)만. 있으면 그 자리를 그대로 쓰되, 관이 왜 거기
|
||||
있는지(기본/자동/수동)는 넘겨받은 목록의 표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로
|
||||
두면 전부 "수동"이 된다. `auto`([자동 제안] · 369) = "all" 자리 새로 · "add" 빈 구간 보충 —
|
||||
같은 자리(0.05 m) 관만 속성 · id 를 이어받고 나머지는 새 관(등록부 기본값).
|
||||
두면 전부 "수동"이 된다. 원본에 없던 자리의 새 관은 등록부 기본값을 받는다.
|
||||
"""
|
||||
directory = drainage_dir(stored_path)
|
||||
chainages = [point.chainage_m for point in points] if points is not None else None
|
||||
detail = build_detail(directory, context.vertices, chainages, routing, upstream_lonlat, auto)
|
||||
detail = build_detail(directory, context.vertices, chainages, routing, upstream_lonlat)
|
||||
if detail is None:
|
||||
return None, []
|
||||
if points is not None:
|
||||
_retag(detail.pipes, points, keep_new=auto is not None)
|
||||
_retag(detail.pipes, points)
|
||||
rebuilt = _rebuilt(detail)
|
||||
# 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관
|
||||
fresh = [
|
||||
@@ -124,14 +123,18 @@ def _rebuilt(detail: DrainageDetail) -> list[PipePoint]:
|
||||
|
||||
|
||||
def _build_stale(context: DrainageContext, points: list[PipePoint]) -> DrainageDetail:
|
||||
"""낡은 분석 — 세부 유역 없이 관 자리만(지금 노선 위) · 시설 속성은 넘겨받은 그대로."""
|
||||
"""낡은 분석 — 마지막 [저장]의 옛 유역 그림(저장본 파일 · 그 노선) + 관 자리만 지금 노선 위 ·
|
||||
저장본이 없으면 관 자리만(512-6 · 새로 연 화면도 옛 그림 · [업데이트] 로만 바꿈)."""
|
||||
pipes = pipes_from_chainages(context.vertices, [point.chainage_m for point in points])
|
||||
_retag(pipes, points)
|
||||
saved = _read_cache_file(context.stored_path)
|
||||
if saved is not None:
|
||||
return dataclasses.replace(saved["value"][0], pipes=pipes, pipe_candidates=[])
|
||||
to_lonlat = context.to_lonlat
|
||||
detail = DrainageDetail(
|
||||
return DrainageDetail(
|
||||
route_lonlat=[list(to_lonlat(vertex.x, vertex.y)) for vertex in context.vertices],
|
||||
pipes=pipes_from_chainages(context.vertices, [point.chainage_m for point in points]),
|
||||
pipes=pipes,
|
||||
)
|
||||
_retag(detail.pipes, points)
|
||||
return detail
|
||||
|
||||
|
||||
# 세부 유역 저장본(342-3 · 부록 4 R15) — 열쇠 = 관 목록 · 노선(평면 · 종단 Z) · 분석 산출물 ·
|
||||
@@ -141,14 +144,11 @@ _DETAIL_CACHE_SIZE = 4
|
||||
DETAIL_CACHE_FILENAME = "04_detail_cache.pkl"
|
||||
|
||||
|
||||
def _detail_key(
|
||||
context: DrainageContext, points: list[PipePoint] | None, auto: str | None = None
|
||||
) -> str:
|
||||
def _detail_key(context: DrainageContext, points: list[PipePoint] | None) -> str:
|
||||
directory = drainage_dir(context.stored_path)
|
||||
digest = hashlib.sha1(usedforsecurity=False)
|
||||
listed = None if points is None else [point.as_dict() for point in points]
|
||||
# auto 를 열쇠에 — 같은 관 목록이라도 [자동 제안] 결과와 섞이지 않게(369 · 옛 저장본도 비껴감)
|
||||
digest.update(json.dumps([listed, auto], sort_keys=True, ensure_ascii=False).encode())
|
||||
digest.update(json.dumps(listed, sort_keys=True, ensure_ascii=False).encode())
|
||||
for vertex in context.vertices:
|
||||
digest.update(f"{vertex.x:.3f},{vertex.y:.3f},{vertex.z:.3f};".encode())
|
||||
routing = STAGES["road_routing"]
|
||||
@@ -171,6 +171,26 @@ def _cache_path(stored_path: str):
|
||||
return drainage_dir(stored_path) / DETAIL_CACHE_FILENAME
|
||||
|
||||
|
||||
def _read_cache_file(stored_path: str) -> dict[str, Any] | None:
|
||||
"""[저장] 때 남긴 세부 유역 저장본 `{key, value: (detail, 관 목록)}` — 없거나 깨지면 None."""
|
||||
try:
|
||||
with _cache_path(stored_path).open("rb") as file:
|
||||
saved = pickle.load(file)
|
||||
saved["value"][0].basins # 모양 확인
|
||||
return saved
|
||||
except (
|
||||
OSError,
|
||||
ValueError,
|
||||
pickle.PickleError,
|
||||
EOFError,
|
||||
AttributeError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
IndexError,
|
||||
):
|
||||
return None
|
||||
|
||||
|
||||
def _remember(key: str, value: tuple[DrainageDetail, list[PipePoint]]) -> None:
|
||||
_DETAIL_CACHE.pop(key, None)
|
||||
_DETAIL_CACHE[key] = value
|
||||
@@ -179,20 +199,16 @@ def _remember(key: str, value: tuple[DrainageDetail, list[PipePoint]]) -> None:
|
||||
|
||||
|
||||
def _build_cached(
|
||||
context: DrainageContext, points: list[PipePoint] | None, auto: str | None = None
|
||||
context: DrainageContext, points: list[PipePoint] | None
|
||||
) -> tuple[DrainageDetail | None, list[PipePoint]]:
|
||||
"""`_build` + 저장본 — 관 목록은 사본으로 돌려준다(저장이 좌표를 채워도 저장본은 그대로)."""
|
||||
key = _detail_key(context, points, auto)
|
||||
key = _detail_key(context, points)
|
||||
hit = _DETAIL_CACHE.get(key)
|
||||
if hit is None:
|
||||
try:
|
||||
with _cache_path(context.stored_path).open("rb") as file:
|
||||
saved = pickle.load(file)
|
||||
hit = saved["value"] if saved.get("key") == key else None
|
||||
except (OSError, pickle.PickleError, EOFError, AttributeError, KeyError, TypeError):
|
||||
hit = None
|
||||
saved = _read_cache_file(context.stored_path)
|
||||
hit = saved["value"] if saved is not None and saved.get("key") == key else None
|
||||
if hit is None:
|
||||
detail, carried = _build(context.stored_path, context, points, auto=auto)
|
||||
detail, carried = _build(context.stored_path, context, points)
|
||||
if detail is None:
|
||||
return None, []
|
||||
hit = (detail, carried)
|
||||
@@ -288,20 +304,8 @@ def ford_height_m(flow_m3s: float | None, width_m: float) -> float | None:
|
||||
return math.ceil(depth * 100 - 1e-9) / 100
|
||||
|
||||
|
||||
def _retag(
|
||||
pipes: list[StructureCandidate], points: list[PipePoint], keep_new: bool = False
|
||||
) -> None:
|
||||
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다.
|
||||
|
||||
`keep_new`([자동 제안]) = 같은 자리(0.05 m) 관만 · 새로 놓인 관은 계산기 사유
|
||||
(stream · spacing) 그대로.
|
||||
"""
|
||||
if keep_new:
|
||||
picks = carry_indices([pipe.chainage_m for pipe in pipes], [p.chainage_m for p in points])
|
||||
for pipe, pick in zip(pipes, picks):
|
||||
if pick is not None:
|
||||
pipe.reason = points[pick].source
|
||||
return
|
||||
def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None:
|
||||
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다."""
|
||||
by_chainage = {round(point.chainage_m, 2): point.source for point in points}
|
||||
for pipe in pipes:
|
||||
source = by_chainage.get(round(pipe.chainage_m, 2))
|
||||
@@ -330,6 +334,12 @@ def _payload(
|
||||
payload["route_signature"] = route_signature(context.vertices)
|
||||
payload["pipe_signature"] = pipe_signature([pipe.chainage_m for pipe in detail.pipes])
|
||||
payload["stale"] = STALE_MESSAGE if stale else None
|
||||
if stale: # 노선선 = 옛 그림의 노선(512-6 · 저장본 없으면 지금 노선 그대로)
|
||||
back = Transformer.from_crs("EPSG:4326", context.crs, always_xy=True)
|
||||
payload["route_xy"] = [
|
||||
[round(x, 3), round(y, 3)]
|
||||
for x, y in (back.transform(lon, lat) for lon, lat in detail.route_lonlat)
|
||||
]
|
||||
return round_floats(payload, LONLAT_DIGITS)
|
||||
|
||||
|
||||
@@ -358,6 +368,8 @@ def _raw_payload(
|
||||
# ── 아래는 B05 배수유역도가 그리는 데 필요한 값. B04 지도는 자체 오버레이가 있어
|
||||
# 쓰지 않지만, 두 화면이 같은 응답을 받아야 결과가 갈리지 않는다(2026-08-01 일원화).
|
||||
"route_lonlat": detail.route_lonlat,
|
||||
# 이 답을 셈한 노선(사업지 CRS m) — B05 배수유역도 노선선 · 관 자리 · 유역이 늘 짝(요구 326-6)
|
||||
"route_xy": [[round(v.x, 3), round(v.y, 3)] for v in context.vertices],
|
||||
# 2차 전체 유역 외곽선 = 분수령. 해석 결과 그대로(손으로 고치는 기능 없음).
|
||||
"main_polygon_lonlat": detail.basin_lonlat,
|
||||
"grid_cell_m": detail.grid_cell_m,
|
||||
@@ -480,16 +492,11 @@ async def _resolve(
|
||||
돌려준다 — 관 목록도 없으면(자동 생성 자리) 409(342-3). 마지막 값 = 낡음.
|
||||
|
||||
빈 목록(`[]`)은 「관 0개」로 그대로 쓴다 — 자동 생성은 요청에 칸이 없고 저장분도 없을(None) 때만(312).
|
||||
요청 `auto`([자동 제안] · 369) = "all" · "add" — `_build` 참고 · 없으면 None.
|
||||
"""
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
|
||||
auto = (payload or {}).get("auto")
|
||||
if auto not in AUTO_MODES:
|
||||
message = f"auto 는 {', '.join(m for m in AUTO_MODES if m)} 중 하나입니다."
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": message})
|
||||
try:
|
||||
raw = (payload or {}).get("points")
|
||||
requested = None if raw is None else parse_pipe_points(raw)
|
||||
@@ -503,7 +510,7 @@ async def _resolve(
|
||||
saved = requested is None and stored is not None
|
||||
|
||||
if analysis_route_signature(context.stored_path) != signature:
|
||||
if points is None or auto is not None: # 자동 자리 = 지금 노선 분석이 있어야(369)
|
||||
if points is None:
|
||||
return JSONResponse(
|
||||
status_code=409, content={"status": "stale", "message": STALE_MESSAGE}
|
||||
)
|
||||
@@ -512,11 +519,9 @@ async def _resolve(
|
||||
|
||||
# [저장]은 정본으로 다시 셈 — 저장본은 GET · POST 만
|
||||
if fresh:
|
||||
result = await asyncio.to_thread(
|
||||
lambda: _build(context.stored_path, context, points, auto=auto)
|
||||
)
|
||||
result = await asyncio.to_thread(lambda: _build(context.stored_path, context, points))
|
||||
else:
|
||||
result = await asyncio.to_thread(_build_cached, context, points, auto)
|
||||
result = await asyncio.to_thread(_build_cached, context, points)
|
||||
detail, resolved = result
|
||||
if detail is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS})
|
||||
@@ -548,7 +553,6 @@ async def post_detail_basins(
|
||||
|
||||
`points` 칸 없이 부르면 저장분을 무시하고 기본 관(세류선 교차)만으로 되돌린다 —
|
||||
[배관 배치 초기화] · 노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312).
|
||||
`auto` = [자동 제안](369) — "all" 전체 자동 · "add" 지금 관 + 빈 구간 보충.
|
||||
"""
|
||||
resolved = await _resolve(project_id, payload, use_stored=False)
|
||||
if isinstance(resolved, JSONResponse):
|
||||
@@ -645,3 +649,47 @@ async def put_pipe_points(
|
||||
result["snapshot_failed"] = failed # 못 만든 구조물 조합 복사본(142-2) — [저장] 알림
|
||||
result["stations_failed"] = not stood # 측점 맞춤 실패(433-6) — [저장] 알림
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{project_id}/drainage/pipe-proposals", response_model=None)
|
||||
async def post_pipe_proposals(
|
||||
project_id: UUID, payload: dict[str, Any] | None = None
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""[자동 제안](510) — 지금 노선 × 세류선 교차 + 간격 보충 후보와 상태 · 아무것도 안 씀.
|
||||
|
||||
`points` = 화면 관 목록(없으면 저장분) · 화면에서 지운 자동 관도 지움 표지로 봄(`keep_labels`).
|
||||
간격 보충은 분석이 지금 노선일 때만(`spacing` 거짓 = 교차만).
|
||||
"""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare
|
||||
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
path = pipe_points_path(context.stored_path)
|
||||
stored = read_pipe_points_file(path)
|
||||
try:
|
||||
raw = (payload or {}).get("points")
|
||||
points = (
|
||||
load_pipe_points(
|
||||
context.stored_path, route_signature(context.vertices), context.vertices
|
||||
)
|
||||
or []
|
||||
if raw is None
|
||||
else parse_pipe_points(raw)
|
||||
)
|
||||
except ValueError as error:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(error)})
|
||||
dismissed = read_dismissed(path)
|
||||
keep_labels(points, stored, dismissed)
|
||||
prepared = await _prepare(project_id)
|
||||
streams = [] if isinstance(prepared, JSONResponse) else prepared["streams"]
|
||||
fresh = analysis_route_signature(context.stored_path) == route_signature(context.vertices)
|
||||
routing = read_road_routing(drainage_dir(context.stored_path)) if fresh else None
|
||||
curve = routing.strength_curve if routing is not None else None
|
||||
proposals = await asyncio.to_thread(
|
||||
lambda: propose_pipes(
|
||||
context.vertices, _base_pipes(context.vertices, streams), points, dismissed, curve
|
||||
)
|
||||
)
|
||||
return {"status": "success", "proposals": proposals, "spacing": curve is not None}
|
||||
|
||||
@@ -18,7 +18,10 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface GIS"])
|
||||
tiles_router = APIRouter(tags=["B04 MVT Tiles"])
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetClip import clipped_sheet # noqa: E402
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetClip import ( # noqa: E402
|
||||
clipped_sheet,
|
||||
parse_route_bbox,
|
||||
)
|
||||
|
||||
# 수치지형도 도엽 병합 레이어 (SheetParser 산출과 1:1 — 정의는 SHEET_LAYERS가 유일)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS # noqa: E402
|
||||
@@ -98,13 +101,18 @@ async def get_vworld_map(
|
||||
# GeoJSON 조회 API
|
||||
@router.get("/{project_id}/geojson", response_model=None)
|
||||
async def get_project_geojson(
|
||||
project_id: UUID, layer: str, request: Request, scope: str | None = None
|
||||
project_id: UUID,
|
||||
layer: str,
|
||||
request: Request,
|
||||
scope: str | None = None,
|
||||
bbox: str | None = None,
|
||||
) -> dict[str, Any] | Response | JSONResponse:
|
||||
"""저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다.
|
||||
|
||||
도엽 레이어는 분석용 원본 대신 **표시용 사본**(프로젝트 주변만 잘라 좌표를 줄인 것)을
|
||||
파일 그대로 내보낸다. 원본을 매번 읽어 재직렬화하면 등고선 한 장에 2초가 든다.
|
||||
`scope`(route · drainage)를 주면 그 화면이 보는 범위만 자른 사본을 준다(509-1 · 509-6).
|
||||
`bbox`(route 만 · 사업지 m `x0,y0,x1,y1`) = 저장 노선 대신 편집 중 노선 bbox(508-2).
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
@@ -158,7 +166,10 @@ async def get_project_geojson(
|
||||
# 그대로고 뜻 없는 자리만 사라진다.
|
||||
display = await asyncio.to_thread(_display_copy, filepath)
|
||||
if scope:
|
||||
display = await asyncio.to_thread(clipped_sheet, project_root, display, scope)
|
||||
bounds = parse_route_bbox(bbox) if scope == "route" else None
|
||||
display = await asyncio.to_thread(
|
||||
clipped_sheet, project_root, display, scope, bounds
|
||||
)
|
||||
return cached_file_response(request, display, "application/geo+json")
|
||||
|
||||
if layer == "등고선":
|
||||
|
||||
@@ -22,7 +22,7 @@ from fastapi.responses import JSONResponse
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import preview_stages
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Expand import AnalysisStopped
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import AUTO_MODES, _build, _payload
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import _build, _payload
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare, router
|
||||
from common_util.common_util_drainage_context import _open_sampler, load_drainage_context
|
||||
from common_util.common_util_drainage_detail import RoadRouting
|
||||
@@ -79,7 +79,7 @@ async def post_drainage_live(
|
||||
project_id: UUID, payload: dict[str, Any] | None = None
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""몸통 = {generation, route: RouteDraft | null(저장 노선),
|
||||
points: 관 목록 | null(세류선 교차만), auto: [자동 제안] 방식(all · add · 369) | 없음}."""
|
||||
points: 관 목록 | null(세류선 교차만)}."""
|
||||
body = payload or {}
|
||||
generation = int(body.get("generation") or 0)
|
||||
key = str(project_id)
|
||||
@@ -88,9 +88,6 @@ async def post_drainage_live(
|
||||
def superseded() -> bool:
|
||||
return _LATEST_GENERATION.get(key) != generation
|
||||
|
||||
auto = body.get("auto")
|
||||
if auto not in AUTO_MODES:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": "auto 틀림"})
|
||||
try:
|
||||
raw = body.get("points")
|
||||
points = None if raw is None else parse_pipe_points(raw)
|
||||
@@ -143,7 +140,7 @@ async def post_drainage_live(
|
||||
[list(to_lonlat(x, y)) for x, y in line.coords] for line in preview.region.split.upstream
|
||||
]
|
||||
detail, carried = await asyncio.to_thread(
|
||||
lambda: _build(context.stored_path, live, points, routing, upstream, auto=auto)
|
||||
lambda: _build(context.stored_path, live, points, routing, upstream)
|
||||
)
|
||||
if detail is None:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* B05_Profile_Api_HaulPlan.ts
|
||||
* 유토 **배분**(평형선·운반거리·장비)을 서버에서 미리 받아 두는 자리.
|
||||
*
|
||||
* 왜 서버인가(2026-09-06 사용자 확정) — 배분 산식은 노하우가 몰린 자리라 브라우저 번들에
|
||||
* 남기지 않는다. 화면은 누가토량까지만 스스로 내고(`common_util_mass_haul`), 그 결과를
|
||||
* 여기로 보내 배분을 받아 쥔다. 계산은 여전히 한 벌이다 — 서버가 같은 TS 를 Node 로 돈다.
|
||||
* 기본은 서버(계산 자리 판 haul = server) — 화면은 누가토량까지만 스스로 내고(`common_util_mass_haul`),
|
||||
* 그 결과를 여기로 보내 배분을 받아 쥔다. 판 haul = both(요구 321 · 511-C)면 배분 모듈을 **그때만**
|
||||
* dynamic import 해 브라우저가 셈(기본값이면 안 받음). 계산은 한 벌 — 서버도 같은 TS 를 Node 로 돈다.
|
||||
*
|
||||
* **조용히 따라오게 한다** — 편집이 멈추면 뒤에서 물어 두므로, 유토곡선 패널을 펼치는
|
||||
* 순간에는 이미 도착해 있다. 늦게 온 응답은 버린다(최신 요청만 채택).
|
||||
@@ -12,11 +12,30 @@
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { HaulPlan } from "@util/common_util_mass_haul_balance";
|
||||
import type { MassHaulResult } from "@util/common_util_mass_haul";
|
||||
import type { EarthworkConversion, HaulEquipmentLimit } from "@util/common_util_mass_haul_types";
|
||||
import { calcInBrowser } from "../A00_Common/design/design_calc_sites";
|
||||
|
||||
/** 서버가 돌려주는 배분 한 벌 — **화면이 쓰는 꼴 그대로**라 그리기 코드가 손대지 않는다.
|
||||
* `import type` 이라 배분 모듈이 번들에 실리지 않는다(빌드에서 지워진다). */
|
||||
* `import type` 이라 배분 모듈이 첫 묶음에 안 실림(판 both 때만 따로 받음). */
|
||||
export type HaulPlanPayload = HaulPlan | null;
|
||||
|
||||
/** 브라우저 셈 문맥 — 서버 `_mass_haul_context` 와 같은 값(sections/context 가 같은 함수로 냄). */
|
||||
export interface HaulPlanContext {
|
||||
conversion: EarthworkConversion;
|
||||
haulLimits?: HaulEquipmentLimit[];
|
||||
}
|
||||
|
||||
/** 판 haul = both — 서버 /haul-plan(`_run_view`)과 같은 인자로 같은 함수.
|
||||
* ponytail: 채집석 · 구조물 잔토는 서버 `haul_inputs_for` 가 늘 빈 값이라 null — 값 내기 시작하면 sections/context 로 실어 옴. */
|
||||
async function browserHaulPlan(result: unknown, ctx: HaulPlanContext): Promise<HaulPlanPayload> {
|
||||
const { computeHaulPlan } = await import("@util/common_util_mass_haul_balance");
|
||||
return computeHaulPlan(result as MassHaulResult, ctx.haulLimits, {
|
||||
conversion: ctx.conversion,
|
||||
drawing: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** 편집이 멈춘 것으로 볼 시간(ms). 계획고를 연속으로 누르는 동안은 안 보낸다. */
|
||||
const SETTLE_MS = 400;
|
||||
/** 배분 계산 대기 상한 — Node 실행 200ms 대라 넉넉히 잡는다. */
|
||||
@@ -85,8 +104,8 @@ async function postHaulPlan(
|
||||
}
|
||||
|
||||
export interface HaulPlanPrefetch {
|
||||
/** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */
|
||||
schedule: (result: unknown) => void;
|
||||
/** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다(판 both = 브라우저 셈). */
|
||||
schedule: (result: unknown, ctx?: HaulPlanContext | null) => void;
|
||||
/** 지금 쥐고 있는 배분. 아직 못 받았으면 null. */
|
||||
current: () => HaulPlanPayload;
|
||||
/** 화면을 떠날 때 예약을 지운다. */
|
||||
@@ -106,6 +125,7 @@ export function createHaulPlanPrefetch(
|
||||
let sequence = 0;
|
||||
let plan: HaulPlanPayload = null;
|
||||
let pending: unknown = null;
|
||||
let pendingCtx: HaulPlanContext | null = null;
|
||||
/** 마지막으로 보낸 입력의 표식 — 같은 값이면 다시 묻지 않는다. */
|
||||
let sentKey: string | null = null;
|
||||
/** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */
|
||||
@@ -116,10 +136,18 @@ export function createHaulPlanPrefetch(
|
||||
if (seq === sequence) sentKey = null;
|
||||
}
|
||||
|
||||
async function send(result: unknown, seq: number, key: string): Promise<void> {
|
||||
async function send(
|
||||
result: unknown,
|
||||
ctx: HaulPlanContext | null,
|
||||
seq: number,
|
||||
key: string,
|
||||
): Promise<void> {
|
||||
const route = routeId();
|
||||
if (!route) return forget(seq);
|
||||
const got = await postHaulPlan(projectId, route, result, key);
|
||||
const got =
|
||||
ctx && (await calcInBrowser("haul"))
|
||||
? await browserHaulPlan(result, ctx).catch(() => undefined)
|
||||
: await postHaulPlan(projectId, route, result, key);
|
||||
if (got === undefined) return forget(seq);
|
||||
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
|
||||
if (seq !== sequence) return;
|
||||
@@ -136,12 +164,13 @@ export function createHaulPlanPrefetch(
|
||||
}
|
||||
|
||||
return {
|
||||
schedule(result) {
|
||||
schedule(result, ctx) {
|
||||
if (applying) return; // 응답을 반영하려고 다시 그리는 중이다.
|
||||
const key = keyOf(result);
|
||||
if (key === sentKey) return; // 같은 값 — 응답이 부른 재그리기가 되돌아온 것이다.
|
||||
sentKey = key;
|
||||
pending = result;
|
||||
pendingCtx = ctx ?? null;
|
||||
sequence += 1;
|
||||
const seq = sequence;
|
||||
window.clearTimeout(timer);
|
||||
@@ -149,7 +178,7 @@ export function createHaulPlanPrefetch(
|
||||
const route = routeId();
|
||||
const known = route ? kept.get(keptKey(projectId, route, key)) : undefined;
|
||||
if (known !== undefined) return void (plan = known);
|
||||
timer = window.setTimeout(() => void send(pending, seq, key), SETTLE_MS);
|
||||
timer = window.setTimeout(() => void send(pending, pendingCtx, seq, key), SETTLE_MS);
|
||||
},
|
||||
current: () => plan,
|
||||
dispose() {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
절·성토 균형은 계획고와 지반고 차이의 적분(A안)으로 판정한다. 횡단 설계가
|
||||
나온 뒤 실제 단면적 기반 계획선을 추가할 수 있도록 결과는 배열의 한 원소로
|
||||
반환한다.
|
||||
남는 까닭(511-B B-6) — 기준표 · 설계 속도 = 서버 종단 입력(페이지 설정 · 재생성 옵션) · 추천 엔진 ·
|
||||
4순위 폴백(`design_grade_line`).
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
@@ -18,6 +18,8 @@ scipy에 의존하지 않는 순수 기하 계산만 두어 프론트엔드 구
|
||||
(거울 시험 `test_426_profile_straight.py`).
|
||||
경고 글 짝: `build_alignment` warnings ↔ TS `buildAlignment` warnings — 같은 목록
|
||||
(거울 시험 `test_472_save_warnings.py`).
|
||||
남는 까닭(511-B B-6) — 계획선 짓기 정본 = Node `profile.alignment` · 이 짝 = 2~4 순위 폴백 · 추천
|
||||
엔진(`pass_through`).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -187,11 +189,6 @@ class AlignmentPolicy:
|
||||
}
|
||||
|
||||
|
||||
def tangent_elevation(pvi_s: np.ndarray, pvi_z: np.ndarray, targets: np.ndarray) -> np.ndarray:
|
||||
"""종단곡선을 빼고 변화점 직선만으로 본 표고(탄젠트 표고)."""
|
||||
return np.interp(targets, pvi_s, pvi_z)
|
||||
|
||||
|
||||
#: 반올림만 다른 같은 측점(m) — 관 측점 0.001m(자동설계) ↔ 0.01m(화면 저장) 차이.
|
||||
#: 화면 `SAME_STATION_SNAP_M`(B05_Profile_UI_Profile_Alignment.ts) 와 짝.
|
||||
SAME_STATION_SNAP_M = 0.006
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
「노선 그대로?」 · 범위.
|
||||
- 같은 자리(`line_matcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안
|
||||
(측점 스냅이 뒤 교점을 밀어도 이어받음).
|
||||
- 자동 꼭짓점(`carry_base`) · 편집(`carry_edits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면
|
||||
- 자동 꼭짓점(`carry_base`) · 편집(Node `carry_seat`)은 **새 측점 위에** 다시 놓음 — 같은 자리면
|
||||
옛 선 그 자리 높이 · 아니면 새 지반 첫 계획선 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 ·
|
||||
R 은 가까운 측점.
|
||||
R 은 가까운 측점. 편집 셈 = TS `carryEdits`(서버 Node · 511-B B-3) — 파이썬 짝 없음.
|
||||
|
||||
짝 = TS `B05_Profile/logic/B05_Profile_Grade_Carry.ts`(같은 규칙) · 거울 시험 =
|
||||
`resources/tester/test_400_profile_carry.py` · `test_344_2_route_pairs.py`.
|
||||
@@ -25,14 +25,10 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key, resolve_pvi
|
||||
|
||||
# 같은 자리로 볼 옆 어긋남(m) — 브레인 정함 10-17 · 바꿀 수 있음 · TS `SAME_LINE_TOL_M` 짝.
|
||||
SAME_LINE_TOL_M = 1.0
|
||||
# 같은 자리로 볼 방향 차(cos 30°) — 엇갈려 지나는 선 · 머리핀 맞은편을 뺌.
|
||||
_SAME_DIRECTION_COS = 0.8660254037844386
|
||||
# 꼭짓점 위로 볼 거리(m) — `SAME_STATION_SNAP_M` 짝.
|
||||
_ON_NODE_M = 0.006
|
||||
# 같은 정점으로 볼 거리(m) — 저장 좌표 0.0001 m 자리 · 원호 다시 뽑기 잡음보다 크게.
|
||||
SAME_VERTEX_M = 1e-3
|
||||
_EPS = 1e-6
|
||||
@@ -146,67 +142,6 @@ def carry_base(
|
||||
return out
|
||||
|
||||
|
||||
def carry_edits(
|
||||
old_xy: list[tuple[float, float]],
|
||||
new_xy: list[tuple[float, float]],
|
||||
old_base: list[dict[str, float]],
|
||||
edits: dict[str, Any] | None,
|
||||
stations: list[float],
|
||||
half_m: float,
|
||||
tol_m: float = SAME_LINE_TOL_M,
|
||||
) -> dict[str, dict[str, float]]:
|
||||
"""새 편집 — 새 측점마다 같은 자리 · 옛 사용자 꼭짓점이 닿은 자리면 옛 계획선 − 옛 자동선.
|
||||
|
||||
닿은 자리 = 꼭짓점 위(그 꼭짓점이 사용자) · 앞뒤 꼭짓점 하나가 사용자. 종단곡선 R = 옛 키에서
|
||||
`half_m` 안 가장 가까운 같은 자리 측점. 새 자동 꼭짓점이 같은 자리에서 옛 자동선
|
||||
높이(`carry_base`)라 계획고가 그대로.
|
||||
"""
|
||||
out: dict[str, dict[str, float]] = {"station_offsets": {}, "curve_radii": {}}
|
||||
if not old_base:
|
||||
return out
|
||||
match = line_matcher(old_xy, new_xy, tol_m)
|
||||
mapped: list[tuple[float, float]] = []
|
||||
for t in sorted({round(float(v), 3) for v in stations if math.isfinite(float(v))}):
|
||||
s = match(t)
|
||||
if s is not None:
|
||||
mapped.append((t, s))
|
||||
base_s = np.array([float(n["chainage_m"]) for n in old_base], dtype=np.float64)
|
||||
base_z = np.array([float(n["elevation_m"]) for n in old_base], dtype=np.float64)
|
||||
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, (edits or {}).get("station_offsets") or {})
|
||||
|
||||
def touched(s: float) -> bool:
|
||||
on = np.nonzero(np.abs(pvi_s - s) < _ON_NODE_M)[0]
|
||||
if len(on):
|
||||
return sources[int(on[0])] == "user"
|
||||
ahead = np.nonzero(pvi_s > s)[0]
|
||||
nxt = int(ahead[0]) if len(ahead) else -1
|
||||
return nxt > 0 and (sources[nxt - 1] == "user" or sources[nxt] == "user")
|
||||
|
||||
for t, s in mapped:
|
||||
if touched(s):
|
||||
out["station_offsets"][chainage_key(t)] = round(
|
||||
float(np.interp(s, pvi_s, pvi_z)) - float(np.interp(s, base_s, base_z)), 6
|
||||
)
|
||||
gaps: dict[str, float] = {}
|
||||
for key, value in ((edits or {}).get("curve_radii") or {}).items():
|
||||
try:
|
||||
chainage = float(key)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not math.isfinite(chainage) or value is None:
|
||||
continue
|
||||
slot, best = None, math.inf
|
||||
for t, s in mapped:
|
||||
gap = round(abs(s - chainage), 3) # mm — 가운데 같은 거리면 앞 측점
|
||||
if gap <= half_m and gap < best:
|
||||
slot, best = chainage_key(t), gap
|
||||
if slot is None or best >= gaps.get(slot, math.inf):
|
||||
continue
|
||||
gaps[slot] = best
|
||||
out["curve_radii"][slot] = float(value)
|
||||
return out
|
||||
|
||||
|
||||
def carry_policy(
|
||||
old_xy: list[tuple[float, float]],
|
||||
new_xy: list[tuple[float, float]],
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
경고만 · 비포장 대수차 5 % 이하(생략 가능)는 뺌 · 45 % 규칙으로 줄어든 곡선도 같은 잣대.
|
||||
|
||||
짝: TS `logic/B05_Profile_Profile_CurveLaw.ts` — 거울 시험 `test_432_1_curve_law.py`.
|
||||
남는 까닭(511-B B-6) — 종단곡선 법칙 정본 = Node `profile.alignment` 안 · 이 짝 = 추천 엔진 첫
|
||||
계획선 · 2~4 순위 폴백.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""종단 계획선 셈 = 서버 Node(PLAN 511-B B-3) — 파이썬은 입력을 모으고 정본 꼴만 적음.
|
||||
|
||||
셈 = TS 한 벌(`design_calc_node_profile.ts` → `B05_Profile/logic/` 의
|
||||
`B05_Profile_UI_Profile_Alignment.ts` `buildAlignment` · `B05_Profile_Grade_Ground.ts`
|
||||
`groundFollowingNodes` · `B05_Profile_Grade_Carry.ts` `carryEdits` · `B05_Profile_Pipeline_Grade.ts`
|
||||
`seatRouteMoved`) · 늘 떠 있는 Node(`run_calc_served`).
|
||||
Node 출력은 반올림 전 값 — 정본 꼴(0.000001 m · 칸 차례 · 실수형)은 `alignment_record` 가 적음
|
||||
(옛 파이썬 `build_alignment` 출력과 같은 글). 파이썬 `build_alignment` 은 1순위 못 선 땅의
|
||||
2~4 순위 폴백(관 정착 · 직선 분할)만 씀. 시험 = `resources/tester/test_511_b3_profile_node.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ALIGNMENT_SCHEMA_VERSION
|
||||
from common_util.common_util_calc_runner import run_calc_served
|
||||
|
||||
|
||||
def run_profile(ids: list[str], inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""종단 id 를 한 요청으로 · Node 못 돌림 · 셈 실패는 RuntimeError(파이썬 짝 없음)."""
|
||||
answer = run_calc_served(ids, inputs)
|
||||
if not answer or "results" not in answer:
|
||||
reason = (answer or {}).get("error") or "Node 응답 없음"
|
||||
raise RuntimeError(f"종단 계산 Node 실패({', '.join(ids)}) — {reason}")
|
||||
return answer["results"]
|
||||
|
||||
|
||||
def _r6(value: Any) -> float:
|
||||
return round(float(value), 6)
|
||||
|
||||
|
||||
def alignment_record(
|
||||
raw: dict[str, Any], policy: dict[str, Any], *, only_explicit_curves: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Node `buildAlignment` 출력 → 저장 정본 꼴(옛 `build_alignment` 반환 · 칸 · 차례 · 반올림)."""
|
||||
segments = [
|
||||
{
|
||||
"index": int(row["index"]),
|
||||
"from_m": _r6(row["from_m"]),
|
||||
"to_m": _r6(row["to_m"]),
|
||||
"length_m": _r6(row["length_m"]),
|
||||
"height_m": _r6(row["height_m"]),
|
||||
"grade_percent": _r6(row["grade_percent"]),
|
||||
}
|
||||
for row in raw["segments"]
|
||||
]
|
||||
pvi = []
|
||||
for index, row in enumerate(raw["pvi"]):
|
||||
pvi.append(
|
||||
{
|
||||
"chainage_m": _r6(row["chainage_m"]),
|
||||
"elevation_m": _r6(row["elevation_m"]),
|
||||
"source": row["source"],
|
||||
"kind": row["kind"],
|
||||
"grade_in_pct": _r6(segments[index - 1]["grade_percent"]) if index else None,
|
||||
"grade_out_pct": (
|
||||
_r6(segments[index]["grade_percent"]) if index < len(segments) else None
|
||||
),
|
||||
"curve_l_m": None if row["curve_l_m"] is None else _r6(row["curve_l_m"]),
|
||||
"curve_r_m": None if row["curve_r_m"] is None else _r6(row["curve_r_m"]),
|
||||
}
|
||||
)
|
||||
max_grade = float(policy["max_grade_pct"])
|
||||
stations = []
|
||||
for row in raw["stations"]:
|
||||
difference = float(row["ground_elevation_m"]) - float(row["plan_elevation_m"])
|
||||
stations.append(
|
||||
{
|
||||
"station_id": row["station_id"],
|
||||
"chainage_m": _r6(row["chainage_m"]),
|
||||
"distance_m": _r6(row["distance_m"]),
|
||||
"ground_elevation_m": _r6(row["ground_elevation_m"]),
|
||||
"plan_elevation_m": _r6(row["plan_elevation_m"]),
|
||||
"cut_m": _r6(max(difference, 0.0)),
|
||||
"fill_m": _r6(max(-difference, 0.0)),
|
||||
}
|
||||
)
|
||||
balance = raw["balance"]
|
||||
cut, fill = float(balance["cut_area_m2"]), float(balance["fill_area_m2"])
|
||||
return {
|
||||
"schema_version": ALIGNMENT_SCHEMA_VERSION,
|
||||
"policy": policy,
|
||||
"only_explicit_curves": bool(only_explicit_curves),
|
||||
"base_pvi": [
|
||||
{"chainage_m": _r6(node["chainage_m"]), "elevation_m": _r6(node["elevation_m"])}
|
||||
for node in raw["base_pvi"]
|
||||
],
|
||||
"edits": {
|
||||
name: {str(key): float(value) for key, value in raw["edits"][name].items()}
|
||||
for name in ("station_offsets", "curve_radii")
|
||||
},
|
||||
"pvi": pvi,
|
||||
"segments": segments,
|
||||
"curves": [
|
||||
{
|
||||
"pvi_index": int(curve["pvi_index"]),
|
||||
**{
|
||||
name: _r6(curve[name])
|
||||
for name in (
|
||||
"chainage_m",
|
||||
"bvc_m",
|
||||
"evc_m",
|
||||
"bvc_elevation_m",
|
||||
"evc_elevation_m",
|
||||
"l_m",
|
||||
"r_m",
|
||||
"k",
|
||||
"delta_pct",
|
||||
"middle_ordinate_m",
|
||||
)
|
||||
},
|
||||
"omitted": bool(curve["omitted"]),
|
||||
"skip_allowed": bool(curve["skip_allowed"]),
|
||||
"omit_reason": curve["omit_reason"],
|
||||
}
|
||||
for curve in raw["curves"]
|
||||
],
|
||||
"stations": stations,
|
||||
"samples": [
|
||||
{
|
||||
"chainage_m": _r6(sample["chainage_m"]),
|
||||
"elevation_m": _r6(sample["elevation_m"]),
|
||||
"ground_elevation_m": _r6(sample["ground_elevation_m"]),
|
||||
"difference_m": _r6(
|
||||
float(sample["elevation_m"]) - float(sample["ground_elevation_m"])
|
||||
),
|
||||
}
|
||||
for sample in raw["samples"]
|
||||
],
|
||||
"balance": {
|
||||
"cut_area_m2": _r6(cut),
|
||||
"fill_area_m2": _r6(fill),
|
||||
"net_area_m2": _r6(fill - cut),
|
||||
"imbalance_percent": _r6(balance["imbalance_percent"]),
|
||||
"tolerance_percent": policy["balance_tolerance_percent"],
|
||||
"within_tolerance": bool(balance["within_tolerance"]),
|
||||
},
|
||||
"violations": [
|
||||
{
|
||||
"segment_index": row["index"],
|
||||
"type": "grade_over",
|
||||
"value": row["grade_percent"],
|
||||
"limit": policy["max_grade_pct"],
|
||||
}
|
||||
for row in segments
|
||||
if abs(row["grade_percent"]) > max_grade + 1e-6
|
||||
],
|
||||
"warnings": list(raw["warnings"]),
|
||||
}
|
||||
|
||||
|
||||
def node_alignment(
|
||||
chainage: Any,
|
||||
ground: Any,
|
||||
stations: list[dict[str, Any]],
|
||||
policy: dict[str, Any],
|
||||
edits: dict[str, Any] | None,
|
||||
*,
|
||||
base_pvi: list[dict[str, Any]] | None = None,
|
||||
ground_following: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""계획선 정본 — 꼭짓점(`base_pvi`) 또는 첫 계획선(`ground_following`) 위에 편집을 얹음.
|
||||
|
||||
첫 계획선이 못 서는 땅이면 None(부르는 쪽 2~4 순위). 편집 None 값 · 글 값은 옛 파이썬처럼 거름.
|
||||
"""
|
||||
edits = edits or {}
|
||||
clean = {
|
||||
name: {
|
||||
str(key): float(value)
|
||||
for key, value in (edits.get(name) or {}).items()
|
||||
if value is not None
|
||||
}
|
||||
for name in ("station_offsets", "curve_radii")
|
||||
}
|
||||
profile: dict[str, Any] = {
|
||||
"chainage": [float(value) for value in chainage],
|
||||
"ground": [float(value) for value in ground],
|
||||
"policy": policy,
|
||||
"stations": [
|
||||
{"station_id": row.get("station_id"), "chainage_m": float(row["chainage_m"])}
|
||||
for row in stations
|
||||
],
|
||||
"edits": clean,
|
||||
}
|
||||
if base_pvi is not None:
|
||||
profile["base_pvi"] = [
|
||||
{"chainage_m": float(node["chainage_m"]), "elevation_m": float(node["elevation_m"])}
|
||||
for node in base_pvi
|
||||
]
|
||||
if ground_following is not None:
|
||||
profile["ground_following"] = ground_following
|
||||
raw = run_profile(["profile.alignment"], {"profile": profile})["profile.alignment"]
|
||||
return None if raw is None else alignment_record(raw, policy)
|
||||
|
||||
|
||||
def carry_seat(
|
||||
old_xy: list[Any],
|
||||
new_xy: list[Any],
|
||||
old_base: list[dict[str, Any]],
|
||||
edits: dict[str, Any] | None,
|
||||
stations: list[float],
|
||||
half_m: float,
|
||||
seat: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, dict[str, float]], list[dict[str, Any]] | None]:
|
||||
"""노선 바뀜 계획고 옮김 + 옮긴 시설 꼭짓점 세움 = Node 한 부름(`profile.carry`).
|
||||
|
||||
`seat` = { ground_s · ground_z · policy · base_pvi · born } · 없으면 꼭짓점 None.
|
||||
"""
|
||||
carry = {
|
||||
"old_xy": [[float(x), float(y)] for x, y in old_xy],
|
||||
"new_xy": [[float(x), float(y)] for x, y in new_xy],
|
||||
"old_base": old_base,
|
||||
"edits": edits or {},
|
||||
"stations": [float(value) for value in stations],
|
||||
"half_m": float(half_m),
|
||||
"seat": seat,
|
||||
}
|
||||
out = run_profile(["profile.carry"], {"carry_profile": carry})["profile.carry"]
|
||||
moved = {
|
||||
name: {str(key): float(value) for key, value in out["edits"][name].items()}
|
||||
for name in ("station_offsets", "curve_radii")
|
||||
}
|
||||
return moved, out["base_pvi"]
|
||||
@@ -24,6 +24,8 @@
|
||||
`B05_Profile/logic/B05_Profile_Grade_Ground.ts` `groundFollowingBasePvi` · 노선 바뀜 때 계획고 옮김은
|
||||
`B05_Profile_Engine_Grade_Carry.py` ↔ `logic/B05_Profile_Grade_Carry.ts` · 거울 시험 =
|
||||
`resources/tester/test_344_2_route_pairs.py`.
|
||||
남는 까닭(511-B B-6) — 1순위 첫 계획선 · 종단 다시 셈 = Node(`Engine_Grade_Node`) · 이 짝 = 2~4 순위
|
||||
폴백 · 추천 엔진 · 정본 꼴 입력.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
@@ -48,6 +50,7 @@ from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
|
||||
evaluate,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import flat_bends, straighten_held
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Node import node_alignment
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Solver import (
|
||||
grade_limits,
|
||||
integration_weights,
|
||||
@@ -275,18 +278,22 @@ def design_ground_following_profile(
|
||||
if note:
|
||||
warnings.append(note)
|
||||
|
||||
# 초기값 직선화(438) — 기본 L 기준 R > 500 m 꼭짓점 묶음은 앞뒤 꺾임을 잇는 직선 위
|
||||
base_z = straight_pass_through(base_s, base_z, policy)
|
||||
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
chainage=chainage,
|
||||
ground=ground,
|
||||
stations=stations,
|
||||
policy=policy,
|
||||
edits=dict(edits or {}),
|
||||
# 꼭짓점(초기값 직선화 438 · 원지반 지남) · 선형 = 서버 Node 한 부름(511-B B-3 · TS
|
||||
# `groundFollowingNodes` → `buildAlignment`) · 못 서는 땅이면 ValueError → 2~4 순위 폴백
|
||||
alignment = node_alignment(
|
||||
chainage,
|
||||
ground,
|
||||
stations,
|
||||
policy.as_dict(),
|
||||
edits,
|
||||
ground_following={
|
||||
"stations": [float(value) for value in base_s],
|
||||
"start_offset_m": float(options.start_elevation_offset_m),
|
||||
"end_offset_m": float(options.end_elevation_offset_m),
|
||||
},
|
||||
)
|
||||
if alignment is None:
|
||||
raise ValueError("첫 계획선 꼭짓점을 세울 수 없는 종단입니다.")
|
||||
warnings.extend(alignment["warnings"])
|
||||
balanced = bool(alignment["balance"]["within_tolerance"])
|
||||
return alignment, _profile_entry(
|
||||
@@ -549,8 +556,6 @@ def rebuild_alignment_profile(
|
||||
|
||||
chainage, ground = ground_profile(longitudinal)
|
||||
base = stored["base_pvi"]
|
||||
base_s = np.array([float(item["chainage_m"]) for item in base], dtype=np.float64)
|
||||
base_z = np.array([float(item["elevation_m"]) for item in base], dtype=np.float64)
|
||||
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
|
||||
|
||||
previous = (longitudinal.get("design_profiles") or [{}])[0]
|
||||
@@ -564,14 +569,14 @@ def rebuild_alignment_profile(
|
||||
min_curve_length_m=policy.min_curve_length_m
|
||||
or float(criteria.get("min_curve_length_m") or 0.0),
|
||||
)
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
chainage=chainage,
|
||||
ground=ground,
|
||||
stations=list(longitudinal.get("stations") or []),
|
||||
policy=policy,
|
||||
edits=edits,
|
||||
# 선형 = 서버 Node(511-B B-3 · TS `buildAlignment`) · 정본 꼴은 `alignment_record`
|
||||
alignment = node_alignment(
|
||||
chainage,
|
||||
ground,
|
||||
list(longitudinal.get("stations") or []),
|
||||
policy.as_dict(),
|
||||
edits,
|
||||
base_pvi=base,
|
||||
)
|
||||
options = GradeDesignOptions(
|
||||
max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct),
|
||||
|
||||
@@ -35,11 +35,18 @@ SOURCE_GLOBS = (
|
||||
"B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py",
|
||||
"B05_Profile/B05_Profile_Route_Save_Parts.py",
|
||||
"B05_Profile/B05_Profile_Plus_Stations.py",
|
||||
"common_util/common_util_route_polyline.py",
|
||||
"common_util/common_util_route_polyline*.py",
|
||||
"common_util/common_util_route_follow.py",
|
||||
"common_util/common_util_structure_station.py",
|
||||
"common_util/common_util_drainage_pipes.py",
|
||||
"config/config_system_design.py",
|
||||
# 측점 · 지반 = Node `stations.sections`(511-B B-2) — 묶음을 낳는 TS
|
||||
"B05_Profile/logic/B05_Profile_Sections_*.ts",
|
||||
"B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts",
|
||||
"common_util/common_util_structure_station.ts",
|
||||
"common_util/common_util_structure_catalog.ts",
|
||||
"common_util/common_util_surface_sampler.ts",
|
||||
"A00_Common/design/logic/design_calc_node_sections.ts",
|
||||
)
|
||||
# 직선 위 꼭짓점(438 직선화) 판정 — 앞뒤 기울기 차(비율)
|
||||
_STRAIGHT_EPS = 1e-5
|
||||
@@ -126,12 +133,19 @@ def initial_longitudinal(
|
||||
) -> dict[str, Any]:
|
||||
"""[적용] 체인이 남길 종단(측점 · 계획선) — 측점 생성 · 균형 얹기와 같은 조각."""
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
from B05_Profile.B05_Profile_Engine_Sections import build_sections, resolve_extra_stations
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
build_sections,
|
||||
resolve_extra_stations,
|
||||
station_facilities,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import seat_base_pvi
|
||||
|
||||
polyline = [[x, y, 0.0] for x, y in line]
|
||||
pipes, structures, plus = facilities
|
||||
extras = resolve_extra_stations(project_root, pipes, polyline, structures=structures, plus=plus)
|
||||
planted = station_facilities(project_root, pipes, polyline, structures=structures, plus=plus)
|
||||
extras = resolve_extra_stations(
|
||||
project_root, planted[0], structures=planted[1], plus=planted[2]
|
||||
)
|
||||
result, _summary = build_sections(
|
||||
polyline,
|
||||
sampler,
|
||||
@@ -144,6 +158,7 @@ def initial_longitudinal(
|
||||
design_curves=curves,
|
||||
station_snap=True, # [적용] = 스냅 켬 · 측점 No.(`saveRecommendedRoute`)
|
||||
station_snap_step=None,
|
||||
facilities=planted,
|
||||
)
|
||||
longitudinal = result["longitudinal"]
|
||||
if target is not None and longitudinal.get("profile_alignment"):
|
||||
|
||||
@@ -47,7 +47,7 @@ SOURCE_GLOBS = (
|
||||
"B05_Profile/B05_Profile_Engine_RouteInitial*.py",
|
||||
"B05_Profile/B05_Profile_Engine_Sections_Core.py",
|
||||
"B06_Section/B06_Section_Engine_Design*.py",
|
||||
"common_util/common_util_route_polyline.py",
|
||||
"common_util/common_util_route_polyline*.py",
|
||||
)
|
||||
STORE_DIR = "B05_Profile/route_recommend"
|
||||
# 종류(갈래 · 지형) 마다 남길 벌 수 — 최근에 적은 것이 남음(꺼내기는 읽기만 · 505-1)
|
||||
|
||||
@@ -34,6 +34,7 @@ from B05_Profile.B05_Profile_Engine_Sections_Names import ( # noqa: F401 —
|
||||
prune_stale_cross_files,
|
||||
valid_cross_names,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Node import node_sections
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
structure_type_map,
|
||||
@@ -257,6 +258,24 @@ def resolve_extra_stations(
|
||||
`xy` = 지금 노선 — 노선 밖(시점 앞 · 종점 뒤 · `route_outside`) 관 · 구조물은 뺌(459).
|
||||
`structures` · `plus` = 파일 대신 따라간 사본(476 추천 초기 종단 · 파일 안 씀).
|
||||
"""
|
||||
return tuple(
|
||||
(float(station["chainage_m"]), str(station["title"]))
|
||||
for station in plan_stations(
|
||||
*station_facilities(project_root, pipes, xy, structures=structures, plus=plus)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def station_facilities(
|
||||
project_root: Path,
|
||||
pipes: list[PipePoint],
|
||||
xy: Sequence[Sequence[float]] | None = None,
|
||||
*,
|
||||
structures: list[Any] | None = None,
|
||||
plus: list[float] | None = None,
|
||||
) -> tuple[list[PipePoint], list[Any], list[float]]:
|
||||
"""측점을 심는 관 · 구조물 · 추가 측점(노선 밖 뺌) — 파이썬 `plan_stations` ·
|
||||
Node 측점 생성이 같은 입력."""
|
||||
from B05_Profile.B05_Profile_Plus_Stations import plus_chainages
|
||||
from common_util.common_util_route_follow import route_outside
|
||||
|
||||
@@ -273,12 +292,7 @@ def resolve_extra_stations(
|
||||
for s in structures
|
||||
if not route_outside(getattr(s, "x", None), getattr(s, "y", None), xy)
|
||||
]
|
||||
return tuple(
|
||||
(float(station["chainage_m"]), str(station["title"]))
|
||||
for station in plan_stations(
|
||||
pipes, structures, plus_chainages(project_root) if plus is None else plus
|
||||
)
|
||||
)
|
||||
return pipes, structures, plus_chainages(project_root) if plus is None else list(plus)
|
||||
|
||||
|
||||
def _append_design_profiles(
|
||||
@@ -408,21 +422,28 @@ def build_sections(
|
||||
design_curves: list[dict[str, Any]],
|
||||
station_snap: bool,
|
||||
station_snap_step: float | None,
|
||||
facilities: tuple[list[Any], list[Any], list[float]] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
"""측점 틀 · 지반 · 1차 계획선(438) · 포장 제안 — 파일 안 씀 · (결과, 계획선 요약).
|
||||
|
||||
종횡단 생성(`run_section_generation`)과 추천 후보 초기 종단(476 · `..._RouteInitial_Initial`)
|
||||
이 같은 셈 한 벌."""
|
||||
result = generate_sections(
|
||||
polyline,
|
||||
sampler,
|
||||
replace(options or SectionGenerationOptions(), extra_stations=extras),
|
||||
source_snapshot=source_snapshot,
|
||||
crs=crs,
|
||||
design_curves=design_curves,
|
||||
station_snap=station_snap,
|
||||
station_snap_step=station_snap_step,
|
||||
)
|
||||
이 같은 셈 한 벌. 측점 · 지반 = 화면과 같은 TS(Node `stations.sections` · 511-B B-2 ·
|
||||
`facilities` = `station_facilities`) · DTM 아닌 지표면 · Node 못 돌림 =
|
||||
파이썬 `generate_sections`."""
|
||||
generation = replace(options or SectionGenerationOptions(), extra_stations=extras)
|
||||
shared = {
|
||||
"crs": crs,
|
||||
"design_curves": design_curves,
|
||||
"station_snap": station_snap,
|
||||
"station_snap_step": station_snap_step,
|
||||
}
|
||||
result = (
|
||||
node_sections(
|
||||
polyline, sampler, facilities, generation, source_snapshot=source_snapshot, **shared
|
||||
)
|
||||
if facilities is not None
|
||||
else None
|
||||
) or generate_sections(polyline, sampler, generation, source_snapshot=source_snapshot, **shared)
|
||||
grade_summary = _append_design_profiles(
|
||||
result["longitudinal"],
|
||||
grade_options,
|
||||
@@ -475,10 +496,13 @@ def run_section_generation(
|
||||
# 값이라, 되먹이면 B04에서 지운 관이 파일 정본을 이기고 되살아난다.
|
||||
pipes = _load_pipe_points(project_root, polyline)
|
||||
try:
|
||||
extras = resolve_extra_stations(project_root, pipes, polyline)
|
||||
facilities = station_facilities(project_root, pipes, polyline)
|
||||
extras = resolve_extra_stations(
|
||||
project_root, facilities[0], structures=facilities[1], plus=facilities[2]
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B05 구조물 측점 해석 실패 (규칙 격자만 생성)")
|
||||
extras = ()
|
||||
facilities, extras = ([], [], []), ()
|
||||
logger.info("B05 종횡단: 구조물 측점 %d건 파생(관 %d건)", len(extras), len(pipes))
|
||||
|
||||
result, grade_summary = build_sections(
|
||||
@@ -495,6 +519,7 @@ def run_section_generation(
|
||||
design_curves=_load_design_curves(project_root),
|
||||
station_snap=load_station_snap(project_root),
|
||||
station_snap_step=load_station_snap_step(project_root),
|
||||
facilities=facilities,
|
||||
)
|
||||
|
||||
stage_root = project_root / _STAGE_SUBDIR
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
(`buildStations`). 거울 시험 `resources/tester/test_344_3_pipeline_stations_mirror.py`.
|
||||
⚠ 파이썬·TS 짝 — `generate_sections` 상단측(`uphill_side`) ↔ `B05_Profile/logic/B05_Profile_Pipeline_Detail.ts`
|
||||
(`uphillSide`). 거울 시험 `resources/tester/test_346_2_pipeline_cross_page.py`.
|
||||
서버 측점 생성 길(`build_sections`)은 DTM 이면 같은 TS(Node `stations.sections` · 511-B B-2 ·
|
||||
`B05_Profile_Engine_Sections_Node.py`) — 이 짝을 쓰는 곳 = DTM 아닌 지표면 · Node 못 돌림 ·
|
||||
구조물 측점만 뜨기(`sample_structure_sections`) · B06 곡선 측점 판정 · 추천 엔진(`_plan_radii` 등).
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""서버 측점 생성 = 화면과 같은 TS(PLAN 511-B B-2 · 요구 320-4) — 늘 떠 있는 Node
|
||||
`stations.sections`.
|
||||
|
||||
`build_sections`(B03 체인 · 노선 저장 · 재생성 · 넓힘 · 추천 초기 종단)가 부름 — 측점 자리 ·
|
||||
종류 · 열쇠 · 반경 · 확폭 · 지반 · 틀 · 상단측 =
|
||||
`B05_Profile/logic/B05_Profile_Sections_Generate.ts`
|
||||
(화면 묶음 stations · ground 단계와 같은 함수). 파이썬은 머리(schema_version · source ·
|
||||
coordinate_reference · cad_exchange · options)만 붙임.
|
||||
|
||||
DTM 지표면만(격자 이진 = `dtm_grid_file`) — DTM 아닌 지표면(tin · nurbs · implicit · meshfree) ·
|
||||
Node 못 돌림 = None → 부르는 쪽이 파이썬 `generate_sections`(짝 · 로그 남김).
|
||||
측점 열쇠(`key`) = 노선 저장 이월 열쇠(`B05_Profile_Route_Save._carry_cross`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
||||
SECTION_SCHEMA_VERSION,
|
||||
SectionGenerationOptions,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def plan_pipe(pipe: Any) -> dict[str, Any]:
|
||||
"""관 정본 → TS `PlanPipe`(누가거리 그대로 · 파이썬 `plan_stations` 가 읽는 칸)."""
|
||||
return {
|
||||
"id": getattr(pipe, "id", None),
|
||||
"chainage_m": float(pipe.chainage_m),
|
||||
"facility": getattr(pipe, "facility", None) or "pipe",
|
||||
"start_m": getattr(pipe, "start_m", None),
|
||||
"end_m": getattr(pipe, "end_m", None),
|
||||
"options": getattr(pipe, "options", None) or {},
|
||||
}
|
||||
|
||||
|
||||
def plan_structure(item: Any) -> dict[str, Any]:
|
||||
"""구조물 정본 → TS `PlanStructure`(JSON 꼴)."""
|
||||
if hasattr(item, "model_dump"):
|
||||
return item.model_dump(mode="json")
|
||||
return dict(item) if isinstance(item, dict) else dict(vars(item))
|
||||
|
||||
|
||||
def structure_table() -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
"""구조물 종류(`/projects/structure-types` 꼴) · 정의(화면 `useCatalogDefinition` 꼴)."""
|
||||
from B05_Profile.B05_Profile_Structures_Schema import _read_definition, _stamp
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible
|
||||
|
||||
types = [item.model_dump(mode="json") for item in with_visible().types]
|
||||
return types, {"types": types, **_read_definition(*_stamp())}
|
||||
|
||||
|
||||
def stations_input(
|
||||
xy: list[list[float]],
|
||||
pipes: list[Any],
|
||||
structures: list[Any],
|
||||
plus: list[float],
|
||||
options: SectionGenerationOptions,
|
||||
design_curves: list[dict[str, Any]],
|
||||
station_snap: bool,
|
||||
station_snap_step: float | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Node `stations.list` · `stations.sections` 입력(격자 빼고)."""
|
||||
types, definition = structure_table()
|
||||
return {
|
||||
"line": xy,
|
||||
"curves": design_curves,
|
||||
"pipes": [plan_pipe(pipe) for pipe in pipes],
|
||||
"structures": [plan_structure(item) for item in structures],
|
||||
"types": types,
|
||||
"definition": definition,
|
||||
"plus": [float(at) for at in plus],
|
||||
"interval_m": options.station_interval_m,
|
||||
"include_endpoint": options.include_endpoint,
|
||||
"station_snap": station_snap,
|
||||
"station_snap_step": station_snap_step,
|
||||
"cross_half_width_m": options.cross_half_width_m,
|
||||
"cross_sample_interval_m": options.cross_sample_interval_m,
|
||||
"long_sample_interval_m": options.long_sample_interval_m,
|
||||
}
|
||||
|
||||
|
||||
def node_stations(stations: dict[str, Any]) -> list[dict[str, Any]] | None:
|
||||
"""측점 목록(`buildStations` · 열쇠 · 누가거리 · 종류) — 못 돌림 None."""
|
||||
from common_util.common_util_calc_runner import run_calc_served
|
||||
|
||||
answer = run_calc_served(["stations.list"], {"stations": stations})
|
||||
if not answer or "error" in answer:
|
||||
logger.warning("Node 측점 목록 실패: %s", (answer or {}).get("error"))
|
||||
return None
|
||||
return answer["results"]["stations.list"]
|
||||
|
||||
|
||||
def node_plan_stations(
|
||||
groups: list[tuple[list[Any], list[Any], list[float]]],
|
||||
) -> list[list[dict[str, Any]]] | None:
|
||||
"""(관 · 구조물 · 추가 측점) 묶음마다 측점 계획(`planStations` · 파이썬 `plan_stations` 짝) —
|
||||
한 Node 부름 · 못 돌림 None(부르는 쪽이 파이썬 짝)."""
|
||||
from common_util.common_util_calc_runner import run_calc
|
||||
|
||||
if not groups:
|
||||
return []
|
||||
types, definition = structure_table()
|
||||
plan = [
|
||||
{
|
||||
"pipes": [plan_pipe(pipe) for pipe in pipes],
|
||||
"structures": [plan_structure(item) for item in structures],
|
||||
"plus": [float(at) for at in plus],
|
||||
}
|
||||
for pipes, structures, plus in groups
|
||||
]
|
||||
try:
|
||||
results = run_calc(
|
||||
["stations.plan"],
|
||||
{"stations": {"types": types, "definition": definition, "plan": plan}},
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 파이썬 짝으로 이어 감
|
||||
logger.exception("Node 측점 계획 실패 — 파이썬 길")
|
||||
return None
|
||||
if results is None:
|
||||
logger.warning("Node 측점 계획 못 돌림 — 파이썬 길")
|
||||
return None
|
||||
return results["stations.plan"]
|
||||
|
||||
|
||||
def node_sections(
|
||||
polyline: list[list[float]],
|
||||
sampler: Any,
|
||||
facilities: tuple[list[Any], list[Any], list[float]],
|
||||
options: SectionGenerationOptions,
|
||||
*,
|
||||
source_snapshot: dict[str, Any],
|
||||
crs: str | None,
|
||||
design_curves: list[dict[str, Any]],
|
||||
station_snap: bool,
|
||||
station_snap_step: float | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""`generate_sections` 와 같은 꼴(+ 측점 `key`) — DTM 아님 · Node 못 돌림 = None."""
|
||||
from common_util.common_util_calc_runner import run_calc_served
|
||||
from common_util.common_util_surface_sampler import dtm_grid_file
|
||||
|
||||
source = getattr(sampler, "source", None)
|
||||
if not source:
|
||||
return None
|
||||
options.validate()
|
||||
pipes, structures, plus = facilities
|
||||
stations = stations_input(
|
||||
[[float(p[0]), float(p[1])] for p in polyline],
|
||||
pipes,
|
||||
structures,
|
||||
plus,
|
||||
options,
|
||||
design_curves,
|
||||
station_snap,
|
||||
station_snap_step,
|
||||
)
|
||||
stations["grid"] = str(dtm_grid_file(source))
|
||||
answer = run_calc_served(["stations.sections"], {"stations": stations})
|
||||
if not answer or "error" in answer:
|
||||
logger.warning("Node 측점 생성 실패 — 파이썬 길: %s", (answer or {}).get("error"))
|
||||
return None
|
||||
out = answer["results"]["stations.sections"]
|
||||
return {
|
||||
"schema_version": SECTION_SCHEMA_VERSION,
|
||||
"status": "completed",
|
||||
"source": source_snapshot or {},
|
||||
"coordinate_reference": {
|
||||
"crs": crs,
|
||||
"world_axes": {"x": "project_easting", "y": "project_northing", "z": "elevation"},
|
||||
"units": {"horizontal": "m", "vertical": "m", "angle": "degree"},
|
||||
},
|
||||
"cad_exchange": {
|
||||
"station_origin": "BP",
|
||||
"chainage_direction": "BP_to_EP",
|
||||
"cross_offset_sign": {"negative": "right", "positive": "left"},
|
||||
"cross_local_axes": {"x": "offset_m", "y": "elevation_m"},
|
||||
"recommended_drawing_datum_m": out["datum"],
|
||||
},
|
||||
"options": {
|
||||
"station_interval_m": options.station_interval_m,
|
||||
"cross_half_width_m": options.cross_half_width_m,
|
||||
"cross_sample_interval_m": options.cross_sample_interval_m,
|
||||
"long_sample_interval_m": options.long_sample_interval_m,
|
||||
"include_endpoint": options.include_endpoint,
|
||||
"extra_stations": [list(entry) for entry in options.extra_stations],
|
||||
},
|
||||
"longitudinal": out["longitudinal"],
|
||||
"cross_sections": out["cross_sections"],
|
||||
"summary": out["summary"],
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/B05_Profile_Footprint_Server.ts
|
||||
* 계산 묶음 footprint 단계(D4) + 계산 자리 「서버」 길(PLAN 511-A) — 판 fixed_browser = server 면 잠잠 뒤 서버
|
||||
* (`footprint.plan` · 같은 `footprintStage` 전 구간)가 셈 · 끊긴 차례는 묶음이 버림 · 못 받으면 브라우저 셈.
|
||||
* 판 both = 지금 그대로(같은 틱 · 브라우저 셈).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StageContext } from "../A00_Common/design/logic/design_pipeline_types";
|
||||
import { calcOnServerNow } from "../A00_Common/design/design_calc_sites";
|
||||
import {
|
||||
calcChecked,
|
||||
calcOnServerOnly,
|
||||
trialRequest,
|
||||
} from "../A00_Common/design/design_calc_server";
|
||||
import { footprintStage } from "./logic/B05_Profile_Footprint_Stage";
|
||||
import type { FootprintLines } from "./logic/B05_Profile_PlanFootprint";
|
||||
|
||||
/** 단계가 읽는 몫만 — 측점(지반) · 측점 횡단 detail · 노선 선. */
|
||||
export function footprintInputs(ctx: StageContext<"footprint">): unknown {
|
||||
const byKey = ctx.out.cross?.byKey ?? {};
|
||||
const route = ctx.out.route;
|
||||
return {
|
||||
ground: { stations: ctx.out.ground?.stations ?? [] },
|
||||
cross: {
|
||||
byKey: Object.fromEntries(Object.entries(byKey).map(([k, v]) => [k, { detail: v.detail }])),
|
||||
},
|
||||
route: route ? { line: route.line, chainage: route.chainage, lengthM: route.lengthM } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function footprintStageSited(
|
||||
ctx: StageContext<"footprint">,
|
||||
): FootprintLines | Promise<FootprintLines> {
|
||||
if (!calcOnServerNow("fixed_browser"))
|
||||
return calcChecked("fixed_browser", "footprint.plan", footprintStage, [ctx]);
|
||||
const asked = trialRequest("footprint.plan", [footprintInputs(ctx)]);
|
||||
return calcOnServerOnly<FootprintLines>("fixed_browser", "D4", asked, ctx.signal).then(
|
||||
(lines) => lines ?? footprintStage(ctx),
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
곡선표 `curves` 는 **그린** R(접선 자리에 눌린 값)만 있어, 그것으로 다시 열면 눌린 R 이
|
||||
넣은 값으로 굳고 R · 길이 고정이 [저장] 뒤 사라짐 — 넣은 값을 따로 둠. 자리 = 저장 노드
|
||||
(앞 노드에서 0.5 m 안인 점은 `build_planned_polyline` 과 같이 거름).
|
||||
남는 까닭(511-B B-6) — 노선 저장 편집표 = Node `route.polyline` · 이 짝 = 단순화 갈래 · Node 못 돌림
|
||||
때.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""계획노선 폴리라인 파일 쓰기(`B05_Profile_Router_Replan` 에서 나눔 · PLAN 511-B B-5).
|
||||
|
||||
노드 · 곡선 켬끔 · R(편집 갈래) → 선 · 노드 · 곡선표(+ 편집표) = 늘 떠 있는 Node `route.polyline`
|
||||
(화면 `buildEditedPolyline` · `nodeEdits` 와 같은 TS) · 파이썬은 파일 꼴(csv 4자리 · 곡선 as_dict
|
||||
반올림)만. 단순화 갈래(예상노선 첫 폴리라인) · Node 못 돌림 = 파이썬 `build_planned_polyline` ·
|
||||
`node_edits`(짝 · 로그 남김).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Route_NodeEdits import node_edits
|
||||
from common_util.common_util_route_geometry import write_route_csv
|
||||
from common_util.common_util_route_polyline import (
|
||||
PlannedPolyline,
|
||||
RouteCurve,
|
||||
RouteNode,
|
||||
build_planned_polyline,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _nodes_path(path: Path) -> Path:
|
||||
"""그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`."""
|
||||
return path.with_name(f"{path.stem}_nodes.csv")
|
||||
|
||||
|
||||
def _curves_path(path: Path) -> Path:
|
||||
"""그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`.
|
||||
|
||||
왜 따로 남기나(2026-09-07 사용자 확정) — 계획노선은 「직선 > 곡선 > 직선」이고 사용자가
|
||||
잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터
|
||||
어디까지가 한 곡선인지, 그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 본다.
|
||||
"""
|
||||
return path.with_name(f"{path.stem}_curves.json")
|
||||
|
||||
|
||||
def _read_curves(path: Path, key: str = "curves") -> list[dict]:
|
||||
"""저장해 둔 곡선 성분(`key` = "edits" 면 넣은 편집값 · 430). 없거나 못 읽으면 빈 목록."""
|
||||
target = _curves_path(path)
|
||||
if not target.is_file():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target)
|
||||
return []
|
||||
curves = data.get(key) if isinstance(data, dict) else None
|
||||
return curves if isinstance(curves, list) else []
|
||||
|
||||
|
||||
def node_polyline(
|
||||
points: list[tuple[float, float]],
|
||||
radius_m: float,
|
||||
curve_flags: list[bool] | None,
|
||||
radii: list[float | None] | None,
|
||||
locks: list[str | None] | None,
|
||||
arcs: list[float | None] | None,
|
||||
) -> tuple[PlannedPolyline, list[dict[str, Any]]] | None:
|
||||
"""편집 갈래 폴리라인 + 편집표 = Node `route.polyline` — 못 돌림 None."""
|
||||
from common_util.common_util_calc_runner import run_calc
|
||||
|
||||
route = {
|
||||
"points": [[float(x), float(y)] for x, y in points],
|
||||
"curve_flags": curve_flags,
|
||||
"radii": radii,
|
||||
"locks": locks,
|
||||
"arcs": arcs,
|
||||
"min_radius_m": float(radius_m),
|
||||
}
|
||||
try:
|
||||
out = (run_calc(["route.polyline"], {"route_polyline": route}) or {}).get("route.polyline")
|
||||
except Exception: # noqa: BLE001 — 노선 저장은 파이썬 짝으로 이어 감
|
||||
logger.exception("Node 노선 폴리라인 실패 — 파이썬 길")
|
||||
return None
|
||||
if not out:
|
||||
logger.warning("Node 노선 폴리라인 못 돌림 — 파이썬 길")
|
||||
return None
|
||||
# JSON 정수(100) → float — 파이썬 길과 같은 글(`100.0`)로 적힘
|
||||
nodes = [
|
||||
RouteNode(
|
||||
x=float(x),
|
||||
y=float(y),
|
||||
inner_angle_deg=_num(info["inner_angle_deg"]),
|
||||
radius_m=_num(info["radius_m"]),
|
||||
tangent_m=_num(info["tangent_m"]),
|
||||
violations=list(info["violations"]),
|
||||
)
|
||||
for (x, y), info in zip(out["cleaned"], out["nodes"])
|
||||
]
|
||||
curves = [
|
||||
RouteCurve(
|
||||
apex=_xy(curve["apex"]),
|
||||
radius_m=float(curve["radius_m"]),
|
||||
tangent_m=float(curve["tangent_m"]),
|
||||
inner_angle_deg=float(curve["inner_angle_deg"]),
|
||||
start=_xy(curve["start"]),
|
||||
end=_xy(curve["end"]),
|
||||
node_first=int(curve["node_first"]),
|
||||
node_last=int(curve["node_last"]),
|
||||
violations=list(curve["violations"]),
|
||||
)
|
||||
for curve in out["curves"]
|
||||
]
|
||||
vertices = [_xy(vertex) for vertex in out["vertices"]]
|
||||
edits = [
|
||||
{**edit, "radius_m": _num(edit["radius_m"]), "arc_m": _num(edit["arc_m"])}
|
||||
for edit in out["edits"]
|
||||
]
|
||||
return PlannedPolyline(nodes=nodes, vertices=vertices, curves=curves), edits
|
||||
|
||||
|
||||
def _num(value: Any) -> float | None:
|
||||
return None if value is None else float(value)
|
||||
|
||||
|
||||
def _xy(pair: list[float]) -> tuple[float, float]:
|
||||
return float(pair[0]), float(pair[1])
|
||||
|
||||
|
||||
def _write_planned_polyline(
|
||||
path: Path,
|
||||
points: list[tuple[float, float]],
|
||||
radius_m: float,
|
||||
*,
|
||||
simplify: bool = True,
|
||||
curve_flags: list[bool] | None = None,
|
||||
radii: list[float | None] | None = None,
|
||||
station_snap: bool | None = None,
|
||||
station_snap_step: float | None = None,
|
||||
locks: list[str | None] | None = None,
|
||||
arcs: list[float | None] | None = None,
|
||||
) -> dict:
|
||||
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
|
||||
|
||||
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔) ·
|
||||
`station_snap_step` — 켬 + 간격이 있을 때만 곡선표에 적음(390-5 · 없으면 측점 No.).
|
||||
`locks` · `arcs` — 편집값이 오면 넣은 값을 곡선표 `edits` 에(430 · `_Route_NodeEdits`).
|
||||
|
||||
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
|
||||
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
|
||||
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
|
||||
"""
|
||||
edited = curve_flags is not None or radii is not None
|
||||
made = (
|
||||
node_polyline(points, radius_m, curve_flags, radii, locks, arcs)
|
||||
if edited and not simplify
|
||||
else None
|
||||
)
|
||||
if made is not None:
|
||||
result, edits = made
|
||||
else:
|
||||
result = build_planned_polyline(
|
||||
points,
|
||||
min_radius_m=radius_m,
|
||||
simplify=simplify,
|
||||
curve_flags=curve_flags,
|
||||
radii=radii,
|
||||
)
|
||||
edits = node_edits(points, curve_flags, radii, locks, arcs) if edited else None
|
||||
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
|
||||
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
|
||||
table: dict[str, Any] = {
|
||||
"min_radius_m": round(radius_m, 3),
|
||||
"curves": [c.as_dict() for c in result.curves],
|
||||
}
|
||||
if station_snap is not None:
|
||||
table["station_snap"] = bool(station_snap)
|
||||
if station_snap and station_snap_step is not None:
|
||||
table["station_snap_step"] = float(station_snap_step)
|
||||
if edits is not None:
|
||||
table["edits"] = edits
|
||||
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
|
||||
return {
|
||||
"nodes": len(result.nodes),
|
||||
"curves": result.curve_count,
|
||||
"violations": result.violation_count,
|
||||
"vertices": len(result.vertices),
|
||||
}
|
||||
@@ -51,8 +51,8 @@ from B05_Profile.B05_Profile_Route_Save_Parts import (
|
||||
add_crossing_pipes,
|
||||
carry_cross_inputs,
|
||||
follow_pipes,
|
||||
keyed_stations,
|
||||
route_moved_stations,
|
||||
seat_route_moved,
|
||||
station_keys,
|
||||
station_seats,
|
||||
vertices_of,
|
||||
@@ -255,12 +255,9 @@ async def _carry_profile(
|
||||
new_xy: list[list[float]],
|
||||
) -> None:
|
||||
"""같은 자리(옆 1 m 안) 옛 계획고 · R 을 새 측점 위에 · 바뀐 자리만 새 지반 첫 계획선(400)."""
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import (
|
||||
carry_base,
|
||||
carry_edits,
|
||||
carry_policy,
|
||||
changed_span,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import AlignmentPolicy
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_base, carry_policy, changed_span
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Node import carry_seat
|
||||
from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary
|
||||
from B05_Profile.B05_Profile_Router import _apply_alignment_edits
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
@@ -278,13 +275,22 @@ async def _carry_profile(
|
||||
stations = [float(row["chainage_m"]) for row in longitudinal.get("stations") or []]
|
||||
half = float((alignment.get("policy") or {}).get("station_interval_m") or 20.0) / 2
|
||||
base = carry_base(old_xy, new_xy, old_base, alignment["base_pvi"])
|
||||
edits = carry_edits(old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half)
|
||||
alignment["policy"] = carry_policy(old_xy, new_xy, old_alignment, alignment) # 옛 L · 열쇠(432)
|
||||
alignment["base_pvi"] = base
|
||||
if span is not None: # 옮긴 시설 측점에 427 꼭짓점 다시 세움(459 · 초안 planline 짝)
|
||||
born = route_moved_stations(longitudinal.get("stations") or [], old_xy, new_xy)
|
||||
held = [float(key) for key in (edits.get("station_offsets") or {})]
|
||||
alignment["base_pvi"] = seat_route_moved(alignment, born, held)
|
||||
if (
|
||||
span is not None
|
||||
): # 편집 옮김 + 옮긴 시설 측점 427 꼭짓점 다시 세움 = Node 한 부름(459 · 511-B)
|
||||
samples = alignment.get("samples") or []
|
||||
seat = {
|
||||
"ground_s": [float(row["chainage_m"]) for row in samples],
|
||||
"ground_z": [float(row["ground_elevation_m"]) for row in samples],
|
||||
"policy": AlignmentPolicy.from_dict(alignment["policy"] or {}).as_dict(),
|
||||
"base_pvi": base,
|
||||
"born": route_moved_stations(longitudinal.get("stations") or [], old_xy, new_xy),
|
||||
}
|
||||
edits, alignment["base_pvi"] = await asyncio.to_thread(
|
||||
carry_seat, old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half, seat
|
||||
)
|
||||
await asyncio.to_thread(atomic_write_json, path, longitudinal)
|
||||
_alignment, summary, _dropped = await asyncio.to_thread(
|
||||
_apply_alignment_edits, project_root, relative, edits
|
||||
@@ -331,8 +337,11 @@ async def _carry_cross(
|
||||
|
||||
new_long = _read_json(project_root / relative)
|
||||
_, new_structures = load_structures(str(project_root))
|
||||
old_keys = station_keys(old_long.get("stations") or [], old_pipes, old_structures, interval_m)
|
||||
new_keys = station_keys(
|
||||
# 열쇠 = Node 측점 출력(`key` · 511-B B-2) · 없으면(옛 파일 · DTM 아닌 지표면) 파이썬 열쇠
|
||||
old_keys = keyed_stations(old_long.get("stations") or []) or station_keys(
|
||||
old_long.get("stations") or [], old_pipes, old_structures, interval_m
|
||||
)
|
||||
new_keys = keyed_stations(new_long.get("stations") or []) or station_keys(
|
||||
new_long.get("stations") or [], [pipe for _, pipe in pairs], new_structures, interval_m
|
||||
)
|
||||
designs = {
|
||||
|
||||
@@ -10,7 +10,8 @@ DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름
|
||||
같은 세류선 라벨 관(옮겼어도) · 지움 표지가 짝으로 잡은 교차점엔 안 더함(507-2 ·
|
||||
`common_util_stream_crossing`).
|
||||
- 관 최대 간격 보충 없음(369) — 간격 보충은 배수유역 [자동 제안]만.
|
||||
- 측점 열쇠 = `common_util_station_key.station_key` 한 벌(sub2 짝) — 일반 `grid:k` ·
|
||||
- 측점 열쇠 = Node 측점 생성이 측점에 적은 `key`(511-B B-2 · `keyed_stations`) · 없으면(옛 파일 ·
|
||||
DTM 아닌 지표면) `common_util_station_key.station_key` 한 벌(sub2 짝) — 일반 `grid:k` ·
|
||||
관 `pipe:<id>` · 구조물 `structure:<id>` · 시종점 `end:BP` · `end:EP` ·
|
||||
id 없으면 `at:<누가거리 0.01 m>`.
|
||||
- 횡단 이월 = 같은 열쇠 측점에 `CARRY_INPUT_KEYS` 일곱 + `ROUTE_CARRY_KEYS`(사면 쉬프트 ·
|
||||
@@ -119,15 +120,21 @@ def _facility_owners(
|
||||
) -> dict[float, tuple[str, str | None, str]]:
|
||||
"""측점 자리(0.01 m) → (관 · 구조물, id, 역할 anchor · start · end). 같은 자리면 관이 먼저
|
||||
(`plan_stations` 이름 규칙)."""
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Node import node_plan_stations
|
||||
|
||||
# 측점 계획 = Node(`stations.plan` · 511-B B-5) · 못 돌림 = 파이썬 `plan_stations`
|
||||
groups = [([pipe], [], []) for pipe in pipes] + [([], [item], []) for item in structures]
|
||||
planned = node_plan_stations(groups) or [plan_stations(*group) for group in groups]
|
||||
marks_of = iter(planned)
|
||||
owners: dict[float, tuple[str, str | None, str]] = {}
|
||||
for pipe in pipes:
|
||||
for mark in plan_stations([pipe], []):
|
||||
for mark in next(marks_of):
|
||||
owners.setdefault(
|
||||
round(float(mark["chainage_m"]), 2),
|
||||
("pipe", getattr(pipe, "id", None), str(mark.get("role") or "anchor")),
|
||||
)
|
||||
for item in structures:
|
||||
for mark in plan_stations([], [item]):
|
||||
for mark in next(marks_of):
|
||||
owners.setdefault(
|
||||
round(float(mark["chainage_m"]), 2),
|
||||
(
|
||||
@@ -176,6 +183,18 @@ def station_keys(
|
||||
return out
|
||||
|
||||
|
||||
def keyed_stations(stations: Sequence[dict[str, Any]]) -> dict[str, float] | None:
|
||||
"""측점이 지닌 열쇠(`key` · Node 측점 생성 출력 · 511-B B-2) → 누가거리 · 열쇠 없는 측점이 있으면
|
||||
None(옛 파일 · DTM 아닌 지표면 = `station_keys` 로 셈)."""
|
||||
rows = [row for row in stations if row.get("chainage_m") is not None]
|
||||
if not rows or not all(isinstance(row.get("key"), str) for row in rows):
|
||||
return None
|
||||
out: dict[str, float] = {}
|
||||
for row in rows:
|
||||
out.setdefault(row["key"], float(row["chainage_m"]))
|
||||
return out
|
||||
|
||||
|
||||
def station_seats(
|
||||
stations: Sequence[dict[str, Any]], keys: dict[str, float]
|
||||
) -> dict[str, tuple[float, float, tuple[float, float] | None] | None]:
|
||||
@@ -248,23 +267,3 @@ def route_moved_stations(
|
||||
for station in stations
|
||||
if station.get("structure") and match(float(station["chainage_m"])) is None
|
||||
)
|
||||
|
||||
|
||||
def seat_route_moved(
|
||||
alignment: dict[str, Any], born: Sequence[float], held: Sequence[float] = ()
|
||||
) -> list[dict[str, Any]]:
|
||||
"""옮긴 시설 측점(`born`)에 427 규칙으로 꼭짓점을 다시 세움(459 · 요구 270) — 그 자리
|
||||
옛 꼭짓점(옮겨 붙은 지반 · 옛 높이)을 걷고 원지반 지남 · 이웃 1 mm 맞춤 · 균형 종단이면
|
||||
옮기기 전 계획선 높이(`_seat_born_nodes`). `alignment` = 새 지반 표본 · 정책 · 옮겨 붙인
|
||||
`base_pvi` · `held` = 높이 고친 측점. TS 짝 = `seatRouteMoved`."""
|
||||
from B05_Profile.B05_Profile_Router_Confirm import _SAME_STATION_M, _seat_born_nodes
|
||||
|
||||
base = alignment.get("base_pvi") or []
|
||||
if not born:
|
||||
return base
|
||||
kept = [
|
||||
node
|
||||
for node in base
|
||||
if all(abs(float(node["chainage_m"]) - value) >= _SAME_STATION_M for value in born)
|
||||
]
|
||||
return _seat_born_nodes(alignment, kept, list(born), list(held))
|
||||
|
||||
@@ -110,11 +110,11 @@ async def sync_uphill_overrides_into_designs(
|
||||
"""
|
||||
# 지연 import — B06 라우터 모듈 로드는 이 함수가 실제 불릴 때만 필요하다.
|
||||
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_Design_Node import DesignJob, compute_cross_designs
|
||||
from B06_Section.B06_Section_Repository import get_cross_section_designs
|
||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
@@ -164,20 +164,17 @@ async def sync_uphill_overrides_into_designs(
|
||||
longitudinal_json = json.loads(longitudinal_path.read_text(encoding="utf-8"))
|
||||
preloaded = (longitudinal_path, longitudinal_json, pavement_suggestions(longitudinal_json))
|
||||
ford_drops = ford_surface_drops(root)
|
||||
out: list[tuple[float, dict[str, Any]]] = []
|
||||
picked: list[tuple[float, dict[str, Any], bool, DesignJob]] = []
|
||||
for chainage, design, next_mode, side in jobs:
|
||||
samples, design_elevation, pavement_suggested, cross_record = read_cross_design_inputs(
|
||||
root, longitudinal_file_path, float(chainage), preloaded
|
||||
)
|
||||
next_design = compute_cross_design(
|
||||
samples,
|
||||
design_elevation,
|
||||
options = dict(
|
||||
ground_type=str(design.get("ground_type", "soil")),
|
||||
section_mode=next_mode,
|
||||
ditch_side=side,
|
||||
ditch_type=str(design.get("ditch_type") or "standard"), # 측구 끔 = None 저장분
|
||||
paved=bool(design.get("paved", False)),
|
||||
standard=stored_standard,
|
||||
rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
|
||||
two_stage_slope=bool(design.get("two_stage_slope", False)),
|
||||
cut_slope_ratio=stored_cut_slope(design), # 측점별 깎기 경사(429 ⑤)
|
||||
@@ -189,13 +186,24 @@ async def sync_uphill_overrides_into_designs(
|
||||
**stored_shoulder_extend(design), # 노견 확장(139-6)
|
||||
**stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4)
|
||||
)
|
||||
picked.append(
|
||||
(float(chainage), design, pavement_suggested, (samples, design_elevation, options))
|
||||
)
|
||||
# 측점 묶음 Node 한 번(TS 짝 · 511-B B-4) · 못 세운 측점 = 저장분 그대로(옛 ValueError 자리)
|
||||
results = compute_cross_designs([job for *_rest, job in picked], stored_standard)
|
||||
out: list[tuple[float, dict[str, Any]]] = []
|
||||
for (chainage, design, pavement_suggested, _job), next_design in zip(
|
||||
picked, results, strict=True
|
||||
):
|
||||
if next_design is None:
|
||||
continue
|
||||
next_design["status"] = design.get("status", "provisional")
|
||||
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
|
||||
# 사용자 조작값 승계(조절 · 소단 · 깎기 경사 · 글) — `enforce_design_lines` 와 같음
|
||||
for key in USER_TOUCHED_KEYS:
|
||||
if design.get(key) is not None:
|
||||
next_design[key] = design[key]
|
||||
out.append((float(chainage), next_design))
|
||||
out.append((chainage, next_design))
|
||||
return out
|
||||
|
||||
# 쓰기도 한 문장으로 — 행마다 내면 원격 DB 왕복이 측점 수만큼 난다(건당 24.5ms).
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -38,7 +37,12 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial
|
||||
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap, load_station_snap_step
|
||||
from B05_Profile.B05_Profile_Route_NodeEdits import node_edits
|
||||
from B05_Profile.B05_Profile_Route_Polyline_Write import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
|
||||
_curves_path,
|
||||
_nodes_path,
|
||||
_read_curves,
|
||||
_write_planned_polyline,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
|
||||
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
|
||||
RouteInitialCriteria,
|
||||
@@ -191,86 +195,6 @@ async def _road_settings(project_id: UUID) -> tuple[str, int | None, str]:
|
||||
return grade_class, design_speed, terrain
|
||||
|
||||
|
||||
def _nodes_path(path: Path) -> Path:
|
||||
"""그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`."""
|
||||
return path.with_name(f"{path.stem}_nodes.csv")
|
||||
|
||||
|
||||
def _curves_path(path: Path) -> Path:
|
||||
"""그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`.
|
||||
|
||||
왜 따로 남기나(2026-09-07 사용자 확정) — 계획노선은 「직선 > 곡선 > 직선」이고 사용자가
|
||||
잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터
|
||||
어디까지가 한 곡선인지, 그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 본다.
|
||||
"""
|
||||
return path.with_name(f"{path.stem}_curves.json")
|
||||
|
||||
|
||||
def _read_curves(path: Path, key: str = "curves") -> list[dict]:
|
||||
"""저장해 둔 곡선 성분(`key` = "edits" 면 넣은 편집값 · 430). 없거나 못 읽으면 빈 목록."""
|
||||
target = _curves_path(path)
|
||||
if not target.is_file():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target)
|
||||
return []
|
||||
curves = data.get(key) if isinstance(data, dict) else None
|
||||
return curves if isinstance(curves, list) else []
|
||||
|
||||
|
||||
def _write_planned_polyline(
|
||||
path: Path,
|
||||
points: list[tuple[float, float]],
|
||||
radius_m: float,
|
||||
*,
|
||||
simplify: bool = True,
|
||||
curve_flags: list[bool] | None = None,
|
||||
radii: list[float | None] | None = None,
|
||||
station_snap: bool | None = None,
|
||||
station_snap_step: float | None = None,
|
||||
locks: list[str | None] | None = None,
|
||||
arcs: list[float | None] | None = None,
|
||||
) -> dict:
|
||||
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
|
||||
|
||||
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔) ·
|
||||
`station_snap_step` — 켬 + 간격이 있을 때만 곡선표에 적음(390-5 · 없으면 측점 No.).
|
||||
`locks` · `arcs` — 편집값이 오면 넣은 값을 곡선표 `edits` 에(430 · `_Route_NodeEdits`).
|
||||
|
||||
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
|
||||
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
|
||||
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
|
||||
"""
|
||||
result = build_planned_polyline(
|
||||
points,
|
||||
min_radius_m=radius_m,
|
||||
simplify=simplify,
|
||||
curve_flags=curve_flags,
|
||||
radii=radii,
|
||||
)
|
||||
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
|
||||
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
|
||||
table: dict[str, Any] = {
|
||||
"min_radius_m": round(radius_m, 3),
|
||||
"curves": [c.as_dict() for c in result.curves],
|
||||
}
|
||||
if station_snap is not None:
|
||||
table["station_snap"] = bool(station_snap)
|
||||
if station_snap and station_snap_step is not None:
|
||||
table["station_snap_step"] = float(station_snap_step)
|
||||
if curve_flags is not None or radii is not None:
|
||||
table["edits"] = node_edits(points, curve_flags, radii, locks, arcs)
|
||||
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
|
||||
return {
|
||||
"nodes": len(result.nodes),
|
||||
"curves": result.curve_count,
|
||||
"violations": result.violation_count,
|
||||
"vertices": len(result.vertices),
|
||||
}
|
||||
|
||||
|
||||
def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None:
|
||||
"""계획노선 **초기 폴리라인**이 없거나 낡았으면 예상노선을 폴리라인화해 세운다.
|
||||
|
||||
|
||||
@@ -157,15 +157,17 @@ async def compute_initial(
|
||||
forget_pool_on_standard(pool_key, inputs["standard"])
|
||||
stored = await asyncio.to_thread(read_stored, project_root, key)
|
||||
if stored is None and not may_compute:
|
||||
# 프로젝트 접근 — 엔진 코드 판이 달라도 같은 입력 저장본을 꺼냄(505-1 덧)
|
||||
found = await asyncio.to_thread(
|
||||
read_stored_any,
|
||||
project_root,
|
||||
pool_key,
|
||||
lambda source: request_fingerprint(
|
||||
route, terrain, streams, **inputs, source=source
|
||||
)[0],
|
||||
)
|
||||
# 프로젝트 접근 — 엔진 코드 판이 달라도 같은 입력 저장본을 꺼냄(505-1 덧 · 514-4)
|
||||
found = None
|
||||
for form in standard_forms(inputs["standard"]):
|
||||
found = found or await asyncio.to_thread(
|
||||
read_stored_any,
|
||||
project_root,
|
||||
pool_key,
|
||||
lambda source, form=form: request_fingerprint(
|
||||
route, terrain, streams, **{**inputs, "standard": form}, source=source
|
||||
)[0],
|
||||
)
|
||||
if found is None:
|
||||
return JSONResponse(
|
||||
status_code=409, content={"status": "error", "message": NOT_PREPARED}
|
||||
@@ -249,6 +251,16 @@ async def project_standard(project_id: UUID) -> dict[str, Any] | None:
|
||||
return stored_standard_cross_section({"data": data if isinstance(data, dict) else None})
|
||||
|
||||
|
||||
def standard_forms(standard: dict[str, Any] | None) -> list[dict[str, Any] | None]:
|
||||
"""같은 뜻 표준 횡단면 꼴(지금 꼴 먼저) — 없음(None) = config 기본값 · 키 · 지문은 둘을 가름 ·
|
||||
[저장]은 손대지 않은 패널 기본값도 실어 보냄 → 프로젝트 접근은 두 꼴 저장본 다 꺼냄(514-4)."""
|
||||
from config.config_system import STANDARD_CROSS_SECTION
|
||||
|
||||
if standard is None:
|
||||
return [None, STANDARD_CROSS_SECTION]
|
||||
return [standard, None] if standard == STANDARD_CROSS_SECTION else [standard]
|
||||
|
||||
|
||||
async def precompute_defaults(project_id: UUID) -> None:
|
||||
"""체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯 ×
|
||||
고를 수 있는 지형(509-5 · 설계 지형 먼저 · 수치지형도는 DB 모델 있을 때만).
|
||||
|
||||
@@ -49,6 +49,7 @@ async def initial_profile_of(
|
||||
NOT_PREPARED,
|
||||
balanced_profile_of,
|
||||
project_standard,
|
||||
standard_forms,
|
||||
)
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_latest_section_options,
|
||||
@@ -113,10 +114,13 @@ async def initial_profile_of(
|
||||
"road": await replan._road_settings(project_id),
|
||||
}
|
||||
mark = ip.fingerprint(parts)
|
||||
# 프로젝트 접근(compute 거짓) = 코드 지문 안 견줌(505-1 덧)
|
||||
stored = await asyncio.to_thread(
|
||||
ip.read_initial, project_root, key, mark, None if compute else parts
|
||||
)
|
||||
# 프로젝트 접근(compute 거짓) = 코드 지문 안 견줌(505-1 덧) · 표준 두 꼴(514-4)
|
||||
forms = standard_forms(parts["standard"])
|
||||
stored = None
|
||||
for form in [None] if compute else [{**parts, "standard": f} for f in forms]:
|
||||
stored = stored or await asyncio.to_thread(
|
||||
ip.read_initial, project_root, key, mark, form
|
||||
)
|
||||
if stored is not None:
|
||||
return stored
|
||||
if not compute:
|
||||
|
||||
@@ -199,6 +199,32 @@ def _design_grid(
|
||||
}
|
||||
|
||||
|
||||
async def dtm_grid_npz(connection, project_id: UUID, model_id: int, smooth: bool) -> Path:
|
||||
"""그 프로젝트 DTM 모델의 npz(평활 고르기 = 서버 표본기와 같음) — 없으면 LookupError(까닭).
|
||||
|
||||
경로 = 프로젝트 storage 안 DB 상대 경로만(밖으로 나가면 LookupError · 계산 창구 `/calc` 도 씀).
|
||||
"""
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT model_type, model_file_path FROM surface_models"
|
||||
" WHERE id = %s AND project_id = %s",
|
||||
(model_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not stored or not row or row[0] != "dtm" or not row[1]:
|
||||
raise LookupError("DTM 격자가 없는 지표면 모델입니다.")
|
||||
root = Path(resolve_stored_project_path(stored))
|
||||
model_path = (root / str(row[1])).resolve()
|
||||
if not model_path.is_relative_to(root.resolve()):
|
||||
raise LookupError("DTM 격자가 없는 지표면 모델입니다.")
|
||||
smooth_path = model_path.with_name(f"{model_path.stem}_smooth.npz")
|
||||
npz_path = smooth_path if smooth and smooth_path.is_file() else model_path
|
||||
if not npz_path.is_file():
|
||||
raise LookupError("DTM 격자 파일이 없습니다.")
|
||||
return npz_path
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/models/{model_id}/grid", response_model=None)
|
||||
async def read_surface_grid(
|
||||
request: Request, project_id: UUID, model_id: int, smooth: bool = False
|
||||
@@ -209,26 +235,9 @@ async def read_surface_grid(
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT model_type, model_file_path FROM surface_models"
|
||||
" WHERE id = %s AND project_id = %s",
|
||||
(model_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not stored or not row or row[0] != "dtm" or not row[1]:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "DTM 격자가 없는 지표면 모델입니다."},
|
||||
)
|
||||
model_path = Path(resolve_stored_project_path(stored)) / str(row[1])
|
||||
smooth_path = model_path.with_name(f"{model_path.stem}_smooth.npz")
|
||||
npz_path = smooth_path if smooth and smooth_path.is_file() else model_path
|
||||
if not npz_path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "DTM 격자 파일이 없습니다."},
|
||||
)
|
||||
try:
|
||||
npz_path = await dtm_grid_npz(connection, project_id, model_id, smooth)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
grid_path = await asyncio.to_thread(dtm_grid_file, npz_path)
|
||||
return cached_file_response(request, grid_path, "application/octet-stream")
|
||||
|
||||
@@ -327,16 +327,13 @@ async def _migrate_structures(project_id: UUID, payload: StructureMigrateRequest
|
||||
# 정본이 이미 심는 자리(측점 계획 · 323-7) — 이름이 아니라 자리로 가름(옛 이름 되짚기 걷음)
|
||||
# · 추가 측점(368-2 · 이름 없는 일반측점)도 심는 자리 — 「기타」 구조물로 옮기지 않음
|
||||
from B05_Profile.B05_Profile_Engine_Sections import route_pipe_points
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Node import node_plan_stations
|
||||
from B05_Profile.B05_Profile_Plus_Stations import plus_chainages
|
||||
|
||||
planted = [
|
||||
float(station["chainage_m"])
|
||||
for station in plan_stations(
|
||||
route_pipe_points(Path(root)), # 관 = 지금 노선에 얹은 자리(472 덧)
|
||||
existing,
|
||||
plus_chainages(root),
|
||||
)
|
||||
]
|
||||
# 관 = 지금 노선에 얹은 자리(472 덧) · 측점 계획 = Node(511-B B-5) · 못 돌림 = 파이썬 짝
|
||||
group = (route_pipe_points(Path(root)), existing, plus_chainages(root))
|
||||
marks = (node_plan_stations([group]) or [plan_stations(*group)])[0]
|
||||
planted = [float(station["chainage_m"]) for station in marks]
|
||||
legacy, standing = await _saved_irregular_stations(project_id, root, payload.route_id)
|
||||
|
||||
# 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고,
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* 3) 어긋나면(저장 안 한 초안 — 벽 · 구조물) [3D 업데이트] · 낡은 채 3D 모달을 열 때만 브라우저가
|
||||
* 미리보기로 지음 — **웹 워커**(`_Corridor_Worker_Client` · 345-4 · 주 스레드 멈춤 0) · 메모리에만 ·
|
||||
* 올리지 않음.
|
||||
* 4) 계산 자리 D3 = 서버(508-2 · 요구 324) — 낡은 저장본도 받아 옛 모형으로 세워 둠(빈 3D 아님) ·
|
||||
* [3D 업데이트]만 서버가 지금 입력으로 지어 바꿔 끼움(지문 같으면 그대로) · 짓기 실패 = 옛 모형 · 「늦음」 남김.
|
||||
* 버전 해시 = 종횡단 정본의 설계 입력 요약(FNV-1a) — 측점·계획고·설계선이
|
||||
* 바뀌면 달라진다. 구조물 서피스 훅(CorridorStructureHook)은 예약만(범위 제외).
|
||||
* ========================================================================== */
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
} from "../A00_Common/b_asset_cache";
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { reconcilePagePipeline } from "../A00_Common/design/design_pipeline_page";
|
||||
import { calcOnServer } from "../A00_Common/design/design_calc_sites";
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||
import { buildCorridorOffThread } from "./B05_Profile_UI_Corridor_Worker_Client";
|
||||
@@ -201,8 +204,29 @@ function withSections(
|
||||
return build;
|
||||
}
|
||||
|
||||
/** 낡은 저장본 그대로(열쇠 안 맞춤 · D3 = 서버 · 508-2) — 보관함에 안 담음(낡은 17MB 가 안 쌓이게). */
|
||||
async function fetchStaleStored(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): Promise<CorridorBuildResult | null> {
|
||||
try {
|
||||
const response = await requestCorridor(`/projects/${projectId}/routes/${routeId}/corridor`, {
|
||||
method: "GET",
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const payload = (await response.json()) as CorridorEnvelope;
|
||||
if (payload?.version !== ENVELOPE_VERSION || !Array.isArray(payload.ribbons)) return null;
|
||||
const build = deserialize(payload);
|
||||
remember(keyOf(projectId, routeId), { hash: payload.hash, build });
|
||||
return build;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거).
|
||||
* 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */
|
||||
* 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01).
|
||||
* 돌려줌 = 3D 가 지금 입력과 맞나 — D3 = 서버 짓기 실패면 거짓 · 옛 모형 그대로(508-2). */
|
||||
export async function refreshCorridor(
|
||||
viewer: { setCorridor: (build: CorridorBuildResult | null) => void },
|
||||
projectId: string,
|
||||
@@ -211,13 +235,18 @@ export async function refreshCorridor(
|
||||
routePoints: RoutePoint[],
|
||||
designSamples?: ProfileSamples,
|
||||
rebuild = true,
|
||||
): Promise<void> {
|
||||
if (!routeId) return;
|
||||
): Promise<boolean> {
|
||||
if (!routeId) return false;
|
||||
// [3D 업데이트] — 계산 묶음 cross 가 다 돈 뒤(낡은 횡단 설계 다시 셈 · 387-4) 그 설계로 지음(317-1)
|
||||
if (rebuild) await reconcilePagePipeline(projectId).catch(() => 0);
|
||||
const keepOld = await calcOnServer("corridor_mesh");
|
||||
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild)
|
||||
.then((build) => viewer.setCorridor(withSections(build, detail)))
|
||||
.catch(() => viewer.setCorridor(null));
|
||||
.then((build) => {
|
||||
if (!build && keepOld) return false;
|
||||
viewer.setCorridor(withSections(build, detail));
|
||||
return true;
|
||||
})
|
||||
.catch(() => (keepOld ? false : (viewer.setCorridor(null), true)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,8 +264,13 @@ export function loadCorridorIfFresh(
|
||||
): Promise<boolean> {
|
||||
if (!routeId) return Promise.resolve(false);
|
||||
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, false)
|
||||
.then((build) => {
|
||||
.then(async (build) => {
|
||||
if (build) viewer.setCorridor(withSections(build, detail));
|
||||
// D3 = 서버 — 낡았으면 옛 저장본을 세워 둠(빈 3D 대신 · 「늦음」 표시는 부르는 쪽 · 508-2)
|
||||
else if (await calcOnServer("corridor_mesh")) {
|
||||
const stale = await fetchStaleStored(projectId, routeId);
|
||||
if (stale) viewer.setCorridor(withSections(stale, detail));
|
||||
}
|
||||
return build !== null;
|
||||
})
|
||||
.catch(() => false);
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
* 3D 길 모형 워커 부르기(345-4) — `buildCorridorOffThread` 가 워커(`_Corridor_Worker`)에 맡겨 주 스레드
|
||||
* 멈춤 없이 짓는다. 워커를 못 쓰는 곳(시험 · 옛 브라우저) · 워커가 깨지면 주 스레드에서 같은 함수로 짓는다.
|
||||
* 워커는 처음 부를 때 하나 세워 페이지 수명 동안 다시 쓴다.
|
||||
* 계산 자리 D3 = 서버면 워커 대신 서버 창구(`/calc` corridor.mesh · 같은 TS)가 지음(508-2 · [3D 업데이트] 때만).
|
||||
* ========================================================================== */
|
||||
|
||||
import { calcOnServer } from "../A00_Common/design/design_calc_sites";
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||
import { deserialize, type CorridorEnvelope } from "./B05_Profile_UI_Corridor_Envelope";
|
||||
import type {
|
||||
CorridorWorkerRequest,
|
||||
CorridorWorkerResponse,
|
||||
@@ -49,14 +51,40 @@ function getWorker(): Worker | null {
|
||||
return worker;
|
||||
}
|
||||
|
||||
/** 서버가 같은 입력으로 지음(508-2) — 계획선 샘플 = 넘긴 값(라이브 편집분) · 실패 = null(부르는 쪽이 옛 모형을 둠). */
|
||||
async function buildOnServer(
|
||||
crossSections: CrossSection[],
|
||||
routePoints: RoutePoint[],
|
||||
designSamples?: Samples,
|
||||
): Promise<CorridorBuildResult | null> {
|
||||
const detail = {
|
||||
cross_sections: crossSections,
|
||||
longitudinal: { design_profiles: [{ samples: designSamples }] },
|
||||
};
|
||||
try {
|
||||
// 서버 갈래만 부름(both 묶음 · 워커 시험에 안 실림)
|
||||
const { serverCalc } = await import("../A00_Common/design/design_calc_server");
|
||||
const results = await serverCalc("corridor.mesh", ["corridor.mesh"], {
|
||||
detail,
|
||||
route_points: routePoints,
|
||||
});
|
||||
const envelope = results["corridor.mesh"] as CorridorEnvelope | null;
|
||||
return envelope ? deserialize(envelope) : null;
|
||||
} catch (error) {
|
||||
console.warn("[corridor] 서버 짓기 실패", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 초안 미리보기 짓기 — 결과는 `buildCorridor` 와 같음(같은 입력 · 같은 함수).
|
||||
* 계산 자리 판(504-2) D3 = server 면 안 지음(null) — 서버가 [저장] · [확정] 때 지은 모형만. */
|
||||
* 계산 자리 판(504-2) D3 = server 면 서버 창구가 지음(508-2 · 워커 안 띄움). */
|
||||
export async function buildCorridorOffThread(
|
||||
crossSections: CrossSection[],
|
||||
routePoints: RoutePoint[],
|
||||
designSamples?: Samples,
|
||||
): Promise<CorridorBuildResult | null> {
|
||||
if (await calcOnServer("corridor_mesh")) return null;
|
||||
if (await calcOnServer("corridor_mesh"))
|
||||
return buildOnServer(crossSections, routePoints, designSamples);
|
||||
const onMain = (): CorridorBuildResult | null =>
|
||||
buildCorridor(crossSections, routePoints, designSamples);
|
||||
const target = getWorker();
|
||||
|
||||
@@ -18,10 +18,9 @@ export interface DrainageChrome {
|
||||
root: HTMLElement;
|
||||
panelHandle: ReturnType<typeof createWorkflowPanelHandle>;
|
||||
layerButtons: HTMLElement;
|
||||
analyzeButton: HTMLButtonElement;
|
||||
deleteButton: HTMLButtonElement;
|
||||
autoButton: HTMLButtonElement;
|
||||
/** [자동 제안](369) — 고름 창 · 배선은 `B05_Profile_UI_Drainage_Live`. */
|
||||
/** [업데이트](요구 326-6) — 지금 노선으로 처음부터 셈 · 배선은 `B05_Profile_UI_Drainage_Live`. */
|
||||
updateButton: HTMLButtonElement;
|
||||
/** [자동 제안](510) — 후보 목록 · 배선은 패널(`B05_Profile_UI_Drainage_Propose`). */
|
||||
proposeButton: HTMLButtonElement;
|
||||
viewport: HTMLElement;
|
||||
backgroundImage: HTMLImageElement;
|
||||
@@ -38,12 +37,6 @@ export interface DrainageChrome {
|
||||
|
||||
export interface DrainageChromeLabels {
|
||||
title: string;
|
||||
analyze: string;
|
||||
analyzeTip: string;
|
||||
deleteSelected: string;
|
||||
deleteSelectedTip: string;
|
||||
auto: string;
|
||||
autoTip: string;
|
||||
imageAlt: string;
|
||||
statusNeedRoute: string;
|
||||
}
|
||||
@@ -77,32 +70,16 @@ export function createDrainageChrome(
|
||||
layerButtons.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||
header.append(title);
|
||||
|
||||
// 세부유역 산정 — B04가 미리 분석해 둔 결과를 읽어 관을 보충하고 세부유역만 나눈다.
|
||||
// 격자 해석은 하지 않으므로 즉시 끝난다.
|
||||
const analyzeButton = document.createElement("button");
|
||||
analyzeButton.type = "button";
|
||||
analyzeButton.className = "b05-drainage__analyze";
|
||||
analyzeButton.textContent = labels.analyze;
|
||||
analyzeButton.title = labels.analyzeTip;
|
||||
// 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다.
|
||||
const deleteButton = document.createElement("button");
|
||||
deleteButton.type = "button";
|
||||
deleteButton.className = "b05-drainage__analyze b05-drainage__tool";
|
||||
deleteButton.textContent = labels.deleteSelected;
|
||||
deleteButton.title = labels.deleteSelectedTip;
|
||||
deleteButton.disabled = true;
|
||||
// 배관 배치 초기화 — 편집한 배관 배치를 버리고 세류선 교차 자리만(초안 · 369).
|
||||
const autoButton = document.createElement("button");
|
||||
autoButton.type = "button";
|
||||
autoButton.className = "b05-drainage__analyze b05-drainage__tool";
|
||||
autoButton.textContent = labels.auto;
|
||||
autoButton.title = labels.autoTip;
|
||||
const updateButton = document.createElement("button");
|
||||
updateButton.type = "button";
|
||||
updateButton.className = "b05-drainage__analyze b05-drainage__update";
|
||||
updateButton.textContent = "업데이트";
|
||||
const proposeButton = document.createElement("button");
|
||||
proposeButton.type = "button";
|
||||
proposeButton.className = "b05-drainage__analyze b05-drainage__tool b05-drainage__propose";
|
||||
proposeButton.textContent = ui_locales.B05_Drainage_Btn_Propose[currentLanguageIndex];
|
||||
proposeButton.title = ui_locales.B05_Drainage_Btn_Propose_Tip[currentLanguageIndex];
|
||||
header.append(analyzeButton, deleteButton, autoButton, proposeButton);
|
||||
header.append(updateButton, proposeButton);
|
||||
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "b05-drainage__viewport";
|
||||
@@ -160,9 +137,7 @@ export function createDrainageChrome(
|
||||
root,
|
||||
panelHandle,
|
||||
layerButtons,
|
||||
analyzeButton,
|
||||
deleteButton,
|
||||
autoButton,
|
||||
updateButton,
|
||||
proposeButton,
|
||||
viewport,
|
||||
backgroundImage,
|
||||
|
||||
@@ -94,11 +94,13 @@ export interface FacilityAttributes {
|
||||
summary_item?: SummaryItem | null;
|
||||
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3) — 없음 = 그룹 미지정. */
|
||||
group_items?: GroupItems | null;
|
||||
/** 자동 세류선 관 라벨(507-2) — [자동 제안]으로 넣은 관 · 응답 · 초안 왕복에 실어 나름(510). */
|
||||
stream_label?: DetailPipeInput["stream_label"];
|
||||
}
|
||||
|
||||
/** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */
|
||||
function hasExtras(attributes: FacilityAttributes): boolean {
|
||||
if (hasLinks(attributes)) return true; // 집계 항목(본체 · 그룹)만 지정한 맨 배관도 보관한다
|
||||
if (hasLinks(attributes) || attributes.stream_label) return true; // 집계 항목 · 라벨만 있어도
|
||||
if (attributes.facility !== "pipe") return true;
|
||||
if (attributes.start_m !== undefined || attributes.end_m !== undefined) return true;
|
||||
return !!attributes.options && Object.keys(attributes.options).length > 0;
|
||||
@@ -134,17 +136,7 @@ export function createFacilityStore() {
|
||||
|
||||
return {
|
||||
/** 서버 응답으로 전체를 다시 채운다 — 응답이 정본이다. */
|
||||
replaceFromResponse(
|
||||
points: ReadonlyArray<{
|
||||
chainage_m: number;
|
||||
facility?: PipeFacility;
|
||||
start_m?: number;
|
||||
end_m?: number;
|
||||
options?: Record<string, string | number>;
|
||||
summary_item?: SummaryItem | null;
|
||||
group_items?: GroupItems | null;
|
||||
}>,
|
||||
): void {
|
||||
replaceFromResponse(points: ReadonlyArray<Omit<DetailPipeInput, "source">>): void {
|
||||
entries = points
|
||||
.map((point) => {
|
||||
// 응답이 정본이다 — 서버는 미지정이면 칸을 안 싣는다(`as_dict`). 예전엔 칸이 없으면 화면이 쥔
|
||||
@@ -156,6 +148,7 @@ export function createFacilityStore() {
|
||||
start_m: point.start_m,
|
||||
end_m: point.end_m,
|
||||
options: point.options,
|
||||
...(point.stream_label ? { stream_label: point.stream_label } : {}),
|
||||
...linkFields(point),
|
||||
} as FacilityAttributes,
|
||||
};
|
||||
|
||||
@@ -25,8 +25,7 @@ import type { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */
|
||||
const CLICK_SLOP_PX = 4;
|
||||
/** 지도 집기 · 유역 고르기 · 팬에서 빼는 지도 위 요소. */
|
||||
const HTML_CONTROLS =
|
||||
"button, input, select, textarea, a, label, [role='button'], .b05-drainage__veil";
|
||||
const HTML_CONTROLS = "button, input, select, textarea, a, label, [role='button']";
|
||||
|
||||
export interface DrainageInteractParams {
|
||||
viewport: HTMLElement;
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Live.ts
|
||||
* 배수유역 자동/수동 다시 셈(347-2) — 묶음 drainage 단계 · ⚙ 설정 창 · 흐림 + [업데이트] · 구석
|
||||
* 「계산 중」 · 「지금 노선으로 셈」 요청(차례 번호 · 끊기). 규칙은 `logic/B05_Profile_Drainage_Live.ts`.
|
||||
* 배수유역 [업데이트](요구 326-6 · 347-2) — 묶음 drainage 단계 · 낡음 표시 · 구석 「계산 중」 ·
|
||||
* 「지금 노선으로 셈」 요청(차례 번호 · 끊기). 규칙은 `logic/B05_Profile_Drainage_Live.ts`.
|
||||
*
|
||||
* - 단계(`stage`)는 계산 묶음(`design_pipeline` · 붙이기 = sub7)이 노선 초안 · 관이 바뀔 때 부름 —
|
||||
* 기다리지 않고 null 을 돌려줌(배수유역은 뒤 단계 · 출력 없음).
|
||||
* - 노선 초안이 있으면 「낡음」 — 자동 = 흐림 없이 3 s 조용한 뒤 셈(구석 「계산 중」만) · 수동 = 칸만
|
||||
* 흐림 + 가운데 [업데이트]. 낡은 동안 제안 [적용]은 끔(두 모드 · 새 답이 오면 다시 켬).
|
||||
* - 다시 조작하면 처음으로 — 떠 있는 요청을 끊고(AbortController) 다시 흐림 또는 다시 3 s.
|
||||
* 묶음이 같은 노선 · 관으로 다시 돌면(입력 지문 같음) 그대로 둠 — 떠 있는 답을 받음.
|
||||
* - 노선 초안이 있으면 「낡음」 — 저절로 셈하지 않음 · 옛 그림 그대로 · [업데이트] 단추에 표시만
|
||||
* (508-2 [3D 업데이트] 꼴). 낡은 동안 제안 [적용] · [자동 제안]은 끔(새 답이 오면 다시 켬).
|
||||
* - [업데이트] = 늘 지금 노선(초안 · 저장 노선)으로 배수유역을 처음부터 셈 — 노선이 그대로여도.
|
||||
* 뒤에서 조용히(덮개 · 잠금 없음) · 답이 오면 그림을 갈아 끼움.
|
||||
* - 다시 조작하면 떠 있는 요청을 끊고(AbortController) 다시 낡음. 묶음이 같은 노선 · 관으로 다시
|
||||
* 돌면(입력 지문 같음) 그대로 둠 — 떠 있는 답을 받음.
|
||||
* - 초안이 없어지면(되돌리기 · 버림) 저장 배수유역을 다시 받아(열 때와 같은 길 · 계산 아님) 그 답의
|
||||
* 낡음대로 — 떠 있던 초안용 답은 버림 · 자동도 3 s 셈 대신 저장분.
|
||||
* - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 흐림 + [업데이트] · 자동이면 3 s 뒤.
|
||||
* 낡음대로 — 떠 있던 초안용 답은 버림.
|
||||
* - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 낡음 표시.
|
||||
* - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림.
|
||||
* - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로.
|
||||
* - [자동 제안](369) — 고름 창 ① 전체 자동(all) ② 지정 포함 추가(add) · [배관 배치 초기화] = 세류선 교차
|
||||
* 관만 · 셋 다 초안(세션) · [저장] 때 정본 · 답이 오면 사진첩 한 걸음(늦은 답도 앞 걸음에 안 묻힘) ·
|
||||
* 낡은 동안 [자동 제안] 끔 · 노선 초안 중이거나 저장 분석이 낡았으면(옛 노선) 「지금 노선으로 셈」 길에
|
||||
* 방식을 실어 보냄(저장 분석으로 셈 = 옛 노선 자리 · 409).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { setDrainageStale } from "../A00_Common/design/design_structure_ford_calc";
|
||||
import type { StageFn } from "../A00_Common/design/logic/design_pipeline_types";
|
||||
import {
|
||||
computeDetailBasins,
|
||||
@@ -36,39 +32,36 @@ import {
|
||||
draftMakesStale,
|
||||
inputKey,
|
||||
liveRequestBody,
|
||||
parseDrainageMode,
|
||||
QUIET_MS,
|
||||
type DrainageMode,
|
||||
} from "./logic/B05_Profile_Drainage_Live";
|
||||
import type { DetailRunner } from "./B05_Profile_UI_Drainage_Run";
|
||||
import { createDrainageSuggest, type DrainageSuggest } from "./B05_Profile_UI_Drainage_Suggest";
|
||||
|
||||
const MODE_KEY = "drainage-mode";
|
||||
|
||||
export interface DrainageLiveDeps {
|
||||
/** 패널 뿌리 — 머리(⚙) · 지도(흐림 · 구석 「계산 중」)를 이 안에서 찾음. */
|
||||
/** 패널 뿌리 — 머리([자동 제안]) · 지도(구석 「계산 중」)를 이 안에서 찾음. */
|
||||
root: HTMLElement;
|
||||
/** [업데이트] 단추(머리 줄 · `_Drainage_Chrome`). */
|
||||
updateButton: HTMLButtonElement;
|
||||
/** 제안 목록을 이 앞에 둠. */
|
||||
basinList: HTMLElement;
|
||||
pipeRequest: () => DetailPipeInput[];
|
||||
runner: DetailRunner;
|
||||
/** [적용] · [자동 제안] 답이 화면에 오른 뒤 — 세션 초안에 담고 사진첩 한 걸음(패널이 콜백으로 이음). */
|
||||
/** [적용] 답이 화면에 오른 뒤 — 세션 초안에 담고 사진첩 한 걸음(패널이 콜백으로 이음). */
|
||||
rememberPipes: () => void;
|
||||
/** 저장 배수유역 다시 받기(열 때와 같은 길 · 조용히) — 답을 화면에 올렸으면 true. */
|
||||
loadSaved: () => Promise<boolean>;
|
||||
/** 노선 · 관 자리가 바뀌어 낡음 — [자동 제안] 목록 닫기(510). */
|
||||
onStale?: () => void;
|
||||
}
|
||||
|
||||
export interface DrainageLive {
|
||||
/** 계산 묶음 drainage 단계 — 노선 초안 · 관이 바뀐 차례에 부름(출력 없음). */
|
||||
stage: StageFn<"drainage">;
|
||||
/** 낡음을 지금 노선으로 다시 셈(자동 · [업데이트]와 같은 길). */
|
||||
refresh(): void;
|
||||
/** 서버 답이 「낡음」 — 흐림 + [업데이트](자동이면 3 s 뒤 셈). 이미 낡음이면 그대로. */
|
||||
/** [업데이트] — 지금 노선으로 처음부터 셈 · 답을 화면에 올렸으면 true. */
|
||||
refresh(): Promise<boolean>;
|
||||
/** 서버 답이 「낡음」 — 낡음 표시. 이미 낡음이면 그대로. */
|
||||
markStale(): void;
|
||||
/** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청 · 기다림도 끊음). */
|
||||
/** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청도 끊음). */
|
||||
markFresh(): void;
|
||||
/** [배관 배치 초기화] — 세류선 교차 관만으로 초안(369 · 저장분은 [저장] 때). */
|
||||
resetToStreams(): void;
|
||||
readonly stale: boolean;
|
||||
suggest: DrainageSuggest;
|
||||
}
|
||||
@@ -76,7 +69,7 @@ export interface DrainageLive {
|
||||
/** 「지금 노선으로 셈」 — 서버 `post_drainage_live` · 작업본에 안 씀. */
|
||||
async function fetchLive(
|
||||
projectId: string,
|
||||
body: ReturnType<typeof liveRequestBody> | (Proposal & { generation: number }),
|
||||
body: ReturnType<typeof liveRequestBody>,
|
||||
signal: AbortSignal,
|
||||
): Promise<DetailBasinResponse & { status?: string; generation?: number }> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/drainage/live`, {
|
||||
@@ -91,102 +84,76 @@ async function fetchLive(
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function readDrainageMode(): DrainageMode {
|
||||
return parseDrainageMode(readStateRaw(MODE_KEY));
|
||||
}
|
||||
|
||||
const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex];
|
||||
|
||||
/** 서버 「낡음」 글 머리 — 짝 `B04_PreProcess_Router_Basins.STALE_MESSAGE`. */
|
||||
const STALE_PREFIX = "배수유역 낡음";
|
||||
|
||||
/** [자동 제안] · [배관 배치 초기화] 요청 — 관 목록 없음(null) = 세류선 교차만. */
|
||||
type Proposal = { points: DetailPipeInput[] | null; auto?: "all" | "add" };
|
||||
const UPDATE_LABEL = "업데이트";
|
||||
const UPDATE_TIP =
|
||||
"지금 노선으로 배수유역을 처음부터 다시 셉니다 — 뒤에서 셈 · 끝나면 그림을 바꿈.";
|
||||
const UPDATE_STALE_TIP =
|
||||
"노선 · 관 자리가 바뀌어 배수유역도가 낡았습니다 — 누르면 지금 노선으로 다시 셈.";
|
||||
|
||||
export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
const viewport = deps.root.querySelector<HTMLElement>(".b05-drainage__viewport") as HTMLElement;
|
||||
const header = deps.root.querySelector<HTMLElement>(".b05-drainage__header") as HTMLElement;
|
||||
const propose = header.querySelector<HTMLButtonElement>(".b05-drainage__propose");
|
||||
/** 관 초안 요청 — 답이 화면에 오르면 세션 초안 + 사진첩 한 걸음(400 ms 넘은 답도 제 걸음). */
|
||||
const draft = (request: (projectId: string) => Promise<DetailBasinResponse>): void => {
|
||||
const update = deps.updateButton;
|
||||
const suggest = createDrainageSuggest(deps.pipeRequest, (points) => {
|
||||
const projectId = deps.runner.projectId();
|
||||
if (!projectId) return;
|
||||
void deps.runner
|
||||
.run(() => request(projectId))
|
||||
.run(() => computeDetailBasins(projectId, points))
|
||||
.then((ok) => {
|
||||
if (!ok) return;
|
||||
deps.rememberPipes();
|
||||
if (ok) deps.rememberPipes();
|
||||
});
|
||||
};
|
||||
const suggest = createDrainageSuggest(deps.pipeRequest, (points) =>
|
||||
draft((projectId) => computeDetailBasins(projectId, points)),
|
||||
);
|
||||
});
|
||||
deps.basinList.before(suggest.root);
|
||||
let mode = readDrainageMode();
|
||||
let route: unknown = null;
|
||||
let stale = false;
|
||||
/** 마지막 단계에 노선 초안이 있었나 — 없어지면 저장분으로 돌아옴. */
|
||||
let drafted = false;
|
||||
/** 저장분 다시 받기 답이 「낡음」이었나(`markStale` 이 세움). */
|
||||
let savedStale = false;
|
||||
/** 저장 분석이 옛 노선(서버 「낡음」 답 · [확정] 전까지) — [자동 제안]은 지금 노선으로 셈. */
|
||||
let analysisStale = false;
|
||||
let generation = 0;
|
||||
/** 마지막 단계 입력 지문 — 같으면 묶음이 다시 돈 것뿐. */
|
||||
let key = "";
|
||||
let timer = 0;
|
||||
let inflight: AbortController | null = null;
|
||||
|
||||
const veil = document.createElement("div");
|
||||
veil.className = "b05-drainage__veil";
|
||||
veil.hidden = true;
|
||||
const update = document.createElement("button");
|
||||
update.type = "button";
|
||||
update.className = "b05-drainage__analyze b05-drainage__update";
|
||||
update.textContent = "업데이트";
|
||||
update.title = "노선 · 관 자리가 바뀌어 배수유역이 낡았습니다 — 지금 노선으로 다시 셉니다.";
|
||||
veil.append(update);
|
||||
const busy = createProgressCircle({ size: 22, label: "계산 중" });
|
||||
busy.root.classList.add("b05-drainage__busy");
|
||||
busy.root.hidden = true;
|
||||
viewport.append(veil, busy.root);
|
||||
|
||||
const gear = document.createElement("button");
|
||||
gear.type = "button";
|
||||
gear.className = "b05-drainage__analyze b05-drainage__tool b05-drainage__gear";
|
||||
gear.textContent = "⚙";
|
||||
gear.title = "배수유역 계산 설정 — 수동(기본) / 자동";
|
||||
header.append(gear);
|
||||
viewport.append(busy.root);
|
||||
|
||||
const paint = (): void => {
|
||||
deps.root.classList.toggle("is-stale", stale && mode === "manual");
|
||||
deps.root.classList.toggle("is-stale", stale);
|
||||
setDrainageStale(stale); // 관 카드 설계유량 가림(요구 328-3)
|
||||
update.classList.toggle("is-pending", stale);
|
||||
update.disabled = inflight !== null;
|
||||
update.textContent = inflight ? "계산 중…" : UPDATE_LABEL;
|
||||
update.title = stale ? UPDATE_STALE_TIP : UPDATE_TIP;
|
||||
suggest.setStale(stale);
|
||||
if (propose) {
|
||||
propose.disabled = stale;
|
||||
propose.title = L(stale ? "B05_Drainage_Btn_Propose_Stale" : "B05_Drainage_Btn_Propose_Tip");
|
||||
}
|
||||
veil.hidden = !stale || mode === "auto" || inflight !== null;
|
||||
busy.root.hidden = inflight === null;
|
||||
};
|
||||
|
||||
function cancel(): void {
|
||||
window.clearTimeout(timer);
|
||||
timer = 0;
|
||||
inflight?.abort();
|
||||
inflight = null;
|
||||
}
|
||||
|
||||
/** 지금 노선으로 셈 — `proposal` 이면 그 관 목록 · 방식으로(답 = 관 초안 · 사진첩 한 걸음). */
|
||||
function refresh(proposal?: Proposal): void {
|
||||
/** 지금 노선으로 처음부터 셈 — 노선 초안이 있으면 그 노선 · 없으면 저장 노선. */
|
||||
function refresh(): Promise<boolean> {
|
||||
const projectId = deps.runner.projectId();
|
||||
if (!projectId) return;
|
||||
if (!projectId) return Promise.resolve(false);
|
||||
cancel();
|
||||
const sent = { generation: ++generation, key };
|
||||
const controller = new AbortController();
|
||||
inflight = controller;
|
||||
paint();
|
||||
const body = { ...liveRequestBody(sent.generation, route, deps.pipeRequest()), ...proposal };
|
||||
void deps.runner
|
||||
const body = liveRequestBody(sent.generation, route, deps.pipeRequest());
|
||||
return deps.runner
|
||||
.run(async () => {
|
||||
const response = await fetchLive(projectId, body, controller.signal);
|
||||
// 옛 차례 · 서버가 그만둔 답 — 감싸기가 조용히 버리게 끊김으로 돌려줌.
|
||||
@@ -195,41 +162,23 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
return response;
|
||||
}, true)
|
||||
.then((applied) => {
|
||||
if (inflight !== controller) return;
|
||||
if (inflight !== controller) return applied;
|
||||
inflight = null;
|
||||
if (applied) stale = false;
|
||||
if (applied && proposal) {
|
||||
deps.rememberPipes();
|
||||
}
|
||||
paint();
|
||||
return applied;
|
||||
});
|
||||
}
|
||||
|
||||
/** [자동 제안] · [배관 배치 초기화] — 저장 분석이 지금 노선과 같으면 빠른 길(detail-basins) ·
|
||||
* 그 길이 「낡음」(409 · 화면이 아직 몰랐던 낡음)이면 곧바로 지금 노선으로 셈 길로 다시. */
|
||||
function proposeDraft(proposal: Proposal): void {
|
||||
if (analysisStale || draftMakesStale(route)) return refresh(proposal);
|
||||
draft(async (projectId) => {
|
||||
try {
|
||||
return await computeDetailBasins(projectId, proposal.points, proposal.auto);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !error.message.startsWith(STALE_PREFIX)) throw error;
|
||||
analysisStale = true;
|
||||
queueMicrotask(() => refresh(proposal)); // 이 요청은 조용히 버림(뒤 차례가 받음)
|
||||
throw new DOMException("stale", "AbortError");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 평면 · 관 자리 조작 — 처음으로(요청 끊기 · 다시 흐림 또는 다시 3 s). */
|
||||
/** 평면 · 관 자리 조작 — 떠 있는 요청을 끊고 낡음(저절로 셈하지 않음). */
|
||||
function invalidate(): void {
|
||||
cancel();
|
||||
stale = true;
|
||||
if (mode === "auto") timer = window.setTimeout(refresh, QUIET_MS);
|
||||
deps.onStale?.();
|
||||
paint();
|
||||
}
|
||||
|
||||
/** 초안이 없어짐 — 저장분을 다시 받아 그 답의 낡음대로(흐림은 답이 올 때까지 그대로). */
|
||||
/** 초안이 없어짐 — 저장분을 다시 받아 그 답의 낡음대로(표시는 답이 올 때까지 그대로). */
|
||||
function backToSaved(): void {
|
||||
cancel();
|
||||
const sent = ++generation;
|
||||
@@ -238,82 +187,12 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
void deps.loadSaved().then((applied) => {
|
||||
if (sent !== generation) return;
|
||||
stale = !applied || savedStale;
|
||||
if (stale && mode === "auto") timer = window.setTimeout(refresh, QUIET_MS);
|
||||
paint();
|
||||
});
|
||||
}
|
||||
|
||||
function openSettings(): void {
|
||||
openModal({
|
||||
title: "배수유역 계산",
|
||||
closeLabel: "닫기",
|
||||
mount(body, close) {
|
||||
const choices: Array<[DrainageMode, string, string]> = [
|
||||
["manual", "수동(기본)", "노선 · 관 자리가 바뀌면 칸만 흐려지고 [업데이트]를 누를 때 셈"],
|
||||
["auto", "자동", "조작이 3초 멈추면 뒤에서 조용히 셈(화면을 막지 않음)"],
|
||||
];
|
||||
choices.forEach(([value, label, hint]) => {
|
||||
const row = document.createElement("label");
|
||||
row.className = "b05-drainage__mode";
|
||||
const input = document.createElement("input");
|
||||
input.type = "radio";
|
||||
input.name = "b05-drainage-mode";
|
||||
input.checked = mode === value;
|
||||
input.addEventListener("change", () => {
|
||||
setMode(value);
|
||||
close();
|
||||
});
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${label} — ${hint}`;
|
||||
row.append(input, text);
|
||||
body.append(row);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** [자동 제안] 고름 창 — ⚙ 창과 같은 틀 · 고르면 닫고 그 방식으로 초안. */
|
||||
function openPropose(): void {
|
||||
if (stale) return;
|
||||
openModal({
|
||||
title: L("B05_Drainage_Propose_Title"),
|
||||
closeLabel: "닫기",
|
||||
mount(body, close) {
|
||||
const choices: Array<["all" | "add", string, string]> = [
|
||||
["all", L("B05_Drainage_Propose_All"), L("B05_Drainage_Propose_All_Hint")],
|
||||
["add", L("B05_Drainage_Propose_Add"), L("B05_Drainage_Propose_Add_Hint")],
|
||||
];
|
||||
choices.forEach(([value, label, hint], order) => {
|
||||
const row = document.createElement("label");
|
||||
row.className = "b05-drainage__mode";
|
||||
const input = document.createElement("input");
|
||||
input.type = "radio";
|
||||
input.name = "b05-drainage-propose";
|
||||
input.addEventListener("change", () => {
|
||||
close();
|
||||
proposeDraft({ points: deps.pipeRequest(), auto: value });
|
||||
});
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${order === 0 ? "①" : "②"} ${label} — ${hint}`;
|
||||
row.append(input, text);
|
||||
body.append(row);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setMode(next: DrainageMode): void {
|
||||
mode = next;
|
||||
writeStateRaw(MODE_KEY, next);
|
||||
window.clearTimeout(timer);
|
||||
timer = 0;
|
||||
if (stale && next === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS);
|
||||
paint();
|
||||
}
|
||||
|
||||
update.addEventListener("click", () => refresh());
|
||||
gear.addEventListener("click", openSettings);
|
||||
propose?.addEventListener("click", openPropose);
|
||||
update.addEventListener("click", () => void refresh());
|
||||
paint();
|
||||
|
||||
return {
|
||||
stage(ctx) {
|
||||
@@ -327,23 +206,19 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
else if (wasDraft) backToSaved();
|
||||
return null;
|
||||
},
|
||||
refresh: () => refresh(),
|
||||
refresh,
|
||||
suggest,
|
||||
markStale() {
|
||||
savedStale = true;
|
||||
analysisStale = true;
|
||||
if (stale) return;
|
||||
stale = true;
|
||||
if (mode === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS);
|
||||
paint();
|
||||
},
|
||||
markFresh() {
|
||||
cancel();
|
||||
stale = false;
|
||||
analysisStale = false;
|
||||
paint();
|
||||
},
|
||||
resetToStreams: () => proposeDraft({ points: null }),
|
||||
get stale() {
|
||||
return stale;
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ import { resampleRoute } from "../B04_PreProcess/logic/B04_PreProcess_UI_RouteSa
|
||||
import { batched, createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
||||
import { followMovedPipe, PIPE_MATCH_M } from "../A00_Common/design/design_pipe_ids";
|
||||
import { snapStructureStation } from "../common_util/common_util_structure_station";
|
||||
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { createFacilityStore, type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { mergeFacilityOptions } from "./logic/B05_Profile_UI_Drainage_Facility_Merge";
|
||||
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
|
||||
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
|
||||
@@ -38,6 +38,7 @@ import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_
|
||||
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
|
||||
import { createDetailRunner } from "./B05_Profile_UI_Drainage_Run";
|
||||
import { createDrainageLive } from "./B05_Profile_UI_Drainage_Live";
|
||||
import { createDrainagePropose } from "./B05_Profile_UI_Drainage_Propose";
|
||||
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";
|
||||
@@ -82,12 +83,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
const chrome = createDrainageChrome(
|
||||
{
|
||||
title: L("B05_Drainage_Title"),
|
||||
analyze: L("B05_Drainage_Btn_Analyze"),
|
||||
analyzeTip: L("B05_Drainage_Btn_Analyze_Tip"),
|
||||
deleteSelected: L("B05_Drainage_Btn_DeleteSelected"),
|
||||
deleteSelectedTip: L("B05_Drainage_Btn_DeleteSelected_Tip"),
|
||||
auto: L("B05_Drainage_Btn_Auto"),
|
||||
autoTip: L("B05_Drainage_Btn_Auto_Tip"),
|
||||
imageAlt: L("B05_Drainage_ImageAlt"),
|
||||
statusNeedRoute: L("B05_Drainage_Status_NeedRoute"),
|
||||
},
|
||||
@@ -101,9 +96,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
root,
|
||||
panelHandle,
|
||||
layerButtons,
|
||||
analyzeButton,
|
||||
deleteButton,
|
||||
autoButton,
|
||||
updateButton,
|
||||
proposeButton,
|
||||
viewport,
|
||||
backgroundImage,
|
||||
canvas,
|
||||
@@ -243,7 +237,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
markedChainage,
|
||||
stationIntervalM: MAP_STATION_INTERVAL_M,
|
||||
intervalSpans,
|
||||
candidates: live.suggest.candidates(),
|
||||
});
|
||||
updateImageTransform();
|
||||
}
|
||||
@@ -258,10 +251,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
|
||||
const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage);
|
||||
|
||||
/** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */
|
||||
/** 마커 선택 ↔ 유역 강조 동기화(편집 폼은 사이드 소관). */
|
||||
function syncPipeSelection(notify = true): void {
|
||||
const index = pipeEditor.selected();
|
||||
deleteButton.disabled = index === null;
|
||||
const picked = index === null ? null : pipeEditor.pipes()[index];
|
||||
selectBasin(basinIndexOfPipe(basins, picked ?? null), notify);
|
||||
// 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은
|
||||
@@ -302,12 +294,17 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
callbacks.onBasinSelected?.(picked ? picked.chainage_m : null);
|
||||
}
|
||||
|
||||
/** 응답을 화면 상태로 옮긴다. B04 지도와 **같은 엔드포인트·같은 응답**을 쓴다
|
||||
* — 두 화면이 다른 결과를 보이면 안 되기 때문이다(2026-08-01 일원화). */
|
||||
/** 응답을 화면 상태로 옮긴다. B04 지도와 같은 엔드포인트 · 같은 응답(2026-08-01 일원화). */
|
||||
function apply(response: DetailBasinResponse): void {
|
||||
// 낡은 답(노선 [저장] 뒤 등 · 세부 유역 없음) = 옛 그림 그대로 · 관만 받음 · [업데이트] 표시(326-6)
|
||||
const keep = !!response.stale && basins.length > 0;
|
||||
if (response.stale) live.markStale();
|
||||
if (keep) return void takePipes(response);
|
||||
basins = response.basins;
|
||||
live.suggest.update(response);
|
||||
if (response.stale) live.markStale(); // 노선 [저장] 뒤 등 — 흐림 + [업데이트](자동이면 3 s 뒤)
|
||||
// 노선선 = 이 답을 셈한 노선 — 유역 · 관 자리와 늘 짝(초안 노선 [업데이트] 뒤 빈 곳 · 326-6)
|
||||
const answered = response.route_xy?.map(([x, y]) => ({ x, y })) ?? [];
|
||||
if (answered.length) takeRoute(answered, false);
|
||||
mainBoundary = response.main_polygon_lonlat ?? [];
|
||||
upstreamLines = (response.upstream_lonlat ?? []) as Array<Array<[number, number]>>;
|
||||
flowArrows = (response.flow_arrows ?? []) as FlowArrow[];
|
||||
@@ -321,7 +318,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
maxStrength = built.maximum;
|
||||
legend.update(maxStrength, show.strength);
|
||||
maxHotspotArea = hotspots.reduce((max, spot) => (spot.area > max ? spot.area : max), 0);
|
||||
// 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함).
|
||||
takePipes(response);
|
||||
}
|
||||
|
||||
/** 답의 관 목록(유역 없는 관 포함) · 시설 정보를 마커 · 폼에 올림(유역 그림은 그대로). */
|
||||
function takePipes(response: DetailBasinResponse): void {
|
||||
pipeEditor.setPipes(
|
||||
response.pipe_points.map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
@@ -329,8 +330,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
id: pipe.id,
|
||||
})),
|
||||
);
|
||||
// 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이를 먼저 얹음).
|
||||
const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits());
|
||||
// 시설 확장 정보는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이 먼저 · 낡은 답이면 뒤로 · 328-3).
|
||||
const fordFilled =
|
||||
!live.stale && fillFordHeights(response.pipe_points, response.basins, fordWaits());
|
||||
facilityStore.replaceFromResponse(response.pipe_points);
|
||||
if (fordFilled) rememberPipes();
|
||||
if (saved?.response === response) saved.request = JSON.stringify(pipeRequest());
|
||||
@@ -349,7 +351,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
/** 세부유역 요청 한 번 — 버튼 잠금 · 진행 문구 · 오류 · 최신 응답만(347-2 에 나눔). */
|
||||
const runner = createDetailRunner({
|
||||
projectId: () => projectId,
|
||||
analyzeButton,
|
||||
updateButton,
|
||||
status,
|
||||
showProgress,
|
||||
apply,
|
||||
@@ -393,21 +395,54 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
* 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */
|
||||
const analyze = batched(() => run(() => computeDetailBasins(projectId as string, pipeRequest())));
|
||||
|
||||
// 자동/수동 다시 셈(347-2) · 제안 목록(347-3 · [적용] = 관 초안으로 세부 유역 다시 나눔).
|
||||
// [업데이트](326-6 · 347-2) · 제안 목록(347-3 · [적용] = 관 초안으로 세부 유역 다시 나눔).
|
||||
const live = createDrainageLive({
|
||||
root,
|
||||
updateButton,
|
||||
basinList,
|
||||
pipeRequest,
|
||||
runner,
|
||||
rememberPipes: () => (rememberPipes(), callbacks.onPipesDrafted?.()), // + 사진첩 한 걸음(369-4)
|
||||
loadSaved: () => run(loadSaved, true), // 초안 버림 = 저장분 조용히(349-0i)
|
||||
onStale: () => propose.close(),
|
||||
});
|
||||
analyzeButton.addEventListener("click", () => void analyze());
|
||||
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
|
||||
autoButton.addEventListener("click", live.resetToStreams); // 세류선만 · 초안(369)
|
||||
// [자동 제안](510) — 서버 후보 · 고른 것만 관 넣기 길(세류선 교차 = 라벨 실음)
|
||||
const propose = createDrainagePropose({
|
||||
projectId: () => projectId,
|
||||
pipeRequest,
|
||||
addPipes: (picks) =>
|
||||
picks.forEach((item) =>
|
||||
addPipe(
|
||||
item.chainage_m,
|
||||
item.stream_label ? { facility: "pipe", stream_label: item.stream_label } : undefined,
|
||||
),
|
||||
),
|
||||
});
|
||||
basinList.before(propose.root);
|
||||
proposeButton.addEventListener("click", () => void propose.open());
|
||||
|
||||
function addPipe(chainageM: number, attributes?: FacilityAttributes): void {
|
||||
chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1)
|
||||
// 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다.
|
||||
facilityStore.set(chainageM, attributes ?? null);
|
||||
if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits().add(chainageM);
|
||||
pipeEditor.addAtChainage(chainageM);
|
||||
}
|
||||
|
||||
let routeFromAnswer = false; // 노선선이 답에서 왔나 — 그 뒤 페이지 노선은 그림 안 바꿈(326-6)
|
||||
|
||||
function takeRoute(points: ReadonlyArray<RoutePoint>, fit: boolean): void {
|
||||
routeFromAnswer ||= !fit;
|
||||
routePoints = points;
|
||||
routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null;
|
||||
// 강도 색칠은 백엔드가 준 1m 구간 값과 인덱스를 맞춰야 하므로 같은 규칙으로 다시 찍는다.
|
||||
strengthSamples = resampleRoute(points);
|
||||
pipeEditor.setContext(meta, points);
|
||||
if (routeLayer && fit) fitToRoute();
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
/** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인).
|
||||
*
|
||||
* B05는 노선 주변 배수유역을 보는 화면이라 도로에 맞춰 확대한 상태로 연다
|
||||
* (2026-08-01 사용자 지시). 배경 전체를 보려면 휠로 축소하면 된다.
|
||||
* 노선이 없으면 배경 전체를 그대로 보여준다. */
|
||||
@@ -596,13 +631,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
intervalSpans = spans;
|
||||
scheduleDraw();
|
||||
},
|
||||
addPipe(chainageM, attributes) {
|
||||
chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1)
|
||||
// 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다.
|
||||
facilityStore.set(chainageM, attributes ?? null);
|
||||
if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits().add(chainageM);
|
||||
pipeEditor.addAtChainage(chainageM);
|
||||
},
|
||||
addPipe,
|
||||
updatePipeFacility(fromChainageM, toChainageM, attributes) {
|
||||
// 옮겼으면 정수 m · 속성 열쇠도 그 자리로(234-1) — 값만 고친 옛 소수 관은 제자리
|
||||
if (Math.abs(toChainageM - fromChainageM) > 0.005)
|
||||
@@ -610,7 +639,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
// ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임.
|
||||
const previous = facilityStore.get(fromChainageM);
|
||||
facilityStore.set(fromChainageM, null);
|
||||
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes));
|
||||
const label = previous?.stream_label ? { stream_label: previous.stream_label } : {}; // 510
|
||||
facilityStore.set(toChainageM, { ...mergeFacilityOptions(previous, attributes), ...label });
|
||||
if (fordWaits().delete(fromChainageM)) fordWaits().add(toChainageM);
|
||||
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
|
||||
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
|
||||
@@ -651,14 +681,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
scheduleDraw();
|
||||
},
|
||||
setRoute(points) {
|
||||
routePoints = points;
|
||||
routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null;
|
||||
// 강도 색칠은 백엔드가 준 1m 구간 값과 인덱스를 맞춰야 하므로 같은 규칙으로 다시 찍는다.
|
||||
strengthSamples = resampleRoute(points);
|
||||
pipeEditor.setContext(meta, points);
|
||||
if (routeLayer) fitToRoute();
|
||||
scheduleDraw();
|
||||
if (!routeFromAnswer) takeRoute(points, true); // 페이지 노선 = 첫 답 전만 · 그 뒤 답의 노선(326-6)
|
||||
},
|
||||
stale: () => live.stale,
|
||||
update: () => live.refresh(),
|
||||
dispose() {
|
||||
loadSequence += 1;
|
||||
if (frameHandle) {
|
||||
|
||||
@@ -52,6 +52,10 @@ export interface DrainagePanel {
|
||||
removePipe: (chainageM: number) => void;
|
||||
/** 계산 묶음 drainage 단계(347-2) — 붙이기 = 묶음 쪽. */
|
||||
pipelineStage: StageFn<"drainage">;
|
||||
/** 배수유역도가 낡았나(노선 · 관 자리가 바뀜 · 서버 「낡음」) — B06 [>] 확인 창(326-6). */
|
||||
stale: () => boolean;
|
||||
/** [업데이트] — 지금 노선으로 처음부터 셈 · 답을 화면에 올렸으면 true. */
|
||||
update: () => Promise<boolean>;
|
||||
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
|
||||
selectBasinByChainage: (chainageM: number | null) => void;
|
||||
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
|
||||
|
||||
@@ -449,6 +449,7 @@ export async function fetchDrainageLayers(
|
||||
projectId: string,
|
||||
onProgress: (ratio: number) => void,
|
||||
scope: SheetScope | null = "drainage",
|
||||
bbox?: string,
|
||||
): Promise<{
|
||||
meta: VWorldMeta;
|
||||
layers: Array<readonly [DrainageLayer, GeoJsonCollection | null]>;
|
||||
@@ -463,6 +464,7 @@ export async function fetchDrainageLayers(
|
||||
projectId,
|
||||
layer,
|
||||
scope ?? undefined,
|
||||
bbox,
|
||||
);
|
||||
return [layer, collection] as const;
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Propose.ts
|
||||
* 배수유역 칸 [자동 제안](510) — 서버가 후보를 셈(`post_pipe_proposals` · 세류선 교차 + 간격 보충 ·
|
||||
* 있는 관 · 지운 관 상태) · 화면은 목록만(점선 원 걷음 · 326-6) · 고른 것만 관 넣기 길(`addPipe`)로.
|
||||
*
|
||||
* - 있는 관(라벨 짝 · 교차 5 m 안 관) = 목록에서 뺌(머리 글에 수만) · 지운 관 = 「지운 관」 표시 ·
|
||||
* 고르면 다시 넣음(서버가 지움 표지 걷음).
|
||||
* - 아무것도 미리 안 고름 · [넣기] = 고른 것 · 세류선 교차 = 라벨 실음(노선 바뀐 [저장] 따라감 · 507-2).
|
||||
* - 노선 · 관이 바뀌면(배수유역 낡음) 목록 닫음 — 옛 노선 후보가 새 노선에 안 들어가게.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
|
||||
export interface PipeProposal {
|
||||
chainage_m: number;
|
||||
kind: "stream" | "spacing";
|
||||
state: "new" | "held" | "dismissed";
|
||||
stream_label?: DetailPipeInput["stream_label"];
|
||||
stream_name?: string | null;
|
||||
}
|
||||
|
||||
interface ProposalResponse {
|
||||
proposals: PipeProposal[];
|
||||
/** 간격 보충까지 셈(분석이 지금 노선) — 거짓 = 세류선 교차만. */
|
||||
spacing: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** 후보 한 줄 글. */
|
||||
export function proposalText(item: PipeProposal): string {
|
||||
const at = `측점 ${Math.round(item.chainage_m)} m`;
|
||||
const what =
|
||||
item.kind === "stream"
|
||||
? `세류선 교차${item.stream_name ? `(${item.stream_name})` : ""}`
|
||||
: "간격 보충(흐름이 모이는 자리)";
|
||||
return `${at} — ${what}${item.state === "dismissed" ? " · 지운 관" : ""}`;
|
||||
}
|
||||
|
||||
export interface DrainagePropose {
|
||||
root: HTMLElement;
|
||||
/** 서버에 후보를 물어 목록을 엶. */
|
||||
open(): Promise<void>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export function createDrainagePropose(deps: {
|
||||
projectId: () => string | null;
|
||||
pipeRequest: () => DetailPipeInput[];
|
||||
/** 고른 후보를 관 넣기 길로(패널 `addPipe` 와 같은 길). */
|
||||
addPipes: (picks: PipeProposal[]) => void;
|
||||
}): DrainagePropose {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-drainage__suggest b05-drainage__proposals";
|
||||
root.hidden = true;
|
||||
const head = document.createElement("div");
|
||||
head.className = "b05-drainage__suggest-head";
|
||||
const title = document.createElement("span");
|
||||
const add = document.createElement("button");
|
||||
add.type = "button";
|
||||
add.className = "b05-drainage__analyze";
|
||||
add.textContent = "넣기";
|
||||
add.title = "고른 후보만 관으로 넣습니다 — 초안이라 [저장] 때 남습니다.";
|
||||
const shut = document.createElement("button");
|
||||
shut.type = "button";
|
||||
shut.className = "b05-drainage__analyze";
|
||||
shut.textContent = "닫기";
|
||||
head.append(title, add, shut);
|
||||
const list = document.createElement("ul");
|
||||
list.className = "b05-drainage__suggest-list b05-drainage__proposals-list";
|
||||
root.append(head, list);
|
||||
let shown: PipeProposal[] = [];
|
||||
const picked = new Set<number>();
|
||||
let sequence = 0;
|
||||
|
||||
const close = (): void => {
|
||||
sequence += 1;
|
||||
root.hidden = true;
|
||||
shown = [];
|
||||
picked.clear();
|
||||
list.replaceChildren();
|
||||
};
|
||||
|
||||
const render = (response: ProposalResponse): void => {
|
||||
shown = response.proposals.filter((item) => item.state !== "held");
|
||||
const held = response.proposals.length - shown.length;
|
||||
title.textContent =
|
||||
`자동 제안 ${shown.length}건` +
|
||||
(held ? ` · 있는 관 ${held} 뺌` : "") +
|
||||
(response.spacing ? "" : " · 배수유역 낡음 — 세류선 교차만");
|
||||
list.replaceChildren(
|
||||
...shown.map((item, index) => {
|
||||
const row = document.createElement("li");
|
||||
const label = document.createElement("label");
|
||||
const box = document.createElement("input");
|
||||
box.type = "checkbox";
|
||||
box.addEventListener("change", () => {
|
||||
if (box.checked) picked.add(index);
|
||||
else picked.delete(index);
|
||||
add.disabled = picked.size === 0;
|
||||
});
|
||||
label.append(box, ` ${proposalText(item)}`);
|
||||
row.append(label);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
add.disabled = true;
|
||||
root.hidden = false;
|
||||
};
|
||||
|
||||
add.addEventListener("click", () => {
|
||||
const picks = shown.filter((_, index) => picked.has(index));
|
||||
close();
|
||||
if (picks.length) deps.addPipes(picks);
|
||||
});
|
||||
shut.addEventListener("click", close);
|
||||
|
||||
return {
|
||||
root,
|
||||
async open() {
|
||||
const projectId = deps.projectId();
|
||||
if (!projectId) return;
|
||||
const sent = ++sequence;
|
||||
title.textContent = "자동 제안 — 후보 셈 중";
|
||||
root.hidden = false;
|
||||
list.replaceChildren();
|
||||
add.disabled = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/drainage/pipe-proposals`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ points: deps.pipeRequest() }),
|
||||
},
|
||||
);
|
||||
const payload = (await response.json()) as ProposalResponse;
|
||||
if (sent !== sequence) return;
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
render(payload);
|
||||
} catch (error) {
|
||||
if (sent !== sequence) return;
|
||||
title.textContent = `자동 제안 실패 — ${error instanceof Error ? error.message : error}`;
|
||||
}
|
||||
},
|
||||
close,
|
||||
};
|
||||
}
|
||||
@@ -70,8 +70,6 @@ export interface DrainageScene {
|
||||
stationIntervalM: number;
|
||||
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 없으면 빈 배열. */
|
||||
intervalSpans: ReadonlyArray<RouteSpanBand>;
|
||||
/** 관 자리 후보 누가거리(347-3 · 제안만) — 계획선 위 점선 원. */
|
||||
candidates: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
export function drawDrainageScene(
|
||||
@@ -170,20 +168,6 @@ export function drawDrainageScene(
|
||||
scene.maxHotspotArea,
|
||||
);
|
||||
}
|
||||
// 관 자리 후보 — 점선 원(배관 마커 아래 · 제안만이라 채우지 않음).
|
||||
scene.candidates.forEach((chainage) => {
|
||||
const sample = scene.strengthSamples[Math.round(chainage)];
|
||||
if (!sample) return;
|
||||
const [x, y] = projector.toScreen(sample.x, sample.y);
|
||||
context.save();
|
||||
context.setLineDash([3, 3]);
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "#1e88e5";
|
||||
context.beginPath();
|
||||
context.arc(x, y, 9, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
});
|
||||
// 배관(관 매설) 마커 — 계획선 위.
|
||||
scene.pipeEditor.draw(context, view, scene.pipeColor);
|
||||
// 측점 선택 마킹 — 계획선 위 다이아몬드 표식(2026-08-05 사용자 지시. 3자 선택 동기화의
|
||||
|
||||
@@ -17,7 +17,7 @@ type StatusKey =
|
||||
|
||||
export interface DetailRunnerDeps {
|
||||
projectId: () => string | null;
|
||||
analyzeButton: HTMLButtonElement;
|
||||
updateButton: HTMLButtonElement;
|
||||
status: HTMLElement;
|
||||
showProgress: (ratio: number | null, label: string | null) => void;
|
||||
apply: (response: DetailBasinResponse) => void;
|
||||
@@ -33,14 +33,14 @@ export interface DetailRunner {
|
||||
}
|
||||
|
||||
export function createDetailRunner(deps: DetailRunnerDeps): DetailRunner {
|
||||
const { analyzeButton, status, showProgress, label } = deps;
|
||||
const { updateButton, status, showProgress, label } = deps;
|
||||
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
|
||||
return {
|
||||
projectId: deps.projectId,
|
||||
async run(request, quiet = false) {
|
||||
if (!deps.projectId()) return false;
|
||||
if (!quiet) {
|
||||
analyzeButton.disabled = true;
|
||||
updateButton.disabled = true;
|
||||
status.hidden = false;
|
||||
status.textContent = label("B05_Drainage_Status_Analyzing");
|
||||
showProgress(null, label("B05_Drainage_Status_Analyzing"));
|
||||
@@ -63,7 +63,7 @@ export function createDetailRunner(deps: DetailRunnerDeps): DetailRunner {
|
||||
return false;
|
||||
} finally {
|
||||
if (seq === requestSeq) {
|
||||
analyzeButton.disabled = false;
|
||||
updateButton.disabled = false;
|
||||
showProgress(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Suggest.ts
|
||||
* 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류 · 관 자리 후보를 보여 주고 [적용] 한 번에 관경 · 시설만
|
||||
* 관 초안으로(관 자리 = [자동 제안] · 369).
|
||||
* 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류를 보여 주고 [적용] 한 번에 관 초안으로.
|
||||
* 모으기 · 얹기 규칙은 `logic/B05_Profile_Drainage_Suggest.ts` · 자동 바꿈 없음.
|
||||
* 관 자리 후보는 배수유역도에도 점선 원으로 그림(`candidates()` → 그리기 판).
|
||||
* 관 자리 후보 = [자동 제안] 목록(510 · `B05_Profile_UI_Drainage_Propose`) — 여기 안 올림(두 목록 겹침).
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
@@ -27,8 +26,7 @@ const facilityName = (id: string): string => FACILITY_NAME[id] ?? id;
|
||||
/** 제안 한 줄 글. */
|
||||
export function suggestionText(item: Suggestion): string {
|
||||
const at = `측점 ${Math.round(item.chainageM)} m`;
|
||||
if (item.kind === "position")
|
||||
return `${at} — 관 자리 후보(흐름이 모이는 오목 자리 · [자동 제안])`;
|
||||
if (item.kind === "position") return at; // 모으지 않음(510 · [자동 제안] 목록)
|
||||
if (item.kind === "facility")
|
||||
return `${at} — ${facilityName(item.from)} → ${facilityName(item.to)} 검토`;
|
||||
return `${at} — 관경 ${item.from === null ? "미정" : `Ø${item.from}`} → Ø${item.to}`;
|
||||
@@ -39,8 +37,6 @@ export interface DrainageSuggest {
|
||||
update(response: DetailBasinResponse): void;
|
||||
/** 노선 · 관이 바뀌어 낡음 — [적용] 끔(옛 노선 기준 제안이 새 노선에 안 들어가게). */
|
||||
setStale(stale: boolean): void;
|
||||
/** 관 자리 후보 누가거리 — 배수유역도 점선 원. */
|
||||
candidates(): number[];
|
||||
}
|
||||
|
||||
export function createDrainageSuggest(
|
||||
@@ -66,9 +62,9 @@ export function createDrainageSuggest(
|
||||
root.append(head, list);
|
||||
let suggestions: Suggestion[] = [];
|
||||
let stale = false;
|
||||
/** [적용] 켜짐 — 낡지 않고 관경 · 시설 제안이 있을 때(관 자리 후보만이면 할 일 없음). */
|
||||
/** [적용] 켜짐 — 낡지 않고 관경 · 시설 제안이 있을 때. */
|
||||
const paintApply = (): void => {
|
||||
apply.disabled = stale || !suggestions.some((item) => item.kind !== "position");
|
||||
apply.disabled = stale || suggestions.length === 0;
|
||||
apply.title = stale ? "노선이 바뀌어 제안이 낡음 — 새 계산 뒤 적용" : applyTitle;
|
||||
};
|
||||
|
||||
@@ -82,7 +78,7 @@ export function createDrainageSuggest(
|
||||
update(response) {
|
||||
suggestions = response.stale
|
||||
? []
|
||||
: collectSuggestions(response.pipe_points, response.basins, response.pipe_candidates ?? []);
|
||||
: collectSuggestions(response.pipe_points, response.basins, []);
|
||||
root.hidden = suggestions.length === 0;
|
||||
title.textContent = `제안 ${suggestions.length}건`;
|
||||
paintApply();
|
||||
@@ -98,7 +94,5 @@ export function createDrainageSuggest(
|
||||
stale = next;
|
||||
paintApply();
|
||||
},
|
||||
candidates: () =>
|
||||
suggestions.filter((item) => item.kind === "position").map((item) => item.chainageM),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { stationPartsLabel } from "../common_util/common_util_station";
|
||||
import { PIPE_DISPLAY_NAME } from "../common_util/common_util_structure_catalog";
|
||||
import {
|
||||
ESCAPE_ROUTE_DEFAULT_WIDTH_M,
|
||||
@@ -26,6 +25,10 @@ import {
|
||||
} from "@config/config_frontend";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
|
||||
// 그래프 측점 id · 측점 표기 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
||||
import { irregularLabel } from "./logic/B05_Profile_Profile_Planted";
|
||||
export { irregularLabel, irregularStationId } from "./logic/B05_Profile_Profile_Planted";
|
||||
|
||||
export interface IrregularStation {
|
||||
id: string;
|
||||
/** 측점번호 X. */
|
||||
@@ -126,16 +129,6 @@ interface IrregularStationsCallbacks {
|
||||
onSelect: (station: IrregularStation | null) => void;
|
||||
}
|
||||
|
||||
/** 비정규 측점의 그래프·3D·테이블 공용 식별자. 주입 측점의 station_id로도 쓴다. */
|
||||
export function irregularStationId(id: string): string {
|
||||
return `irregular:${id}`;
|
||||
}
|
||||
|
||||
/** 측점번호+잔여거리 표기 (예: 3+18 · 공용 표기 한 벌 — 거리는 정수로 보이기만). */
|
||||
export function irregularLabel(station: IrregularStation): string {
|
||||
return stationPartsLabel(station.station, station.remainder);
|
||||
}
|
||||
|
||||
function field(labelText: string, input: HTMLElement): HTMLLabelElement {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__field";
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
import { type ButtonVariant, createButton } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
|
||||
// 법정 기준표 = logic(511-B B-3 · X5) — 부르는 쪽은 그대로 여기서 받음
|
||||
export { PROFILE_CRITERIA, SPEED_CHOICES, gradeLimitOf } from "./logic/B05_Profile_Grade_Criteria";
|
||||
|
||||
export interface RoutePanelValues {
|
||||
contourInterval: number;
|
||||
/** 임도 종류 — 현행 3종(간선·산불진화·작업). branch(지선)는 폐지됐으나 기존
|
||||
@@ -32,44 +35,9 @@ export interface RoutePanelValues {
|
||||
stationDisplay: { station: number; cumulative: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값.
|
||||
* 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다.
|
||||
* 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다.
|
||||
*/
|
||||
/** 측점 간격(m) — 20m 고정(2026-08-18 사용자 확정, 입력 UI 삭제). */
|
||||
export const STATION_INTERVAL_M = 20;
|
||||
|
||||
/* 종단기울기·종단곡선 반경 — 지식DB `설계제원_총괄 §6·§7`(간선·산불진화 공통,
|
||||
* 설계속도 40/30/20 → 7·10 / 8·12 / 9·14 %, 반경 450/250/100 m) 값과 일치한다
|
||||
* (2026-08-19 재점검). 서버 `legal_grade_limit_pct`가 같은 표를 쓰므로 화면 안내와
|
||||
* 실제 적용값이 어긋나지 않는다.
|
||||
*
|
||||
* ⚠ 미해결(사용자 협의 필요, 2026-08-19 재점검에서 확인):
|
||||
* ① 등급 선택지가 간선·지선·작업인데 현행 규칙은 **지선 폐지**, 3종은 간선·
|
||||
* 산불진화·작업이다(설계제원_총괄 §1 [구현] 지침).
|
||||
* ② 지식DB의 40/30/20 표는 **설계속도**별이고 간선·산불진화 공통이다. 지금은
|
||||
* 등급에 속도를 고정 매핑(trunk=40·branch=30·work=20)했는데, 작업임도는
|
||||
* 설계속도 20 이하 + 종단기울기 최대 20% 범위 조정이라는 별도 규정이다.
|
||||
* ③ "최소 곡선반경" 입력 칸의 안내값이 **종단**곡선 반경(450/250/100)이다.
|
||||
* 평면 최소곡선반지름은 일반지형 60/30/15 (설계제원_총괄 §4).
|
||||
* 값을 임의로 바꾸지 않고 남긴다 — 기본값 확정은 사용자 협의 영역이다. */
|
||||
export const PROFILE_CRITERIA: Record<
|
||||
RoutePanelValues["designSpeed"],
|
||||
{ grade: Record<RoutePanelValues["terrainType"], number>; radius: number }
|
||||
> = {
|
||||
40: { grade: { normal: 7, special: 10 }, radius: 450 },
|
||||
30: { grade: { normal: 8, special: 12 }, radius: 250 },
|
||||
20: { grade: { normal: 9, special: 14 }, radius: 100 },
|
||||
};
|
||||
|
||||
/** 적용 종단기울기 상한(%) — 넣은 값(> 0)이면 그 값 · 비웠으면 설계속도 · 지형 법정 기준(좌측 패널 · 페이지 설정 한 칸 463). */
|
||||
export const gradeLimitOf = (
|
||||
speed: RoutePanelValues["designSpeed"],
|
||||
terrain: RoutePanelValues["terrainType"],
|
||||
typed: number | null,
|
||||
): number => (typed != null && typed > 0 ? typed : PROFILE_CRITERIA[speed].grade[terrain]);
|
||||
|
||||
/** 임도 종류별 표시 이름 — 프로젝트 등록(B02)과 같은 말을 쓴다(2026-08-19 확정). */
|
||||
export const ROAD_TYPE_LABELS: Record<string, string> = {
|
||||
trunk: "간선임도",
|
||||
@@ -78,14 +46,6 @@ export const ROAD_TYPE_LABELS: Record<string, string> = {
|
||||
branch: "작업임도(구 지선)",
|
||||
};
|
||||
|
||||
/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */
|
||||
export const SPEED_CHOICES: Record<string, Array<RoutePanelValues["designSpeed"]>> = {
|
||||
trunk: [20, 30, 40],
|
||||
fire: [20, 30, 40],
|
||||
work: [20],
|
||||
branch: [20],
|
||||
};
|
||||
|
||||
export type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement };
|
||||
|
||||
export function L(key: keyof typeof ui_locales): string {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { calcOnServerNow } from "../A00_Common/design/design_calc_sites";
|
||||
import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields";
|
||||
|
||||
/** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */
|
||||
@@ -81,7 +82,10 @@ export function createViewControls(callbacks: ViewControlCallbacks): {
|
||||
"3D 업데이트",
|
||||
() => {
|
||||
corridorRefresh.disabled = true;
|
||||
corridorRefresh.textContent = "갱신 중…";
|
||||
// D3 = 서버 = 서버가 짓는 동안 옛 모형 그대로(508-2)
|
||||
corridorRefresh.textContent = calcOnServerNow("corridor_mesh")
|
||||
? "서버에서 짓는 중…"
|
||||
: "갱신 중…";
|
||||
void Promise.resolve(callbacks.onCorridorRefresh()).finally(() => {
|
||||
corridorRefresh.disabled = false;
|
||||
corridorRefresh.textContent = "3D 업데이트";
|
||||
|
||||
@@ -8,15 +8,12 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import { normalizedLongitudinal } from "./logic/B05_Profile_UI_Profile_Data";
|
||||
import { TABLE_TARGET_FONT_PX, tableCellWidthFor } from "./B05_Profile_UI_Profile_Table";
|
||||
import type { LongitudinalSection, SectionStation } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import {
|
||||
irregularLabel,
|
||||
irregularStationId,
|
||||
type IrregularStation,
|
||||
} from "./B05_Profile_UI_IrregularStations";
|
||||
import { PLUS_OWNER_PREFIX } from "../common_util/common_util_structure_station";
|
||||
import type { LongitudinalSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
// 최대 누가거리 · 그래프 측점 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
||||
import { maxChainageOf } from "./logic/B05_Profile_Profile_Planted";
|
||||
export { irregularGraphStations, maxChainageOf } from "./logic/B05_Profile_Profile_Planted";
|
||||
|
||||
/** 테이블 6행(구배 S · 계획고 · 지반고 · 측점 · 곡선 L · 곡선 R). 행 높이와 셀 폭에서
|
||||
* 글자 크기를 정하는 데 쓴다. 2026-09-06 사용자 지시로 12행에서 줄였다. */
|
||||
@@ -40,39 +37,6 @@ export const STATION_SPACING_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL
|
||||
*/
|
||||
export const PROFILE_SPACING_MULTIPLIER = 1.5;
|
||||
|
||||
/** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */
|
||||
export function maxChainageOf(data: LongitudinalSection): number {
|
||||
const samples = normalizedLongitudinal(data).samples.filter(
|
||||
(sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN),
|
||||
);
|
||||
return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비정규 측점을 그래프용 `SectionStation`으로 만든다. 그래프 렌더러는 chainage·라벨·kind만
|
||||
* 쓰므로 월드 좌표는 0으로 둔다(3D 마커용 좌표는 Page가 따로 보간). 범위 밖은 제외.
|
||||
*/
|
||||
export function irregularGraphStations(
|
||||
list: IrregularStation[],
|
||||
maxChainage: number,
|
||||
): SectionStation[] {
|
||||
return list
|
||||
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
|
||||
.map((entry) => ({
|
||||
station_id: irregularStationId(entry.id),
|
||||
chainage_m: entry.chainage_m,
|
||||
label: irregularLabel(entry),
|
||||
kind: "irregular" as const,
|
||||
// 추가 측점(368-2) = 일반측점 색 점선 · 끌기 없음
|
||||
...(entry.id.startsWith(PLUS_OWNER_PREFIX) ? { plus: true } : {}),
|
||||
center_z: null,
|
||||
azimuth_deg: null,
|
||||
center_x: 0,
|
||||
center_y: 0,
|
||||
frame: { left_xy: [0, 0] as [number, number] },
|
||||
}));
|
||||
}
|
||||
|
||||
/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */
|
||||
export function chainageMapper(
|
||||
data: LongitudinalSection,
|
||||
|
||||
@@ -52,6 +52,8 @@ import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import "@util/common_util_mass_haul.css";
|
||||
import "./B05_Profile_UI_Style_MassHaul.css";
|
||||
import { frameOf } from "../common_util/common_util_view_window";
|
||||
import { calcOnServerNow } from "../A00_Common/design/design_calc_sites";
|
||||
import { calcChecked, calcFromServer } from "../A00_Common/design/design_calc_server";
|
||||
|
||||
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
|
||||
/* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
|
||||
@@ -307,7 +309,7 @@ export function createRouteMassHaulPanel(
|
||||
// 배분 선반입도 **여기서** 건다 — 접힘 여부와 무관하게 지나는 자리다(2026-09-06).
|
||||
// 아래 `draw` 안에 두었더니 접힘 가드 뒤라 한 번도 안 불렸고, 그래서 「편집이 멈추면
|
||||
// 조용히 받아 둔다」가 성립하지 않았다(펼친 뒤에야 첫 요청이 나갔다).
|
||||
haulPrefetch?.schedule(picked.result);
|
||||
haulPrefetch?.schedule(picked.result, context);
|
||||
onSummary(badgeValuesFrom(picked.result));
|
||||
}
|
||||
|
||||
@@ -317,15 +319,30 @@ export function createRouteMassHaulPanel(
|
||||
function reportSummary(params: RouteMassHaulDrawParams): void {
|
||||
if (!onSummary) return;
|
||||
if (!context || params.pendingRecalc) return;
|
||||
emitSummary(
|
||||
computeMassHaulSeries(
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
context.conversion,
|
||||
context.naturalSpoilMinSlope,
|
||||
["cross"],
|
||||
),
|
||||
const series = crossSeries(params, context).series;
|
||||
if (series) emitSummary(series);
|
||||
}
|
||||
|
||||
/** 횡단 기준 누가토량 — 계산 자리 quantity = server 면 서버 값(잠잠 뒤 · 그 사이 앞 값 · 511-A). */
|
||||
function crossSeries(
|
||||
params: RouteMassHaulDrawParams,
|
||||
ctx: RouteMassHaulContext,
|
||||
): { series: MassHaulSeries[] | undefined; fresh: boolean } {
|
||||
const args: Parameters<typeof computeMassHaulSeries> = [
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
ctx.conversion,
|
||||
ctx.naturalSpoilMinSlope,
|
||||
["cross"],
|
||||
];
|
||||
if (!calcOnServerNow("quantity")) {
|
||||
const series = calcChecked("quantity", "masshaul.series", computeMassHaulSeries, args);
|
||||
return { series, fresh: true };
|
||||
}
|
||||
const got = calcFromServer<MassHaulSeries[]>("quantity", "masshaul.series", args, () =>
|
||||
draw(params),
|
||||
);
|
||||
return { series: got.value, fresh: got.fresh };
|
||||
}
|
||||
|
||||
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. */
|
||||
@@ -371,13 +388,12 @@ export function createRouteMassHaulPanel(
|
||||
// (354-2 · 계산 코드는 공용에 남김).
|
||||
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
|
||||
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
|
||||
const series: MassHaulSeries[] = computeMassHaulSeries(
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
context.conversion,
|
||||
context.naturalSpoilMinSlope,
|
||||
["cross"],
|
||||
);
|
||||
const { series, fresh } = crossSeries(params, context);
|
||||
if (!fresh) {
|
||||
pendingChip.textContent = "서버 셈 중…";
|
||||
pendingChip.hidden = !drawnOnce;
|
||||
}
|
||||
if (!series) return note("서버가 유토곡선을 셈하는 중입니다.");
|
||||
// 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다.
|
||||
if (series !== lastSeries) emitSummary(series);
|
||||
lastSeries = series;
|
||||
|
||||
@@ -40,6 +40,12 @@ import { createRangeBandView } from "../A00_Common/design/design_profile_range_b
|
||||
import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
|
||||
import { stepStructureStation } from "../common_util/common_util_structure_station";
|
||||
import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
|
||||
import { calcOnServerNow } from "../A00_Common/design/design_calc_sites";
|
||||
import {
|
||||
calcChecked,
|
||||
calcOnServerOnly,
|
||||
trialRequest,
|
||||
} from "../A00_Common/design/design_calc_server";
|
||||
|
||||
/** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */
|
||||
const KEY_STEP_M = 0.1;
|
||||
@@ -141,7 +147,22 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
tools.handleRunPick(sub);
|
||||
return;
|
||||
}
|
||||
ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii()));
|
||||
// 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 값으로 얹음(511-A)
|
||||
const args: Parameters<typeof straightenBetween> = [
|
||||
base,
|
||||
ctx.edits(),
|
||||
fromM,
|
||||
toM,
|
||||
ctx.savedRadii(),
|
||||
];
|
||||
if (calcOnServerNow("fixed_browser")) {
|
||||
const asked = trialRequest("profile.tools", args);
|
||||
void calcOnServerOnly<ReturnType<typeof straightenBetween>>(
|
||||
"fixed_browser",
|
||||
"P7",
|
||||
asked,
|
||||
).then((edits) => edits && ctx.applyEdits(edits));
|
||||
} else ctx.applyEdits(calcChecked("fixed_browser", "profile.tools", straightenBetween, args));
|
||||
tools.clear();
|
||||
},
|
||||
onShift: (runs, delta) => {
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface RouteProfilePanelCallbacks {
|
||||
onStructureSelect?: (structureId: string | null) => void;
|
||||
/** 종단 그래프에서 구조물 서클마크를 끌어 옮김. */
|
||||
onStructureMarkMove?: (structureId: string, toChainageM: number) => void;
|
||||
/** 알약 · 평면 마커 우클릭으로 그 구조물(관 포함)을 지움(514-1). */
|
||||
onStructureMarkRemove?: (structureId: string) => void;
|
||||
/** 종단 그래프 우클릭으로 레지스트리 타입을 지정해 구조물을 넣음. */
|
||||
onStructureTypeAdd?: (chainageM: number, typeId: string) => void;
|
||||
/** 우클릭 군 · 종류 목록 = 사이드 「구조물 배치」 드롭다운 그대로(129-1 · B05 · B06 한 패널). */
|
||||
|
||||
@@ -23,11 +23,11 @@ import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch
|
||||
import { normalizedLongitudinal, toDesignProfile } from "./logic/B05_Profile_UI_Profile_Data";
|
||||
import {
|
||||
adjustStation,
|
||||
setCurveRadius,
|
||||
type AlignmentBase,
|
||||
type AlignmentEdits,
|
||||
type ProfileAlignment,
|
||||
} from "./logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { setCurveRadius } from "./logic/B05_Profile_UI_Profile_Alignment_Edit";
|
||||
import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit";
|
||||
import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight";
|
||||
import {
|
||||
@@ -408,6 +408,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
chainageAt: chainageInverter(longitudinal, width, layout.originOffset),
|
||||
maxChainageM: maxChainageOf(longitudinal),
|
||||
onRemove: (station) => callbacks?.onStructureRemove?.(station),
|
||||
onRemoveMark: (structureId) => callbacks?.onStructureMarkRemove?.(structureId),
|
||||
onRemovePlus: removePlusStation,
|
||||
onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage),
|
||||
onAddStructure: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type),
|
||||
@@ -437,6 +438,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
ctx.redraw();
|
||||
},
|
||||
onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage),
|
||||
onRemove: (structureId) => callbacks?.onStructureMarkRemove?.(structureId),
|
||||
// 라벨 흙막이 상자 수 = 폼과 같은 유출 단 수(54-1 · 54-14)
|
||||
containerCount: (structure) =>
|
||||
structure.type_id === "pipe"
|
||||
|
||||
@@ -11,11 +11,7 @@
|
||||
|
||||
import { pickPx } from "../A00_Common/touch_bridge";
|
||||
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import {
|
||||
irregularStationId,
|
||||
isPipeStation,
|
||||
type IrregularStation,
|
||||
} from "./B05_Profile_UI_IrregularStations";
|
||||
import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
structureAnchorM,
|
||||
type StructureInstance,
|
||||
@@ -60,6 +56,8 @@ export interface StructureLineOptions {
|
||||
maxChainageM: number;
|
||||
/** 선을 지울 때. */
|
||||
onRemove: (station: IrregularStation) => void;
|
||||
/** 알약(평면 마커)으로 구조물을 지울 때(514-1 · 측점선 없는 구조물도). */
|
||||
onRemoveMark?: (structureId: string) => void;
|
||||
/** 추가 측점을 지울 때(368-3 · 우클릭 「측점 삭제」). */
|
||||
onRemovePlus?: (chainageM: number) => void;
|
||||
/** 빈 자리에서 우클릭해 배관을 넣을 때. */
|
||||
@@ -72,16 +70,48 @@ export interface StructureLineOptions {
|
||||
onAddStructureType?: (chainageM: number, typeId: string) => void;
|
||||
}
|
||||
|
||||
/** 지금 선 종단 그래프의 메뉴 재료 — 평면 우클릭이 같은 것을 씀(513-1 · 종단도는 페이지에 한 벌). */
|
||||
let liveMenu: { host: HTMLElement; options: StructureLineOptions } | null = null;
|
||||
|
||||
/** 종단 그래프 메뉴 재료 — 그래프가 아직 안 섰거나 떠났으면 null. */
|
||||
export function liveStructureMenu(): StructureLineOptions | null {
|
||||
return liveMenu?.host.isConnected ? liveMenu.options : null;
|
||||
}
|
||||
|
||||
/** 우클릭 한 자리의 항목 — 가까운 측점이 있으면 삭제, 없으면 그 자리에 추가(종단 그래프 · 평면 공용 · 513-1). */
|
||||
export function structureMenuItemsAt(
|
||||
options: StructureLineOptions,
|
||||
chainageM: number,
|
||||
near: IrregularStation | null,
|
||||
): MapContextMenuItem[] {
|
||||
if (near) {
|
||||
if (near.id.startsWith(PLUS_OWNER_PREFIX))
|
||||
return [["측점 삭제", () => options.onRemovePlus?.(near.chainage_m)]];
|
||||
return [["구조물 삭제", () => options.onRemove(near)]]; // 관 · 세월교 · BOX 도 한 글(514-1)
|
||||
}
|
||||
const at = Number(Math.min(Math.max(chainageM, 0), options.maxChainageM).toFixed(2));
|
||||
// 빈 자리 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 메뉴(2026-08-18
|
||||
// 일원화). 선택 = 폼 자동 지정(addAt 경로, A군은 임시 배치까지). 레지스트리를
|
||||
// 아직 못 받았으면 구 항목(배관·기성막이 등)으로 대신한다.
|
||||
const groups = options.structureGroups?.() ?? [];
|
||||
return groups.length
|
||||
? structureGroupMenuItems(groups, (typeId) => options.onAddStructureType?.(at, typeId))
|
||||
: [
|
||||
["배관 추가", () => options.onAddPipe(at)],
|
||||
...(["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [
|
||||
`${type === "기타" ? "기타 구조물" : type} 추가`,
|
||||
() => options.onAddStructure?.(at, type),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 그래프 칸에 우클릭 메뉴를 붙인다. 그래프는 매 그리기마다 새로 만들어지므로 이 함수도
|
||||
* 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다.
|
||||
* 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다(평면이 쓸 재료만 `liveMenu` 에 둠).
|
||||
*/
|
||||
export function mountStructureMenu(host: HTMLElement, options: StructureLineOptions): void {
|
||||
const menu = createMapContextMenu("b05-profile-chart");
|
||||
|
||||
function clamp(chainageM: number): number {
|
||||
return Math.min(Math.max(chainageM, 0), options.maxChainageM);
|
||||
}
|
||||
liveMenu = { host, options };
|
||||
|
||||
// 우클릭 — 가까운 구조물이 있으면 삭제, 없으면 그 자리에 배관을 넣는다.
|
||||
host.addEventListener("contextmenu", (event) => {
|
||||
@@ -91,40 +121,17 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti
|
||||
}
|
||||
const rect = host.getBoundingClientRect();
|
||||
const localX = event.clientX - rect.left;
|
||||
const chainage = clamp(options.chainageAt(localX));
|
||||
// 선을 그리지 않으므로 화면 거리로 가장 가까운 구조물을 찾는다.
|
||||
const near = options.stations
|
||||
.map((station) => ({ station, distance: Math.abs(options.x(station.chainage_m) - localX) }))
|
||||
.filter((entry) => entry.distance <= pickPx(GRAB_SLACK_PX))
|
||||
.sort((left, right) => left.distance - right.distance)[0];
|
||||
event.preventDefault();
|
||||
const at = Number(chainage.toFixed(2));
|
||||
// 빈 자리 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 메뉴(2026-08-18
|
||||
// 일원화). 선택 = 폼 자동 지정(addAt 경로, A군은 임시 배치까지). 레지스트리를
|
||||
// 아직 못 받았으면 구 항목(배관·기성막이 등)으로 대신한다.
|
||||
const groups = options.structureGroups?.() ?? [];
|
||||
const addItems: MapContextMenuItem[] = groups.length
|
||||
? structureGroupMenuItems(groups, (typeId) => options.onAddStructureType?.(at, typeId))
|
||||
: [
|
||||
["배관 추가", () => options.onAddPipe(at)],
|
||||
...(["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [
|
||||
`${type === "기타" ? "기타 구조물" : type} 추가`,
|
||||
() => options.onAddStructure?.(at, type),
|
||||
]),
|
||||
];
|
||||
const plus = near?.station.id.startsWith(PLUS_OWNER_PREFIX);
|
||||
menu.open(localX, event.clientY - rect.top, [
|
||||
...(near
|
||||
? [
|
||||
(plus
|
||||
? ["측점 삭제", () => options.onRemovePlus?.(near.station.chainage_m)]
|
||||
: [
|
||||
isPipeStation(near.station) ? "배관 삭제" : "구조물 삭제",
|
||||
() => options.onRemove(near.station),
|
||||
]) as [string, () => void],
|
||||
]
|
||||
: addItems),
|
||||
]);
|
||||
menu.open(
|
||||
localX,
|
||||
event.clientY - rect.top,
|
||||
structureMenuItemsAt(options, options.chainageAt(localX), near?.station ?? null),
|
||||
);
|
||||
});
|
||||
host.addEventListener("pointerdown", (event) => {
|
||||
if (!menu.contains(event.target)) menu.close();
|
||||
|
||||
@@ -18,8 +18,13 @@ import type {
|
||||
AlignmentSegment,
|
||||
ProfileAlignment,
|
||||
} from "./logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { stationLabel } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import { crowdedThickness } from "../B06_Section/logic/B06_Section_Label_Tiers";
|
||||
import {
|
||||
buildSelectedColumn,
|
||||
displayStationLabel,
|
||||
element,
|
||||
isStraightCurve,
|
||||
} from "./B05_Profile_UI_Profile_Table_Selected";
|
||||
import {
|
||||
irregularStationId,
|
||||
isPipeStation,
|
||||
@@ -90,20 +95,6 @@ const CURVE_ROTATED_WIDTH_PX = 16;
|
||||
*/
|
||||
const SAME_STATION_TOLERANCE_M = 0.1;
|
||||
|
||||
/**
|
||||
* 대수차가 이 값보다 작으면 **직선**으로 본다(%).
|
||||
*
|
||||
* 직선화하면 그 자리의 좌·우 기울기가 같아져 대수차가 0 이 되고, `L = R × |대수차|` 를
|
||||
* 뒤집은 R 은 수억 m 로 튄다. 직선 구간에는 반경이 없으므로 표에는 빈 칸으로 둔다
|
||||
* (2026-09-04 사용자 지시). 진짜 완만한 변화점(대수차 0.9% 수준)은 그대로 남는다.
|
||||
*/
|
||||
const STRAIGHT_DELTA_PCT = 1e-4;
|
||||
|
||||
/** 직선화로 대수차가 사라진 자리인가 — 표기 대상에서 뺀다. */
|
||||
function isStraightCurve(curve: AlignmentCurve): boolean {
|
||||
return Math.abs(curve.delta_pct) < STRAIGHT_DELTA_PCT;
|
||||
}
|
||||
|
||||
/** 구조물(비정규) 측점 누가거리 목록에 이 측점이 들어 있는가 — 허용 오차로 본다. */
|
||||
function isNearChainage(chainageM: number, list: ReadonlyArray<number>): boolean {
|
||||
return list.some((entry) => Math.abs(entry - chainageM) < SAME_STATION_TOLERANCE_M);
|
||||
@@ -130,14 +121,6 @@ function fitFontSize(rowHeight: number, cellWidth: number): number {
|
||||
function segmentTextFits(text: string, widthPx: number, fontPx: number): boolean {
|
||||
return widthPx >= text.length * fontPx * CHAR_WIDTH_EM + CELL_PADDING_PX * 2;
|
||||
}
|
||||
|
||||
function element(tag: string, className: string, text?: string): HTMLElement {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 절대 배치 셀: 측점 x를 중심으로 좌우 대칭 배치한다. */
|
||||
function placeCell(row: HTMLElement, centerX: number, node: HTMLElement): void {
|
||||
node.style.left = `${centerX}px`;
|
||||
@@ -184,15 +167,6 @@ function createRow(className: string, label: string, unit: string): HTMLElement
|
||||
const SEGMENT_ROWS: SegmentRowSpec[] = [
|
||||
{ label: "구배 S", unit: "% 구간 기울기", cell: (segment) => segment.grade_percent.toFixed(2) },
|
||||
];
|
||||
|
||||
/** 이어 공사 시작 기준을 반영한 측점 표기 — 측점번호에 시작 측점을 더한다(잔여거리는 그대로). */
|
||||
function displayStationLabel(chainageM: number, interval: number, stationOffset: number): string {
|
||||
const base = stationLabel(chainageM, interval); // "X+YY.Y"
|
||||
if (!stationOffset) return base;
|
||||
const [number, remainder] = base.split("+");
|
||||
return `${Number(number) + stationOffset}+${remainder}`;
|
||||
}
|
||||
|
||||
function buildStationRows(
|
||||
alignment: ProfileAlignment,
|
||||
interval: number,
|
||||
@@ -423,134 +397,6 @@ function buildCurveRows(
|
||||
return [lengthRow, radiusRow];
|
||||
}
|
||||
|
||||
/** 정렬된 계획선 샘플에서 chainage 위치 값을 선형보간한다(범위 밖은 양 끝 클램프). */
|
||||
function interpolateSample(
|
||||
samples: ProfileAlignment["samples"],
|
||||
chainageM: number,
|
||||
key: "elevation_m" | "ground_elevation_m",
|
||||
): number | null {
|
||||
if (!samples.length) return null;
|
||||
if (chainageM <= samples[0].chainage_m) return samples[0][key];
|
||||
const last = samples[samples.length - 1];
|
||||
if (chainageM >= last.chainage_m) return last[key];
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const current = samples[index];
|
||||
if (current.chainage_m < chainageM) continue;
|
||||
const previous = samples[index - 1];
|
||||
const span = current.chainage_m - previous.chainage_m;
|
||||
if (span <= 1e-9) return current[key];
|
||||
const ratio = (chainageM - previous.chainage_m) / span;
|
||||
return previous[key] + (current[key] - previous[key]) * ratio;
|
||||
}
|
||||
return last[key];
|
||||
}
|
||||
|
||||
/** 값 열 한 칸의 내용: 단일 값이거나, 변화점에서 좌/우로 갈린 두 값. */
|
||||
type CellValue = { text: string } | { left: string; right: string };
|
||||
|
||||
/**
|
||||
* 선택된 측점(규칙·비정규 공용)의 **값 열 오버레이**. 12행 순서 그대로 값(계획고·지반고·누가거리·
|
||||
* 측점·구배·곡선 등)을 쌓아 테이블 위에 겹친다. 규칙 측점을 눌러도 비정규와 **같은 오버레이 방식**으로
|
||||
* 눈에 잘 들어오게 한다. `centerX`는 호출부가 정한다 — 종점처럼 셀이 우측 이동된 규칙 측점은 그 이동된
|
||||
* 중심을, 비정규 측점은 `x(chainage)`를 넘긴다.
|
||||
*/
|
||||
function buildSelectedColumn(
|
||||
chainage: number,
|
||||
alignment: ProfileAlignment,
|
||||
centerX: number,
|
||||
cellWidth: number,
|
||||
interval: number,
|
||||
display: { station: number; cumulative: number },
|
||||
onAdjustStation?: (chainageM: number, deltaM: number) => void,
|
||||
isIrregular = false,
|
||||
mc = false,
|
||||
): HTMLElement {
|
||||
const plan = interpolateSample(alignment.samples, chainage, "elevation_m");
|
||||
const ground = interpolateSample(alignment.samples, chainage, "ground_elevation_m");
|
||||
const cut = plan !== null && ground !== null && ground - plan > 0.005 ? ground - plan : null;
|
||||
const fill = plan !== null && ground !== null && plan - ground > 0.005 ? plan - ground : null;
|
||||
|
||||
// 구배(L/H/S): 이 측점이 구간 안에 있으면 그 구간 값을 그대로(중복이어도) 표기하고,
|
||||
// 변화점이라 좌우 구간이 갈리면 세로 구분선으로 셀을 반 나눠 좌/우 값을 각각 넣는다.
|
||||
const inside = alignment.segments.find(
|
||||
(segment) => segment.from_m + 1e-6 < chainage && chainage < segment.to_m - 1e-6,
|
||||
);
|
||||
const leftSeg = alignment.segments.find((segment) => Math.abs(segment.to_m - chainage) < 0.01);
|
||||
const rightSeg = alignment.segments.find((segment) => Math.abs(segment.from_m - chainage) < 0.01);
|
||||
const gradeCell = (pick: (segment: AlignmentSegment) => number, digits: number): CellValue => {
|
||||
if (inside) return { text: pick(inside).toFixed(digits) };
|
||||
const left = leftSeg ? pick(leftSeg).toFixed(digits) : "";
|
||||
const right = rightSeg ? pick(rightSeg).toFixed(digits) : "";
|
||||
return left && right ? { left, right } : { text: left || right || "" };
|
||||
};
|
||||
const curveAt = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01);
|
||||
// 값 열 오버레이도 같은 규칙 — 직선화된 자리는 곡선 L·R 을 비운다.
|
||||
const curve = curveAt && !isStraightCurve(curveAt) ? curveAt : undefined;
|
||||
// 테이블 행 순서(구배 1 · 측점값 3 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백.
|
||||
// `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 6행 격자는 불변).
|
||||
// 절토·성토고는 행이 빠졌어도 값 자체는 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다.
|
||||
const rows: Array<{ value: CellValue; modifier: string; plan?: boolean }> = [
|
||||
{ value: gradeCell((segment) => segment.grade_percent, 2), modifier: "grade" }, // 구배 S
|
||||
{ value: { text: plan !== null ? plan.toFixed(2) : "" }, modifier: "plan", plan: true }, // 계획고
|
||||
{ value: { text: ground !== null ? ground.toFixed(2) : "" }, modifier: "" }, // 지반고
|
||||
{
|
||||
value: {
|
||||
text: displayStationLabel(chainage, interval, display.station) + (mc ? " (MC)" : ""),
|
||||
},
|
||||
modifier: mc ? "mc" : "",
|
||||
}, // 측점
|
||||
{ value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L
|
||||
{ value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R
|
||||
];
|
||||
const column = element(
|
||||
"div",
|
||||
`b05-profile-table__irregular-col${isIrregular ? " is-floating" : ""}`,
|
||||
);
|
||||
column.style.left = `${centerX}px`;
|
||||
column.style.width = `${cellWidth}px`;
|
||||
rows.forEach((row) => {
|
||||
const cell = element(
|
||||
"div",
|
||||
`b05-profile-table__irregular-col-cell${row.modifier ? ` is-${row.modifier}` : ""}`,
|
||||
);
|
||||
if (row.plan && onAdjustStation && plan !== null) {
|
||||
// 계획고 직접 입력 → (입력값 − 현재 계획고) delta로 station_offset 반영(규칙·비정규 공용).
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.step = "any";
|
||||
input.className = "b05-profile-table__irregular-input b05-profile-table__no-spin";
|
||||
input.value = plan.toFixed(2);
|
||||
input.title = `계획고 직접 입력 (현재 ${plan.toFixed(2)}m)`;
|
||||
input.addEventListener("change", () => {
|
||||
const target = Number.parseFloat(input.value);
|
||||
if (Number.isFinite(target)) onAdjustStation(chainage, target - plan);
|
||||
});
|
||||
cell.append(input);
|
||||
} else if ("left" in row.value) {
|
||||
// 좌우 값을 세로 구분선으로 반씩 나눠 넣는다(각 반쪽에서 폰트를 줄여 맞춘다).
|
||||
cell.classList.add("is-split");
|
||||
cell.append(
|
||||
element("span", "b05-profile-table__irregular-col-half", row.value.left),
|
||||
element("span", "b05-profile-table__irregular-col-half", row.value.right),
|
||||
);
|
||||
} else {
|
||||
cell.textContent = row.value.text;
|
||||
}
|
||||
column.append(cell);
|
||||
});
|
||||
// 행에서 뺀 값(절토고·성토고·누가거리)은 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다.
|
||||
const cutFillText =
|
||||
cut !== null
|
||||
? ` · 절토 ${cut.toFixed(2)}m`
|
||||
: fill !== null
|
||||
? ` · 성토 ${fill.toFixed(2)}m`
|
||||
: "";
|
||||
column.title = `${displayStationLabel(chainage, interval, display.station)} · 누가거리 ${(
|
||||
chainage + display.cumulative
|
||||
).toFixed(2)}m${cutFillText}`;
|
||||
return column;
|
||||
}
|
||||
|
||||
export function createProfileTable(options: ProfileTableOptions): HTMLElement {
|
||||
const { alignment, stationInterval, width, height, cellWidth, labelWidth, rowCount, x } = options;
|
||||
const display = options.stationDisplay ?? { station: 0, cumulative: 0 };
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Table_Selected.ts
|
||||
* 종단면도 하단 표 — 고른 측점의 **값 열 오버레이**(`buildSelectedColumn`)와 표 본체가 같이 쓰는 조각
|
||||
* (`element` · `displayStationLabel` · `isStraightCurve`). 표 본체는 `B05_Profile_UI_Profile_Table`.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
AlignmentCurve,
|
||||
AlignmentSegment,
|
||||
ProfileAlignment,
|
||||
} from "./logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { stationLabel } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
|
||||
/**
|
||||
* 대수차가 이 값보다 작으면 **직선**으로 본다(%).
|
||||
*
|
||||
* 직선화하면 그 자리의 좌·우 기울기가 같아져 대수차가 0 이 되고, `L = R × |대수차|` 를
|
||||
* 뒤집은 R 은 수억 m 로 튄다. 직선 구간에는 반경이 없으므로 표에는 빈 칸으로 둔다
|
||||
* (2026-09-04 사용자 지시). 진짜 완만한 변화점(대수차 0.9% 수준)은 그대로 남는다.
|
||||
*/
|
||||
const STRAIGHT_DELTA_PCT = 1e-4;
|
||||
|
||||
/** 직선화로 대수차가 사라진 자리인가 — 표기 대상에서 뺀다. */
|
||||
export function isStraightCurve(curve: AlignmentCurve): boolean {
|
||||
return Math.abs(curve.delta_pct) < STRAIGHT_DELTA_PCT;
|
||||
}
|
||||
|
||||
export function element(tag: string, className: string, text?: string): HTMLElement {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 이어 공사 시작 기준을 반영한 측점 표기 — 측점번호에 시작 측점을 더한다(잔여거리는 그대로). */
|
||||
export function displayStationLabel(
|
||||
chainageM: number,
|
||||
interval: number,
|
||||
stationOffset: number,
|
||||
): string {
|
||||
const base = stationLabel(chainageM, interval); // "X+YY.Y"
|
||||
if (!stationOffset) return base;
|
||||
const [number, remainder] = base.split("+");
|
||||
return `${Number(number) + stationOffset}+${remainder}`;
|
||||
}
|
||||
|
||||
/** 정렬된 계획선 샘플에서 chainage 위치 값을 선형보간한다(범위 밖은 양 끝 클램프). */
|
||||
function interpolateSample(
|
||||
samples: ProfileAlignment["samples"],
|
||||
chainageM: number,
|
||||
key: "elevation_m" | "ground_elevation_m",
|
||||
): number | null {
|
||||
if (!samples.length) return null;
|
||||
if (chainageM <= samples[0].chainage_m) return samples[0][key];
|
||||
const last = samples[samples.length - 1];
|
||||
if (chainageM >= last.chainage_m) return last[key];
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const current = samples[index];
|
||||
if (current.chainage_m < chainageM) continue;
|
||||
const previous = samples[index - 1];
|
||||
const span = current.chainage_m - previous.chainage_m;
|
||||
if (span <= 1e-9) return current[key];
|
||||
const ratio = (chainageM - previous.chainage_m) / span;
|
||||
return previous[key] + (current[key] - previous[key]) * ratio;
|
||||
}
|
||||
return last[key];
|
||||
}
|
||||
|
||||
/** 값 열 한 칸의 내용: 단일 값이거나, 변화점에서 좌/우로 갈린 두 값. */
|
||||
type CellValue = { text: string } | { left: string; right: string };
|
||||
|
||||
/**
|
||||
* 선택된 측점(규칙·비정규 공용)의 **값 열 오버레이**. 12행 순서 그대로 값(계획고·지반고·누가거리·
|
||||
* 측점·구배·곡선 등)을 쌓아 테이블 위에 겹친다. 규칙 측점을 눌러도 비정규와 **같은 오버레이 방식**으로
|
||||
* 눈에 잘 들어오게 한다. `centerX`는 호출부가 정한다 — 종점처럼 셀이 우측 이동된 규칙 측점은 그 이동된
|
||||
* 중심을, 비정규 측점은 `x(chainage)`를 넘긴다.
|
||||
*/
|
||||
export function buildSelectedColumn(
|
||||
chainage: number,
|
||||
alignment: ProfileAlignment,
|
||||
centerX: number,
|
||||
cellWidth: number,
|
||||
interval: number,
|
||||
display: { station: number; cumulative: number },
|
||||
onAdjustStation?: (chainageM: number, deltaM: number) => void,
|
||||
isIrregular = false,
|
||||
mc = false,
|
||||
): HTMLElement {
|
||||
const plan = interpolateSample(alignment.samples, chainage, "elevation_m");
|
||||
const ground = interpolateSample(alignment.samples, chainage, "ground_elevation_m");
|
||||
const cut = plan !== null && ground !== null && ground - plan > 0.005 ? ground - plan : null;
|
||||
const fill = plan !== null && ground !== null && plan - ground > 0.005 ? plan - ground : null;
|
||||
|
||||
// 구배(L/H/S): 이 측점이 구간 안에 있으면 그 구간 값을 그대로(중복이어도) 표기하고,
|
||||
// 변화점이라 좌우 구간이 갈리면 세로 구분선으로 셀을 반 나눠 좌/우 값을 각각 넣는다.
|
||||
const inside = alignment.segments.find(
|
||||
(segment) => segment.from_m + 1e-6 < chainage && chainage < segment.to_m - 1e-6,
|
||||
);
|
||||
const leftSeg = alignment.segments.find((segment) => Math.abs(segment.to_m - chainage) < 0.01);
|
||||
const rightSeg = alignment.segments.find((segment) => Math.abs(segment.from_m - chainage) < 0.01);
|
||||
const gradeCell = (pick: (segment: AlignmentSegment) => number, digits: number): CellValue => {
|
||||
if (inside) return { text: pick(inside).toFixed(digits) };
|
||||
const left = leftSeg ? pick(leftSeg).toFixed(digits) : "";
|
||||
const right = rightSeg ? pick(rightSeg).toFixed(digits) : "";
|
||||
return left && right ? { left, right } : { text: left || right || "" };
|
||||
};
|
||||
const curveAt = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01);
|
||||
// 값 열 오버레이도 같은 규칙 — 직선화된 자리는 곡선 L·R 을 비운다.
|
||||
const curve = curveAt && !isStraightCurve(curveAt) ? curveAt : undefined;
|
||||
// 테이블 행 순서(구배 1 · 측점값 3 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백.
|
||||
// `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 6행 격자는 불변).
|
||||
// 절토·성토고는 행이 빠졌어도 값 자체는 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다.
|
||||
const rows: Array<{ value: CellValue; modifier: string; plan?: boolean }> = [
|
||||
{ value: gradeCell((segment) => segment.grade_percent, 2), modifier: "grade" }, // 구배 S
|
||||
{ value: { text: plan !== null ? plan.toFixed(2) : "" }, modifier: "plan", plan: true }, // 계획고
|
||||
{ value: { text: ground !== null ? ground.toFixed(2) : "" }, modifier: "" }, // 지반고
|
||||
{
|
||||
value: {
|
||||
text: displayStationLabel(chainage, interval, display.station) + (mc ? " (MC)" : ""),
|
||||
},
|
||||
modifier: mc ? "mc" : "",
|
||||
}, // 측점
|
||||
{ value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L
|
||||
{ value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R
|
||||
];
|
||||
const column = element(
|
||||
"div",
|
||||
`b05-profile-table__irregular-col${isIrregular ? " is-floating" : ""}`,
|
||||
);
|
||||
column.style.left = `${centerX}px`;
|
||||
column.style.width = `${cellWidth}px`;
|
||||
rows.forEach((row) => {
|
||||
const cell = element(
|
||||
"div",
|
||||
`b05-profile-table__irregular-col-cell${row.modifier ? ` is-${row.modifier}` : ""}`,
|
||||
);
|
||||
if (row.plan && onAdjustStation && plan !== null) {
|
||||
// 계획고 직접 입력 → (입력값 − 현재 계획고) delta로 station_offset 반영(규칙·비정규 공용).
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.step = "any";
|
||||
input.className = "b05-profile-table__irregular-input b05-profile-table__no-spin";
|
||||
input.value = plan.toFixed(2);
|
||||
input.title = `계획고 직접 입력 (현재 ${plan.toFixed(2)}m)`;
|
||||
input.addEventListener("change", () => {
|
||||
const target = Number.parseFloat(input.value);
|
||||
if (Number.isFinite(target)) onAdjustStation(chainage, target - plan);
|
||||
});
|
||||
cell.append(input);
|
||||
} else if ("left" in row.value) {
|
||||
// 좌우 값을 세로 구분선으로 반씩 나눠 넣는다(각 반쪽에서 폰트를 줄여 맞춘다).
|
||||
cell.classList.add("is-split");
|
||||
cell.append(
|
||||
element("span", "b05-profile-table__irregular-col-half", row.value.left),
|
||||
element("span", "b05-profile-table__irregular-col-half", row.value.right),
|
||||
);
|
||||
} else {
|
||||
cell.textContent = row.value.text;
|
||||
}
|
||||
column.append(cell);
|
||||
});
|
||||
// 행에서 뺀 값(절토고·성토고·누가거리)은 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다.
|
||||
const cutFillText =
|
||||
cut !== null
|
||||
? ` · 절토 ${cut.toFixed(2)}m`
|
||||
: fill !== null
|
||||
? ` · 성토 ${fill.toFixed(2)}m`
|
||||
: "";
|
||||
column.title = `${displayStationLabel(chainage, interval, display.station)} · 누가거리 ${(
|
||||
chainage + display.cumulative
|
||||
).toFixed(2)}m${cutFillText}`;
|
||||
return column;
|
||||
}
|
||||
@@ -25,9 +25,12 @@ import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
bindMinorContourToggle,
|
||||
createSheetFollow,
|
||||
fetchRouteSheets,
|
||||
loadRouteEditBackground,
|
||||
contourHintText,
|
||||
type RouteEditContours,
|
||||
type SheetLayers,
|
||||
} from "./B05_Profile_UI_RouteEdit_Contour";
|
||||
import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock";
|
||||
import {
|
||||
@@ -37,7 +40,7 @@ import {
|
||||
} from "./B05_Profile_Api_Replan";
|
||||
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
|
||||
import type { EditedCurve, EditedNode } from "./logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import { drawRouteEditScene, polylineLengthM, watchTheme } from "./B05_Profile_UI_RouteEdit_Render";
|
||||
import { drawRouteEditScene, routeHeadText, watchTheme } from "./B05_Profile_UI_RouteEdit_Render";
|
||||
import { bindNodeSplice, bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import { bindRouteEditPointer } from "./B05_Profile_UI_RouteEdit_Pointer";
|
||||
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
|
||||
@@ -74,6 +77,7 @@ import type { DesignPageHost } from "../A00_Common/design/design_page";
|
||||
import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft";
|
||||
import { bindStationSnap, paintStationSnap } from "./B05_Profile_UI_RouteEdit_SnapStep";
|
||||
import * as fit from "./logic/B05_Profile_RouteEdit_StationFit";
|
||||
import { calcChecked } from "../A00_Common/design/design_calc_server";
|
||||
import "./B05_Profile_UI_Style_RouteEdit.css";
|
||||
|
||||
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
|
||||
@@ -226,6 +230,8 @@ export async function mountRouteEditor(
|
||||
mapRect: computeMapRect(null, 0, 0),
|
||||
};
|
||||
let closed = false;
|
||||
/** 놓은 뒤 노선이 도엽 범위 밖이면 다시 받음(508-2 ③) — 자료를 읽은 뒤 섬. */
|
||||
let sheetFollow: ReturnType<typeof createSheetFollow> | null = null;
|
||||
/** 지도 회전 — 단추 배선과 좌표 되돌리기는 `_Rotate` 몫(계획서 0-9 ⑯). */
|
||||
const rotation = createMapRotation({
|
||||
overlay,
|
||||
@@ -250,6 +256,7 @@ export async function mountRouteEditor(
|
||||
unbindApply(); // 노선 저장 등록부에서 풂(445-2)
|
||||
viewMemory.flush();
|
||||
closed = true;
|
||||
sheetFollow?.dispose();
|
||||
sizeWatch.disconnect();
|
||||
themeWatch();
|
||||
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 떠난 뒤에도 산다.
|
||||
@@ -366,19 +373,10 @@ export async function mountRouteEditor(
|
||||
nodeInfo = built.nodes;
|
||||
}
|
||||
|
||||
/** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로
|
||||
* 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */
|
||||
/** 상태줄 머리 · 고른 등고선 글 — 짓기는 `_Render` · `_Contour` 몫. */
|
||||
const routeHead = (): string =>
|
||||
`예상노선 ${polylineLengthM(expected).toFixed(1)}m · ` +
|
||||
`계획노선 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` +
|
||||
`노드 ${planned.length}개`;
|
||||
|
||||
/** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */
|
||||
const contourHint = (): string => {
|
||||
if (pickedContour < 0) return "등고선을 누르면 그 줄의 높이가 보입니다.";
|
||||
const level = contours?.layer.features[pickedContour]?.labelValue ?? null;
|
||||
return level === null ? "등고선 한 줄을 골랐습니다." : `고른 등고선 ${level}m.`;
|
||||
};
|
||||
routeHeadText(expected, plannedLine.length ? plannedLine : planned, planned.length);
|
||||
const contourHint = (): string => contourHintText(contours, pickedContour);
|
||||
|
||||
/** 상태줄 꼬리 — 셈은 `_Edits` 몫. */
|
||||
const curveHint = (): string =>
|
||||
@@ -434,7 +432,13 @@ export async function mountRouteEditor(
|
||||
markEdited();
|
||||
const stop = fitting && before ? edits.floorStop(before, planned, nodeInfo, fitLimits()) : "";
|
||||
if (stop && before) return restoreSnapshot(before, stop, false);
|
||||
if (stationSnap && fitting) planned = fit.fitAfter(before, snapshotNow(), fitLimits());
|
||||
// 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 대조만(511-A)
|
||||
if (stationSnap && fitting)
|
||||
planned = calcChecked("fixed_browser", "route.station_fit", fit.fitAfter, [
|
||||
before,
|
||||
snapshotNow(),
|
||||
fitLimits(),
|
||||
]);
|
||||
markEdited();
|
||||
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
|
||||
syncCurveBar();
|
||||
@@ -442,6 +446,7 @@ export async function mountRouteEditor(
|
||||
draw();
|
||||
if (record) history?.commit(snapshotNow());
|
||||
historyControls.sync();
|
||||
sheetFollow?.poke();
|
||||
}
|
||||
|
||||
/** 계산 묶음에 알림 — 노선 · 스냅이 저장분과 다르면 초안 · 같으면 null(430 · 433-14 ②). */
|
||||
@@ -600,7 +605,7 @@ export async function mountRouteEditor(
|
||||
try {
|
||||
const [plan, drainage, sources] = await Promise.all([
|
||||
fetchRoutePlan(projectId),
|
||||
fetchRouteSheets(projectId, () => plannedLine),
|
||||
fetchRouteSheets(projectId, () => (planned.length ? planned : plannedLine)), // 띠 = 편집 노드
|
||||
fetchTerrainSources(projectId).catch(() => null),
|
||||
]);
|
||||
if (closed) return;
|
||||
@@ -635,13 +640,20 @@ export async function mountRouteEditor(
|
||||
terrainName.textContent = `지형 ${designTerrain === "sheet" ? "수치지형도" : "라이다"}`;
|
||||
// 등고선은 따로 고른다(LAS 우선 · 수치지형도를 고르면 도엽). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
|
||||
// 확정 모델 등고선은 설계 지형이 라이다일 때만 라이다 것 — 수치지형도 설계면 도엽 선(글도 도엽).
|
||||
const loadBackground = () =>
|
||||
loadRouteEditBackground(projectId, drainage, {
|
||||
surfaceModelId: designTerrain === "las" ? (options.surfaceModelId ?? null) : null,
|
||||
intervalM: options.contourIntervalM ?? 1,
|
||||
smooth: options.smooth ?? false,
|
||||
});
|
||||
let sheets: SheetLayers = drainage;
|
||||
const loadBackground = (reuse: RouteEditContours | null = null) =>
|
||||
loadRouteEditBackground(
|
||||
projectId,
|
||||
sheets,
|
||||
{
|
||||
surfaceModelId: designTerrain === "las" ? (options.surfaceModelId ?? null) : null,
|
||||
intervalM: options.contourIntervalM ?? 1,
|
||||
smooth: options.smooth ?? false,
|
||||
},
|
||||
reuse,
|
||||
);
|
||||
const useBackground = (background: Awaited<ReturnType<typeof loadBackground>>): void => {
|
||||
sheets = background.sheets;
|
||||
otherSheets = background.otherSheets;
|
||||
contours = background.contours;
|
||||
pickedContour = contourLock.reattach(contours?.layer ?? null);
|
||||
@@ -660,6 +672,15 @@ export async function mountRouteEditor(
|
||||
};
|
||||
useBackground(await loadBackground());
|
||||
if (closed) return;
|
||||
sheetFollow = createSheetFollow(
|
||||
projectId,
|
||||
() => sheets,
|
||||
async (next) => {
|
||||
sheets = next;
|
||||
const background = await loadBackground(contours);
|
||||
if (!closed) (useBackground(background), draw());
|
||||
},
|
||||
);
|
||||
viewWanted = true;
|
||||
resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때).
|
||||
showStatus();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { createSidePanels, type SidePanels } from "@ui/ui_template_side_panels";
|
||||
import { createSidePanels } from "@ui/ui_template_side_panels";
|
||||
import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
|
||||
import { bindMarkToggle } from "./B05_Profile_UI_RouteEdit_Marks";
|
||||
import { bottomRowNarrow, infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoLift";
|
||||
@@ -65,9 +65,6 @@ export interface RouteEditChrome {
|
||||
/** 손잡이 자리(24) + 틈(8) — 접혀도 남는 폭(`.ui-side-panels` margin-left). */
|
||||
const SIDE_HANDLE_PX = 32;
|
||||
|
||||
/** 횡단 판 몸 → 패널 묶음 — 고른 측점 횡단을 띄울 때 접힌 판을 폄(382-2 · `_Cross`). */
|
||||
export const sidePanelsOf = new WeakMap<HTMLElement, SidePanels>();
|
||||
|
||||
// 글판 접기 기억(345-5 덧 3) — 페이지 동안만(모듈 값) · 다시 열어도 그대로.
|
||||
let hintOpen = false;
|
||||
let infoCollapsed = false;
|
||||
@@ -155,7 +152,7 @@ export function createRouteEditChrome(
|
||||
overlay.querySelector('[data-slot="tools"]')!.replaceWith(...tools);
|
||||
|
||||
// 횡단 미리보기 = 패널 양식(382-1 · 손잡이 · 머리 · 폭 끌기 · 열림 · 폭 기억) · 자리는 지도 위 덮개 그대로 ·
|
||||
// 기본 접힘(393-1 · 키 -v2 = 옛 열림 기억 한 번 버림) · 손잡이로 연 것만 기억 · 잰 값 창이 판을 비킴(--b05-side-open).
|
||||
// 기본 접힘(393-1 · 키 -v2 = 옛 열림 기억 한 번 버림) · 열림 · 닫힘은 손잡이만(513-2) · 잰 값 창이 판을 비킴(--b05-side-open).
|
||||
const side = document.createElement("div");
|
||||
side.className = "b05-routeedit__side";
|
||||
const setSideOpen = (): void =>
|
||||
@@ -172,7 +169,6 @@ export function createRouteEditChrome(
|
||||
onChange: setSideOpen,
|
||||
onResize: setSideOpen,
|
||||
});
|
||||
sidePanelsOf.set(side, panels);
|
||||
overlay.querySelector(".b05-routeedit__side-wrap")!.append(panels.root);
|
||||
setSideOpen();
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
prepareMetricPolylines,
|
||||
} from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { QUIET_MS } from "../A00_Common/design/design_calc_server";
|
||||
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
|
||||
import { CONTOUR_BAND_M } from "./B05_Profile_UI_RouteEdit_Render";
|
||||
|
||||
@@ -47,6 +48,13 @@ export interface RouteEditContours {
|
||||
minor: (() => PreparedLayer) | null;
|
||||
}
|
||||
|
||||
/** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */
|
||||
export function contourHintText(contours: RouteEditContours | null, picked: number): string {
|
||||
if (picked < 0) return "등고선을 누르면 그 줄의 높이가 보입니다.";
|
||||
const level = contours?.layer.features[picked]?.labelValue ?? null;
|
||||
return level === null ? "등고선 한 줄을 골랐습니다." : `고른 등고선 ${level}m.`;
|
||||
}
|
||||
|
||||
interface ContourResponse {
|
||||
contours: Array<{ level: number; coordinates: Array<[number, number, number]> }>;
|
||||
}
|
||||
@@ -128,7 +136,21 @@ export function sheetCoversBand(
|
||||
);
|
||||
}
|
||||
|
||||
type SheetLayers = {
|
||||
/** 범위 눈금(m) — 서버 `SNAP_M` 과 같음 · 노선을 조금 옮겨도 같은 주소 · 같은 저장본. */
|
||||
const SHEET_SNAP_M = 50;
|
||||
|
||||
/** 편집 중 노선 bbox(`x0,y0,x1,y1` m · 눈금 바깥 맞춤) — 서버가 띠 + 여유를 더해 자름(508-2). */
|
||||
export function routeBboxParam(line: ReadonlyArray<readonly [number, number]>): string {
|
||||
const xs = line.map((point) => point[0]);
|
||||
const ys = line.map((point) => point[1]);
|
||||
const down = (value: number): number => Math.floor(value / SHEET_SNAP_M) * SHEET_SNAP_M;
|
||||
const up = (value: number): number => Math.ceil(value / SHEET_SNAP_M) * SHEET_SNAP_M;
|
||||
return [down(Math.min(...xs)), down(Math.min(...ys)), up(Math.max(...xs)), up(Math.max(...ys))]
|
||||
.map(String)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export type SheetLayers = {
|
||||
meta: VWorldMeta;
|
||||
layers: ReadonlyArray<readonly [string, GeoJsonCollection | null]>;
|
||||
/** 지금 노선(띠를 잴 선 · 잴 때 읽음) — 없으면 잘린 그대로 씀. */
|
||||
@@ -143,36 +165,84 @@ export async function fetchRouteSheets(
|
||||
return { ...(await fetchDrainageLayers(projectId, () => {}, "route")), line };
|
||||
}
|
||||
|
||||
/** 지금 노선 띠를 덮는 도엽 — 덮으면 `clipped` 그대로 · 밖이면 지금 노선 범위로 잘라 다시 받음(508-2 ③). */
|
||||
export async function coveringSheets(
|
||||
projectId: string,
|
||||
clipped: SheetLayers,
|
||||
): Promise<SheetLayers> {
|
||||
const line = clipped.line?.() ?? [];
|
||||
const covered = clipped.layers.every(([, collection]) =>
|
||||
sheetCoversBand(collection, clipped.meta, line, CONTOUR_BAND_M),
|
||||
);
|
||||
if (covered) return clipped;
|
||||
const bbox = routeBboxParam(line);
|
||||
return { ...(await fetchDrainageLayers(projectId, () => {}, "route", bbox)), line: clipped.line };
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 창 바탕 한 벌 — 등고선(LAS 우선)과, 등고선 말고 함께 깔 도엽 레이어(하천중심선).
|
||||
* 도엽 컬렉션은 노선 띠로 잘라 받은 것(`fetchDrainageLayers(…, "route")` · 509-1) — 지금 노선의
|
||||
* 띠가 그 범위를 벗어나면(저장 노선에서 여유 넘게 옮긴 초안) 통째 사본을 받아 쓴다.
|
||||
* 띠가 그 범위를 벗어나면(저장 노선에서 여유 넘게 옮긴 초안) 지금 노선 범위로 다시 받는다(508-2).
|
||||
* `reuse` = 앞 등고선 — LAS 것이면 그대로(도엽 범위와 상관없음 · 다시 안 읽음).
|
||||
*/
|
||||
export async function loadRouteEditBackground(
|
||||
projectId: string,
|
||||
clipped: SheetLayers,
|
||||
options: { surfaceModelId: number | null; intervalM: number; smooth: boolean },
|
||||
): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours }> {
|
||||
// ponytail: 여는 때 · 지형 바꿀 때만 잼 — 편집 도중 여유 밖으로 끌면 다음 열 때 통째로 받음.
|
||||
const drainage = clipped.layers.every(([, collection]) =>
|
||||
sheetCoversBand(collection, clipped.meta, clipped.line?.() ?? [], CONTOUR_BAND_M),
|
||||
)
|
||||
? clipped
|
||||
: await fetchDrainageLayers(projectId, () => {}, null);
|
||||
reuse: RouteEditContours | null = null,
|
||||
): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours; sheets: SheetLayers }> {
|
||||
const drainage = await coveringSheets(projectId, clipped);
|
||||
const normalizer = createNormalizer(drainage.meta);
|
||||
const otherSheets = drainage.layers
|
||||
.filter(([layer]) => layer !== "도엽_등고선")
|
||||
.map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null))
|
||||
.filter((layer): layer is PreparedLayer => layer !== null);
|
||||
const sheet = drainage.layers.find(([layer]) => layer === "도엽_등고선")?.[1] ?? null;
|
||||
const contours = await loadRouteEditContours(
|
||||
projectId,
|
||||
drainage.meta,
|
||||
normalizer,
|
||||
sheet,
|
||||
options,
|
||||
);
|
||||
return { otherSheets, contours };
|
||||
const contours =
|
||||
reuse?.source === "las"
|
||||
? reuse
|
||||
: await loadRouteEditContours(projectId, drainage.meta, normalizer, sheet, options);
|
||||
return { otherSheets, contours, sheets: drainage };
|
||||
}
|
||||
|
||||
/**
|
||||
* 놓은 뒤(잠잠 `QUIET_MS`) 편집 노선 띠가 받은 도엽 범위 밖이면 지금 노선 범위로 다시 받아
|
||||
* 바꿔 끼움(508-2 ③) — 바꿔 끼우는 곳 = `swap` 한 자리. 받는 중 새 걸음은 끝난 뒤 다시 잼.
|
||||
*/
|
||||
export function createSheetFollow(
|
||||
projectId: string,
|
||||
current: () => SheetLayers | null,
|
||||
swap: (next: SheetLayers) => Promise<void>,
|
||||
): { poke: () => void; dispose: () => void } {
|
||||
let timer: number | undefined;
|
||||
let running = false;
|
||||
let again = false;
|
||||
let disposed = false;
|
||||
const check = async (): Promise<void> => {
|
||||
const sheets = current();
|
||||
if (!sheets || disposed) return;
|
||||
if (running) return void (again = true);
|
||||
running = true;
|
||||
try {
|
||||
const next = await coveringSheets(projectId, sheets);
|
||||
if (next !== sheets && !disposed) await swap(next);
|
||||
} catch {
|
||||
/* 못 받으면 지금 바탕 그대로 — 다음 놓기에 다시 잼. */
|
||||
} finally {
|
||||
running = false;
|
||||
if (again && !disposed) ((again = false), void check());
|
||||
}
|
||||
};
|
||||
return {
|
||||
poke: () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void check(), QUIET_MS);
|
||||
},
|
||||
dispose: () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 1m 등고선 켬끔 단추를 배선한다 — 기본 꺼짐. 지금 켜져 있나를 돌려주는 함수를 낸다. */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user