Merge remote-tracking branch 'origin/dev' into sub_laptop_6
This commit is contained in:
@@ -89,7 +89,7 @@ export const STATE_REGISTRY = {
|
||||
"masshaul-visible": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
version: 5,
|
||||
version: 6,
|
||||
legacy: () => "b06:masshaul-visible-v4",
|
||||
},
|
||||
"table-open": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:open" },
|
||||
|
||||
@@ -32,6 +32,7 @@ import { saveSharedDesign, type DesignPageShared } from "./design_shared";
|
||||
import { guardDesignLeave } from "./design_leave";
|
||||
import { usePipeIds } from "./design_pipe_ids";
|
||||
import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center";
|
||||
import { reconcilePagePipeline } from "./design_pipeline_page";
|
||||
import "./design_page.css";
|
||||
|
||||
export type { DesignPageShared } from "./design_shared";
|
||||
@@ -244,6 +245,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
|
||||
if (slot) {
|
||||
slot.show();
|
||||
applyView(view);
|
||||
void reconcilePagePipeline(projectId); // 들어올 때 입력(세션 · 가게) 대 묶음 맞춤(365-2)
|
||||
} else mountView(view, true);
|
||||
// 검증용 — 보기 전환에 든 시간(ms)과 횟수를 화면 밖에서 수치로 본다.
|
||||
const probe = window as unknown as { __designPage?: { view: string; switchMs: number[] } };
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
* 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 ·
|
||||
* 암 경계 · 암 경사 · 소단) + 얹힌 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구
|
||||
* 방향은 새 지형이 정함(`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`) · 노견 확장 · 소단
|
||||
* 깊이는 안 넘김. 값 = 상세에 얹힌 설계(세션 고침은 종단도 · 횡단 보기가 다시 셈하며 이미 얹음).
|
||||
* 깊이는 안 넘김. 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 · 지반 유형 · 측구 · 2단 경사 · 암 경계 ·
|
||||
* 암 경사) — 노선 초안 동안 B06 고침은 저장 상세에 안 얹혀도 입력에 들어감(365-2). 부를 때마다 모음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState } from "../b_page_state";
|
||||
import type { CrossSection, SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import {
|
||||
effectiveStandardCross,
|
||||
@@ -66,16 +68,50 @@ function carried(section: CrossSection): Record<string, unknown> {
|
||||
: null,
|
||||
paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement),
|
||||
tilted: Boolean(section.ford_pavement || section.underdrain),
|
||||
tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null,
|
||||
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
|
||||
surfaceDropM: drop > 0 ? drop : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 저장 상세 + 저장 노선 측점(열쇠) → cross 입력 · 시설 제원 — 표준단면 · 상세가 없으면 null. */
|
||||
/** 세션 칸 하나를 측점 열쇠마다 — 옛 열쇠(누가거리 · `at:`)는 읽는 쪽이 옮긴 뒤에 들어옴(빼고 봄). */
|
||||
function sessionCells(
|
||||
name: "crossdesign" | "rockb" | "cutslope",
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): Array<[string, unknown]> {
|
||||
const raw = readState<Record<string, unknown>>(name, projectId, routeId);
|
||||
if (!raw || typeof raw !== "object") return [];
|
||||
return Object.entries(raw).filter(([key]) => key.includes(":") && !key.startsWith("at:"));
|
||||
}
|
||||
|
||||
/** 세션 횡단 고침 → 측점 열쇠마다 넘기는 입력 조각(`carried` 와 같은 칸 이름). */
|
||||
function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"] {
|
||||
const out: CrossInput["byKey"] = {};
|
||||
for (const [key, value] of sessionCells("crossdesign", projectId, routeId)) {
|
||||
const choice = (value ?? {}) as Record<string, unknown>;
|
||||
const entry = (out[key] ??= {});
|
||||
if (typeof choice.ground_type === "string") entry.groundType = choice.ground_type;
|
||||
if (typeof choice.ditch_type === "string") entry.ditchType = choice.ditch_type;
|
||||
if ("ditch_choice" in choice)
|
||||
entry.ditchChoice = typeof choice.ditch_choice === "boolean" ? choice.ditch_choice : null;
|
||||
if ("two_stage_slope" in choice) entry.twoStageSlope = Boolean(choice.two_stage_slope);
|
||||
}
|
||||
for (const [key, value] of sessionCells("rockb", projectId, routeId))
|
||||
if (finite(value) !== null) (out[key] ??= {}).rockBoundaryOffsetM = value;
|
||||
for (const [key, value] of sessionCells("cutslope", projectId, routeId)) {
|
||||
const ratio = finite(value);
|
||||
if (ratio !== null) (out[key] ??= {}).cutSlopeRatio = ratio > 0 ? ratio : null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 저장 상세 + 저장 노선 측점(열쇠) + 세션 고침(`routeId` 칸) → cross 입력 · 시설 제원 — 표준단면 ·
|
||||
* 상세가 없으면 null. */
|
||||
export function pipelineCrossSource(
|
||||
projectId: string,
|
||||
detail: SectionDetailResponse | null,
|
||||
savedStations: ReadonlyArray<PipelineStation>,
|
||||
routeId: number | null = null,
|
||||
): PipelineCrossSource | null {
|
||||
const standard = detail && effectiveStandardCross(projectId);
|
||||
if (!detail || !standard) return null;
|
||||
@@ -94,6 +130,9 @@ export function pipelineCrossSource(
|
||||
for (const name of SPEC_KEYS) if (section[name] !== undefined) own[name] = section[name];
|
||||
specs.set(best.key, own as SectionSpecs);
|
||||
}
|
||||
if (routeId !== null)
|
||||
for (const [key, entry] of Object.entries(sessionCarried(projectId, routeId)))
|
||||
byKey[key] = { ...byKey[key], ...entry };
|
||||
return {
|
||||
// 암 경계 기본값 = 서버 config(화면이 문맥에서 기억) — 입력 없는 측점(초안 새 측점)도 서버처럼(349-0p)
|
||||
input: { standard, byKey, rockBoundaryDefaultM: readRockBoundaryDefault(projectId) },
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
*
|
||||
* 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집
|
||||
* (`pipelineGrade` · 노선 초안 동안은 alignment-draft 노선 `DRAFT_ROUTE_ID`) · 횡단 = 저장 상세의 측점
|
||||
* 입력(`design_pipeline_cross.ts`) · 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분.
|
||||
* 입력 + 세션 고침(`design_pipeline_cross.ts` · 부를 때마다 모음 · 횡단 고침 뒤 `pipelineCross` · 365-2) ·
|
||||
* 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분.
|
||||
* 고리(365-2) — 열 때 · 페이지 들어올 때(`reconcilePagePipeline`) 입력(세션 · 가게)을 묶음이 쥔 입력과 견줌 →
|
||||
* 다르면 한 바퀴 → 끝난 뒤 다시 견줌 · 최대 `RECONCILE_ROUNDS` · 넘으면 `onUnstable`(「계산 불안정」 한 줄).
|
||||
* 출력 — 가게 stations · profile · footprint · generation · 노선 초안이 있으면 종단도가 새 첫 계획선 ·
|
||||
* 옮긴 계획고 · 새 노선 상세(`pipelineDetail` — 지반 · 측점 · 횡단 면적 → 유토곡선)를 그림.
|
||||
* 첫 계산 = 열 때 한 번(저장 노선 · 발자국 · 저장 안 한 노선 초안이 있으면 그 노선).
|
||||
@@ -22,15 +25,19 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createDesignPipeline, publishToStore, type DesignPipeline } from "./logic/design_pipeline";
|
||||
import { SOURCE_INPUT } from "./logic/design_pipeline_types";
|
||||
export { pipelineWork } from "./logic/design_pipeline";
|
||||
import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross";
|
||||
import { createStationsStage } from "./logic/design_pipeline_stations";
|
||||
import { createStationsStage, stationDiff } from "./logic/design_pipeline_stations";
|
||||
import type {
|
||||
ChangeSource,
|
||||
CrossOutput,
|
||||
FollowPatch,
|
||||
PipelineInputs,
|
||||
PipelineOutputs,
|
||||
PipelineRunRecord,
|
||||
PipelineStation,
|
||||
RangePart,
|
||||
RouteDraft,
|
||||
StageFn,
|
||||
Vertex,
|
||||
@@ -102,6 +109,8 @@ import type {
|
||||
|
||||
/** 노선 초안 동안의 계획고 초안 칸 — alignment-draft 의 노선 0(노선 번호는 [저장] 때 남). */
|
||||
export const DRAFT_ROUTE_ID = 0;
|
||||
/** 입력 ↔ 묶음 입력 맞춤 상한(바퀴) — 넘으면 「계산 불안정」(365-2). */
|
||||
export const RECONCILE_ROUNDS = 3;
|
||||
|
||||
/** 종단도(`design_profile`)가 묶음에 내주는 것. */
|
||||
export interface ProfileLink {
|
||||
@@ -137,6 +146,15 @@ export interface PagePipelineOptions {
|
||||
routeId: number,
|
||||
saved: SectionDetailResponse | null,
|
||||
) => (stations: PipelineStation[]) => Promise<string>;
|
||||
/** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */
|
||||
onUnstable?: () => void;
|
||||
/** 저장 노선 cross = 다시 셈 창구 한 벌(365-4) — 상세 설계를 고치고 그 값을 냄 · 측점이 상세와 다르면 null
|
||||
* (묶음 엔진). 주면 종단도가 계획고 편집 뒤 따로 다시 셈 안 함(`pipelineOwnsCross`). */
|
||||
savedCross?: (
|
||||
detail: SectionDetailResponse,
|
||||
routeId: number,
|
||||
stations: ReadonlyArray<PipelineStation>,
|
||||
) => Promise<CrossOutput | null>;
|
||||
}
|
||||
|
||||
interface PagePipeline {
|
||||
@@ -145,6 +163,9 @@ interface PagePipeline {
|
||||
grade(edits: AlignmentEdits): void;
|
||||
restart(): void;
|
||||
earthwork(context: MassHaulStageContext | null): void;
|
||||
cross(): void;
|
||||
reconcile(): Promise<number>;
|
||||
ownsCross: boolean;
|
||||
detail(): SectionDetailResponse | null;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -155,22 +176,6 @@ type Facilities = Pick<DesignStoreValues, "pipes" | "structures">;
|
||||
const pages = new Map<string, PagePipeline>();
|
||||
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
/** 상세 측점 대 묶음 측점(1 mm) — 같으면 null · 다르면 첫 다른 자리 · 개수(묶음 기록 `restoreDiff`). */
|
||||
function stationDiff(
|
||||
detail: SectionDetailResponse,
|
||||
stations: ReadonlyArray<PipelineStation>,
|
||||
): NonNullable<PipelineRunRecord["restoreDiff"]> | null {
|
||||
const saved = detail.cross_sections.map((section) => section.chainage_m);
|
||||
const n = Math.max(saved.length, stations.length);
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const pipelineM = stations[i]?.chainageM ?? null;
|
||||
if (saved[i] !== undefined && pipelineM !== null && Math.abs(saved[i] - pipelineM) < 1e-3)
|
||||
continue;
|
||||
return { savedM: saved[i] ?? null, pipelineM, saved: saved.length, pipeline: stations.length };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 편집기에 넘길 다리 — 그 프로젝트 묶음을 처음 부를 때 세움. */
|
||||
export function routeDraftBridge(
|
||||
projectId: string,
|
||||
@@ -193,6 +198,17 @@ export const restartPagePipeline = (projectId: string): void => pages.get(projec
|
||||
export const pipelineEarthwork = (projectId: string, context: MassHaulStageContext | null): void =>
|
||||
pages.get(projectId)?.earthwork(context);
|
||||
|
||||
/** 횡단 고침(지반 유형 · 측구 · 암 · 소단 · 표준 — B06 · 365-2) — 세션 횡단 입력을 다시 모아 cross 부터. */
|
||||
export const pipelineCross = (projectId: string): void => pages.get(projectId)?.cross();
|
||||
|
||||
/** 저장 노선 횡단 다시 셈을 묶음 cross 가 맡는가(365-4) — 종단도는 계획고 편집 뒤 따로 안 셈. */
|
||||
export const pipelineOwnsCross = (projectId: string): boolean =>
|
||||
pages.get(projectId)?.ownsCross ?? false;
|
||||
|
||||
/** 입력(세션 · 가게) 대 묶음 입력을 견줘 맞춤(365-2) — 돈 바퀴 수(0 = 같음 · -1 = 넘음 · 묶음 없음 = 0). */
|
||||
export const reconcilePagePipeline = (projectId: string): Promise<number> =>
|
||||
pages.get(projectId)?.reconcile() ?? Promise.resolve(0);
|
||||
|
||||
/** 노선 초안 동안 종단도가 그릴 새 노선 상세 — 초안이 없으면 null(저장 상세). */
|
||||
export const pipelineDetail = (projectId: string): SectionDetailResponse | null =>
|
||||
pages.get(projectId)?.detail() ?? null;
|
||||
@@ -219,10 +235,9 @@ function createPagePipeline(
|
||||
let savedStructures: PlanStructure[] = [];
|
||||
let savedPipes: PlanPipe[] = [];
|
||||
let earthwork: MassHaulStageContext | null = null;
|
||||
/** 저장 노선 측점(열쇠) · 그것으로 모은 횡단 입력 — 저장 상세가 바뀌면 다시. */
|
||||
/** 저장 노선 측점(열쇠) · 마지막으로 모은 횡단 입력(시설 제원 — 새 노선 상세가 씀). */
|
||||
let savedStations: PipelineStation[] = [];
|
||||
let crossSource: { detail: SectionDetailResponse; source: PipelineCrossSource | null } | null =
|
||||
null;
|
||||
let crossSource: PipelineCrossSource | null = null;
|
||||
/** 저장 정본(서버가 준 상세 · 노선 번호) — 측점 갈아끼움(swap)이 만든 새 상세는 지반 표본 배열을
|
||||
* 그대로 둠 → 정본 아님(묶음 출력 → 상세 → 다시 묶음 고리를 끊음). */
|
||||
let canon: { routeId: unknown; samples: unknown; detail: SectionDetailResponse | null } | null =
|
||||
@@ -286,12 +301,17 @@ function createPagePipeline(
|
||||
emptyEdits()
|
||||
);
|
||||
};
|
||||
/** 횡단 입력 — 부를 때마다 저장 상세 + 세션 고침 · 표준을 모음(상세 객체마다 기억하면 고침이 안 감 · 365-2). */
|
||||
const crossNow = (): PipelineCrossSource | null => {
|
||||
const detail = canon?.detail ?? null;
|
||||
if (!detail) return null;
|
||||
if (crossSource?.detail !== detail || !crossSource.source)
|
||||
crossSource = { detail, source: pipelineCrossSource(projectId, detail, savedStations) };
|
||||
return crossSource.source;
|
||||
const routeId = canon?.routeId;
|
||||
const source = pipelineCrossSource(
|
||||
projectId,
|
||||
canon?.detail ?? null,
|
||||
savedStations,
|
||||
typeof routeId === "number" ? routeId : null,
|
||||
);
|
||||
if (source) crossSource = source;
|
||||
return source;
|
||||
};
|
||||
const groundDeps = groundStageDeps(projectId);
|
||||
const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j)
|
||||
@@ -340,18 +360,27 @@ function createPagePipeline(
|
||||
ground: createGroundStage(groundDeps),
|
||||
planline: createPlanlineStage({ savedBase: () => savedSource().base, savedLine }),
|
||||
profile: profileStage,
|
||||
// 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름
|
||||
cross: (ctx) =>
|
||||
crossStage.stage({
|
||||
...ctx,
|
||||
inputs: {
|
||||
...ctx.inputs,
|
||||
cross: crossWithUphill(
|
||||
ctx.inputs.cross as CrossInput | null,
|
||||
ctx.out.ground?.stations ?? [],
|
||||
),
|
||||
},
|
||||
}),
|
||||
// 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름 · 저장 노선 = 다시 셈
|
||||
// 창구 한 벌(카드 · 유토곡선이 보는 상세 설계 · 365-4)
|
||||
cross: (ctx) => {
|
||||
const engine = () =>
|
||||
crossStage.stage({
|
||||
...ctx,
|
||||
inputs: {
|
||||
...ctx.inputs,
|
||||
cross: crossWithUphill(
|
||||
ctx.inputs.cross as CrossInput | null,
|
||||
ctx.out.ground?.stations ?? [],
|
||||
),
|
||||
},
|
||||
});
|
||||
const { detail = null, routeId } = savedSource();
|
||||
const saved =
|
||||
!ctx.inputs.route && detail && typeof routeId === "number"
|
||||
? options.savedCross?.(detail, routeId, ctx.out.ground?.stations ?? [])
|
||||
: null;
|
||||
return saved ? saved.then((out) => out ?? engine()) : engine();
|
||||
},
|
||||
footprint: footprintStage,
|
||||
masshaul: createMassHaulStage(() => earthwork),
|
||||
haul: (ctx) => (ctx.inputs.route ? haulStage(ctx) : (ctx.prev ?? null)),
|
||||
@@ -460,7 +489,7 @@ function createPagePipeline(
|
||||
ground,
|
||||
profile,
|
||||
pipe.output("cross"),
|
||||
crossSource?.source?.specs ?? new Map(),
|
||||
crossSource?.specs ?? new Map(),
|
||||
options.stationIntervalM ?? profile.policy.station_interval_m,
|
||||
)
|
||||
: null;
|
||||
@@ -513,12 +542,52 @@ function createPagePipeline(
|
||||
if (disposed || !ready || !savedSource().base) return;
|
||||
canonChanged();
|
||||
started = true;
|
||||
held = null;
|
||||
const inputs = inputsNow();
|
||||
// 초안 둔 채 열기(새로고침 · B06 바로 열기 · 365-1) — 초안 전 = 지금 저장 상세(세션 그대로) · 측점은 가게에 냄
|
||||
held = inputs.route ? (held ?? holdNow()) : null;
|
||||
dropping = null;
|
||||
void pipe.start(inputsNow());
|
||||
openGeneration = quietGeneration = pipe.generation;
|
||||
Object.assign(given, inputs);
|
||||
const run = (lastRun = pipe.start(inputs));
|
||||
openGeneration = pipe.generation;
|
||||
quietGeneration = inputs.route ? -1 : pipe.generation;
|
||||
void run.then(() => reconcile()); // 열 때 셈하는 사이 바뀐 입력(세션 · 가게)도 맞춤
|
||||
};
|
||||
|
||||
/** 묶음에 넘긴 입력(follow 가 고치기 전) · 마지막으로 띄운 차례 — 고리가 견줌 · 기다림. */
|
||||
const given: Partial<PipelineInputs> = {};
|
||||
let lastRun: Promise<unknown> = Promise.resolve();
|
||||
const feed = (source: ChangeSource, value: unknown, part: RangePart | null = null) => {
|
||||
given[SOURCE_INPUT[source]] = value as never;
|
||||
const run = pipe.update(source, value, part);
|
||||
if (run) lastRun = run;
|
||||
return run;
|
||||
};
|
||||
let reconciling: Promise<number> | null = null;
|
||||
/** 입력 지문 대 묶음 입력 — 넘긴 값 · follow 뒤 값 둘 다와 다르면 한 바퀴(노선 = 다리 길) · 끝난 뒤 다시 견줌. */
|
||||
function reconcile(): Promise<number> {
|
||||
if (!started || disposed) return Promise.resolve(0);
|
||||
return (reconciling ??= (async () => {
|
||||
for (let round = 0; ; round += 1) {
|
||||
let seen: Promise<unknown> | null = null;
|
||||
while (seen !== lastRun) await (seen = lastRun); // 기다리는 사이 뜬 차례까지
|
||||
await pipe.settled();
|
||||
if (disposed || canon?.routeId !== savedSource().routeId) return round; // 정본 바뀜 = restart 몫
|
||||
const now = inputsNow();
|
||||
const stale = (Object.entries(SOURCE_INPUT) as Array<[ChangeSource, keyof PipelineInputs]>)
|
||||
.filter(([, key]) => !same(now[key], pipe.input(key)) && !same(now[key], given[key]))
|
||||
.map(([source, key]): [ChangeSource, unknown] => [source, now[key]]);
|
||||
if (!stale.length) return round;
|
||||
if (round >= RECONCILE_ROUNDS) {
|
||||
options.onUnstable?.();
|
||||
return -1;
|
||||
}
|
||||
for (const [source, value] of stale)
|
||||
if (source === "route") bridge.edited(value as RouteDraft | null);
|
||||
else feed(source, value);
|
||||
}
|
||||
})().finally(() => (reconciling = null)));
|
||||
}
|
||||
|
||||
/** 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — [저장] 뒤 다시 받음(`restart`). */
|
||||
const loadPlan = (): Promise<void> =>
|
||||
fetchRoutePlan(projectId)
|
||||
@@ -552,13 +621,10 @@ function createPagePipeline(
|
||||
.catch(() => undefined);
|
||||
|
||||
const unsubscribe = [
|
||||
store.subscribe(
|
||||
"pipes",
|
||||
(pipes) => started && !echo && void pipe.update("pipe", pipes ?? savedPipes),
|
||||
),
|
||||
store.subscribe("pipes", (pipes) => started && !echo && void feed("pipe", pipes ?? savedPipes)),
|
||||
store.subscribe(
|
||||
"structures",
|
||||
(list) => started && !echo && void pipe.update("structure", list ?? savedStructures),
|
||||
(list) => started && !echo && void feed("structure", list ?? savedStructures),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -579,7 +645,7 @@ function createPagePipeline(
|
||||
if (!started) return start();
|
||||
const before = pipe.output("route")?.line ?? null;
|
||||
const after = draft ? draftLine(draft, plan?.min_radius_m ?? 0).line : savedLine();
|
||||
const run = pipe.update("route", draft, routeRange(before, after));
|
||||
const run = feed("route", draft, routeRange(before, after));
|
||||
if (run && !draft && wasDraft) {
|
||||
quietGeneration = pipe.generation;
|
||||
dropping = run.then(finishDrop);
|
||||
@@ -595,7 +661,7 @@ function createPagePipeline(
|
||||
if (same(edits, pipe.input("alignment"))) return;
|
||||
const nodes = pipe.output("planline")?.basePvi.map((node) => node.chainage_m) ?? [];
|
||||
const before = pipe.input("alignment") as AlignmentEdits | null;
|
||||
void pipe.update("grade", edits, gradeRange(before, edits, nodes));
|
||||
void feed("grade", edits, gradeRange(before, edits, nodes));
|
||||
},
|
||||
restart() {
|
||||
if (!started) return start();
|
||||
@@ -604,8 +670,14 @@ function createPagePipeline(
|
||||
earthwork(context) {
|
||||
earthwork = context;
|
||||
const cross = crossNow()?.input ?? null;
|
||||
if (started && cross) void pipe.update("cross", cross);
|
||||
if (started && cross) void feed("cross", cross);
|
||||
},
|
||||
cross() {
|
||||
const cross = started ? (crossNow()?.input ?? null) : null;
|
||||
if (cross) void feed("cross", cross);
|
||||
},
|
||||
reconcile,
|
||||
ownsCross: Boolean(options.savedCross),
|
||||
detail: () => shown,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
|
||||
@@ -324,9 +324,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
refresh: () => renderBalance(),
|
||||
});
|
||||
|
||||
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */
|
||||
function applyEdits(next: AlignmentEdits): void {
|
||||
if (!base || !alignment) return;
|
||||
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. false = 막힘. */
|
||||
function applyEdits(next: AlignmentEdits): boolean {
|
||||
if (!base || !alignment) return false;
|
||||
const candidate = buildAlignment(base, next);
|
||||
if (
|
||||
base.policy.grade_violation_policy === "block" &&
|
||||
@@ -336,12 +336,13 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
`종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
// 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정).
|
||||
if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return;
|
||||
if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return false;
|
||||
store.replace(next);
|
||||
history.record();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 계획선 측점 줄 = 측점 한 벌 — 셈은 `design_profile_base` 몫(계산 묶음 planline 도 같은 기준). */
|
||||
@@ -353,8 +354,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
pagePipeline.pipelineGrade(projectId, store.edits());
|
||||
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유
|
||||
// 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다
|
||||
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
|
||||
scheduleCrossPreview();
|
||||
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음). 묶음이 맡으면 그 cross 한 벌(365-4).
|
||||
if (!pagePipeline.pipelineOwnsCross(projectId)) scheduleCrossPreview();
|
||||
drawSoon();
|
||||
}
|
||||
/** 다음 틀에 한 번 그림 — 같은 차례 알림 · 갈아끼움 · 설정이 여러 번 불러도 한 번(349-0s). */
|
||||
|
||||
@@ -59,8 +59,6 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) {
|
||||
const column = document.createElement("div");
|
||||
column.className = "b05-route-profile__column";
|
||||
column.append(bodyWrap, massHaul.overlay);
|
||||
// 판의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
|
||||
massHaul.attachScrollSync(body);
|
||||
// 테이블은 종단 칸 바닥 겹침 판(2026-08-05 사용자 지시).
|
||||
const tableOverlay = createProfileTableOverlay(onChange);
|
||||
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
|
||||
|
||||
@@ -29,6 +29,19 @@ import { saveDesign, type DesignSaveStep } from "./design_save";
|
||||
import { markDesignClean } from "./design_leave";
|
||||
import { resetDesign } from "./design_reset";
|
||||
import type { DesignView } from "./design_dock";
|
||||
import { routeDraftBridge, type PagePipelineOptions } from "./design_pipeline_page";
|
||||
import { SECTION_STATION_INTERVAL_M } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import {
|
||||
applyStructureAreaRows,
|
||||
structureAreaRows,
|
||||
} from "../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import {
|
||||
holdSectionDetail,
|
||||
restoreSectionDetail,
|
||||
swapSectionStations,
|
||||
} from "../../B06_Section/B06_Section_Section_Store";
|
||||
import { refreshCrossDesigns } from "../../B06_Section/B06_Section_Cross_Refresh";
|
||||
import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -62,6 +75,8 @@ export interface DesignViewBinding {
|
||||
onReset?: () => void;
|
||||
/** 다른 보기가 고친 관 옵션을 이 보기 캐시(횡단 스펙)에 얹는다. */
|
||||
onPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
|
||||
/** 계산 묶음 알림 — 묶음은 페이지가 세움(365-1) · 보기는 알림만 받음. */
|
||||
pipeline?: Pick<PagePipelineOptions, "onCorridorStale" | "onFacilitiesFollowed">;
|
||||
}
|
||||
|
||||
/** 한 벌 자리 — 페이지(`design_page`)가 만들어 두 보기에 같은 객체로 넘긴다. */
|
||||
@@ -182,9 +197,48 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile {
|
||||
attachProfilePopout(profile, shared.profileSlot, popoutButton);
|
||||
attachSubPanelPopouts(profile);
|
||||
shared.profile = profile;
|
||||
if (shared.projectId) startPagePipeline(shared, profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** 계산 묶음 한 벌(365-1) — 종단도와 함께 페이지가 세움 · B05 · B06 어느 쪽으로 들어와도(새로고침도) 노선
|
||||
* 초안을 셈. 편집기는 `routeDraftBridge` 로 이 다리를 받아 씀 · 보기 몫 알림은 `pipeline` 바인딩으로. */
|
||||
function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): void {
|
||||
const projectId = shared.projectId;
|
||||
const each = (fn: (hooks: NonNullable<DesignViewBinding["pipeline"]>) => void): void =>
|
||||
Object.values(shared.bindings).forEach((binding) => binding?.pipeline && fn(binding.pipeline));
|
||||
routeDraftBridge(projectId, profile, {
|
||||
stationIntervalM: SECTION_STATION_INTERVAL_M,
|
||||
onCorridorStale: () => each((hooks) => hooks.onCorridorStale?.()),
|
||||
onFacilitiesFollowed: (far) => each((hooks) => hooks.onFacilitiesFollowed?.(far)),
|
||||
onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"),
|
||||
// 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 고치고 그 값을
|
||||
// 묶음 출력으로(발자국 · 평면 횡단 판) · 측점이 상세와 다르면(갈아끼움 전) 묶음 엔진
|
||||
savedCross: async (detail, routeId, stations) => {
|
||||
if (!detailCross(detail, stations)) return null;
|
||||
const edits = profile.pipelineSource().edits;
|
||||
const updated = await refreshCrossDesigns({ projectId, routeId, detail, edits });
|
||||
if (updated.length) toAll(shared, "onCrossDesignsUpdated")(updated);
|
||||
profile.redraw();
|
||||
return detailCross(detail, stations);
|
||||
},
|
||||
structureAreas: ({ cross_sections: rows }) =>
|
||||
applyStructureAreaRows(rows, structureAreaRows(rows)),
|
||||
// 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움
|
||||
holdDraft: (routeId, saved) => {
|
||||
const hold = saved && holdSectionDetail(projectId, routeId, saved);
|
||||
return async (stations) => {
|
||||
if (!hold) {
|
||||
await swapSectionStations(projectId, routeId, stations);
|
||||
return "no-saved";
|
||||
}
|
||||
const back = await restoreSectionDetail(projectId, routeId, hold, stations);
|
||||
return !back ? "no-cache" : back.same ? "saved" : "saved-diff";
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 「구조물 배치」 폼 한 벌 — 알림은 지금 보기로, 상세 옵션은 횡단 보기에서만 그린다. */
|
||||
export function sharedStructures(shared: DesignPageShared): StructuresSection {
|
||||
if (shared.structures) return shared.structures;
|
||||
|
||||
@@ -225,11 +225,11 @@ export async function mountCrossView(
|
||||
const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4)
|
||||
if (savingDesign(projectId)) return; // 저장 도중 = 낡은 캐시 · 빈 초안 — 저장 뒤 한 번(323-9)
|
||||
try {
|
||||
await new Promise((next) => setTimeout(next, 0)); // 겹친 신호(갈아끼움 · 묶음 · 초안) = 한 번(365-4)
|
||||
const fresh = await loadSectionDetail(projectId, currentRouteId);
|
||||
if (seq !== structuresRefreshSeq) return;
|
||||
sectionDetail = fresh;
|
||||
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가
|
||||
// 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측).
|
||||
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 셈(카드만 고치면 곡선이 옛 면적 · 2026-09-06 실측).
|
||||
if (sectionDetail) {
|
||||
applyStructureAreaRows(
|
||||
sectionDetail.cross_sections,
|
||||
|
||||
@@ -48,16 +48,7 @@ import {
|
||||
rememberStandardDefaults,
|
||||
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
|
||||
import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import {
|
||||
applyStructureAreaRows,
|
||||
structureAreaRows,
|
||||
} from "../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import {
|
||||
holdSectionDetail,
|
||||
invalidateSectionDetail,
|
||||
restoreSectionDetail,
|
||||
swapSectionStations,
|
||||
} from "../../B06_Section/B06_Section_Section_Store";
|
||||
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
||||
import {
|
||||
onStaleStructureTypes,
|
||||
structureAnchorM,
|
||||
@@ -419,6 +410,11 @@ export async function mountProfileView(
|
||||
bindDesignView(shared, "profile", {
|
||||
save: profileSaveParts(actionContext),
|
||||
onReset: () => uphillOverrides.clear(),
|
||||
// 계산 묶음(페이지 몫 · 365-1) 알림 중 종단 보기 몫 — 3D 「낡음」 · 관 · 구조물 따라감
|
||||
pipeline: {
|
||||
onCorridorStale: () => sections.setCorridorPending(true),
|
||||
onFacilitiesFollowed: followFacilities,
|
||||
},
|
||||
});
|
||||
|
||||
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
|
||||
@@ -539,27 +535,8 @@ export async function mountProfileView(
|
||||
smooth: latestResponse.surface_params.smooth,
|
||||
tools: [view3d.modal.button],
|
||||
recommend: panel.recommend,
|
||||
// 계산 묶음(346-1) — 노선을 고칠 때마다 종단이 따라옴(미리보기 · [저장]이 정본).
|
||||
draft: routeDraftBridge(activeProjectId, profilePanel, {
|
||||
stationIntervalM: panel.values().stationInterval ?? undefined,
|
||||
onCorridorStale: () => sections.setCorridorPending(true),
|
||||
onFacilitiesFollowed: followFacilities,
|
||||
structureAreas: ({ cross_sections: rows }) =>
|
||||
applyStructureAreaRows(rows, structureAreaRows(rows)),
|
||||
// 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면(초안 둔 채
|
||||
// 새로고침) 묶음 측점으로 갈아끼움
|
||||
holdDraft: (routeId, saved) => {
|
||||
const hold = saved && holdSectionDetail(activeProjectId, routeId, saved);
|
||||
return async (stations) => {
|
||||
if (!hold) {
|
||||
await swapSectionStations(activeProjectId, routeId, stations);
|
||||
return "no-saved";
|
||||
}
|
||||
const back = await restoreSectionDetail(activeProjectId, routeId, hold, stations);
|
||||
return !back ? "no-cache" : back.same ? "saved" : "saved-diff";
|
||||
};
|
||||
},
|
||||
}),
|
||||
// 계산 묶음(346-1) — 페이지가 세운 다리(365-1) · 노선을 고칠 때마다 종단이 따라옴(미리보기 · [저장]이 정본).
|
||||
draft: routeDraftBridge(activeProjectId, profilePanel),
|
||||
});
|
||||
|
||||
// ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
|
||||
|
||||
@@ -14,7 +14,13 @@ import type {
|
||||
PlanStructure,
|
||||
PlanType,
|
||||
} from "../../../common_util/common_util_structure_station";
|
||||
import type { PipelineStation, StageContext, StageFn } from "./design_pipeline_types";
|
||||
import type {
|
||||
PipelineRunRecord,
|
||||
PipelineStation,
|
||||
StageContext,
|
||||
StageFn,
|
||||
} from "./design_pipeline_types";
|
||||
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
/** 범위 밖 측점은 앞 값(같은 열쇠 · 같은 자리)을 그대로 — 뒤 단계가 같은 객체로 「안 바뀜」을 앎. */
|
||||
export function reuseOutside(
|
||||
@@ -54,3 +60,19 @@ export function createStationsStage(
|
||||
return reuseOutside(next, ctx.prev, ctx.range);
|
||||
};
|
||||
}
|
||||
|
||||
/** 상세 측점 대 묶음 측점(1 mm) — 같으면 null · 다르면 첫 다른 자리 · 개수(묶음 기록 `restoreDiff`). */
|
||||
export function stationDiff(
|
||||
detail: SectionDetailResponse,
|
||||
stations: ReadonlyArray<PipelineStation>,
|
||||
): NonNullable<PipelineRunRecord["restoreDiff"]> | null {
|
||||
const saved = detail.cross_sections.map((section) => section.chainage_m);
|
||||
const n = Math.max(saved.length, stations.length);
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const pipelineM = stations[i]?.chainageM ?? null;
|
||||
if (saved[i] !== undefined && pipelineM !== null && Math.abs(saved[i] - pipelineM) < 1e-3)
|
||||
continue;
|
||||
return { savedM: saved[i] ?? null, pipelineM, saved: saved.length, pipeline: stations.length };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*
|
||||
* 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은
|
||||
* 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽
|
||||
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · Backspace = 마지막 점 빼기.
|
||||
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · ↶ ↷(Backspace = ↶) =
|
||||
* 자유지정 안 한 걸음씩(범위 · 시작 · 점 · 367-3) — 큰 이력과 따로 · 끝나면 큰 이력 한 걸음.
|
||||
*
|
||||
* 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고
|
||||
* [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L
|
||||
@@ -181,8 +182,8 @@ export interface FreeDrawDeps {
|
||||
base: () => AlignmentBase | null;
|
||||
alignment: () => ProfileAlignment | null;
|
||||
edits: () => AlignmentEdits;
|
||||
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). */
|
||||
apply: (next: AlignmentEdits) => void;
|
||||
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). false = 막힘(열린 채). */
|
||||
apply: (next: AlignmentEdits) => boolean | void;
|
||||
/** 모드를 끈다(적용 뒤 · Esc). */
|
||||
close: () => void;
|
||||
/** 단계 · 안내가 바뀜. */
|
||||
@@ -198,6 +199,11 @@ export interface FreeDraw {
|
||||
hover: (pointer: { chainageM: number; elevationM: number; stations: number[] } | null) => void;
|
||||
/** Enter · Escape · Backspace — 먹었으면 true. */
|
||||
key: (key: string) => boolean;
|
||||
/** 자유지정 안 한 걸음 되돌림 · 다시함(367-3) — 큰 이력과 따로. */
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
marks: () => FreeMarks;
|
||||
hint: () => string;
|
||||
}
|
||||
@@ -209,6 +215,25 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
let anchor: number | null = null;
|
||||
let points: FreeVertex[] = [];
|
||||
let target: FreeVertex | null = null;
|
||||
/** 자유지정 안 이력(367-3) — 상태 바뀐 클릭만 쌓음 · 새 클릭이 future 비움. */
|
||||
interface Snap {
|
||||
phase: FreePhase;
|
||||
pending: number | null;
|
||||
range: [number, number] | null;
|
||||
anchor: number | null;
|
||||
points: FreeVertex[];
|
||||
}
|
||||
let past: Snap[] = [];
|
||||
let future: Snap[] = [];
|
||||
const snap = (): Snap => ({ phase, pending, range, anchor, points: [...points] });
|
||||
function step(from: Snap[], to: Snap[]): void {
|
||||
const state = from.pop();
|
||||
if (!state) return;
|
||||
to.push(snap());
|
||||
({ phase, pending, range, anchor, points } = state);
|
||||
target = null;
|
||||
deps.changed();
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
phase = "range";
|
||||
@@ -217,6 +242,8 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
anchor = null;
|
||||
points = [];
|
||||
target = null;
|
||||
past = [];
|
||||
future = [];
|
||||
}
|
||||
|
||||
/** 그 측점의 지금 계획고(변화점이면 라운드 중심 z — 직선화와 같은 값). */
|
||||
@@ -263,8 +290,9 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
if (!base || end === null || z === null) return;
|
||||
const vertices = [...path(), { chainage_m: end, elevation_m: z }];
|
||||
const next = buildFreeEdits(base, deps.edits(), vertices);
|
||||
// 막히면(기울기 · 흙덮기) 자유지정 열린 채 — 그린 점 그대로(367-3)
|
||||
if (deps.apply(next) === false) return;
|
||||
reset();
|
||||
deps.apply(next);
|
||||
deps.close();
|
||||
}
|
||||
|
||||
@@ -272,6 +300,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
phase: () => phase,
|
||||
reset,
|
||||
click(chainageM, elevationM, stations) {
|
||||
const before = snap();
|
||||
if (phase === "range") {
|
||||
const station = snapStation(stations, chainageM);
|
||||
if (station === null) return true;
|
||||
@@ -297,6 +326,10 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
}
|
||||
points.push(next);
|
||||
}
|
||||
if (JSON.stringify(snap()) !== JSON.stringify(before)) {
|
||||
past.push(before);
|
||||
future = [];
|
||||
}
|
||||
target = null;
|
||||
deps.changed();
|
||||
return true;
|
||||
@@ -316,18 +349,16 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
return true;
|
||||
}
|
||||
if (key === "Backspace") {
|
||||
if (phase !== "drawing") return false;
|
||||
if (points.length) points.pop();
|
||||
else {
|
||||
anchor = null;
|
||||
phase = "armed";
|
||||
}
|
||||
target = null;
|
||||
deps.changed();
|
||||
if (!past.length) return false;
|
||||
step(past, future);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
undo: () => step(past, future),
|
||||
redo: () => step(future, past),
|
||||
canUndo: () => past.length > 0,
|
||||
canRedo: () => future.length > 0,
|
||||
marks() {
|
||||
const maxPct = deps.base()?.policy.max_grade_pct ?? Number.POSITIVE_INFINITY;
|
||||
const dots = path();
|
||||
@@ -357,7 +388,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`;
|
||||
}
|
||||
if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)";
|
||||
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (Backspace 점 빼기 · Esc 취소)";
|
||||
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (↶ · Backspace 되돌리기 · Esc 취소)";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -218,14 +218,14 @@ async def send_initial_analysis_complete_email(
|
||||
{design_rows}
|
||||
</div>
|
||||
<div class="notice">
|
||||
기본값으로 계산한 결과입니다. 종단설계 화면에서 검토·수정한 뒤 횡단설계에서
|
||||
기본값으로 계산한 결과입니다. 노선설계 화면에서 검토·수정한 뒤 횡단설계에서
|
||||
[확정]을 누르면 다음 단계(수량 산출)로 넘어갑니다.
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
design_block = """
|
||||
<div class="notice">
|
||||
초기 노선·종횡단 자동 계산은 완료되지 않았습니다. 종단설계 화면에서 직접
|
||||
초기 노선·종횡단 자동 계산은 완료되지 않았습니다. 노선설계 화면에서 직접
|
||||
경로를 계산해 주세요.
|
||||
</div>
|
||||
"""
|
||||
@@ -239,7 +239,7 @@ async def send_initial_analysis_complete_email(
|
||||
</div>
|
||||
{design_block}
|
||||
<p style="text-align: center; margin: 26px 0;">
|
||||
<a class="button" href="{html.escape(result_url)}">종단설계 화면 열기</a>
|
||||
<a class="button" href="{html.escape(result_url)}">노선설계 화면 열기</a>
|
||||
</p>
|
||||
<p style="color:#6b7280;font-size:13px;">
|
||||
로그인이 필요한 경우 인증 후 같은 화면으로 이동합니다.
|
||||
|
||||
@@ -105,7 +105,7 @@ async def _prepare_drainage_pipes_and_reprofile(
|
||||
|
||||
marks.append(("배수유역 분석", time.perf_counter()))
|
||||
|
||||
# 2) 기본 관(도로 × 상류 세류선) + 최대 간격 자동 보충을 정본으로 저장한다.
|
||||
# 2) 기본 관(도로 × 상류 세류선 교차)만 정본으로 저장한다 — 간격 보충은 [자동 제안](369).
|
||||
pipes = await put_pipe_points(project_id, None)
|
||||
if isinstance(pipes, JSONResponse):
|
||||
logger.error(
|
||||
|
||||
@@ -620,14 +620,16 @@ export async function fetchDetailPipePoints(projectId: string): Promise<DetailBa
|
||||
|
||||
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
|
||||
*
|
||||
* 빈 목록 = 관 0개(312) · 기본 관 + 자동 보충으로 되돌리기는 `resetDetailPipePoints`(DELETE)만. */
|
||||
* 빈 목록 = 관 0개(312) · null = 세류선 교차 관만(369) · `auto`([자동 제안]) = "all" 전체 자동
|
||||
* (같은 자리 관은 속성 유지) · "add" 지금 관 + 빈 구간 보충. */
|
||||
export async function computeDetailBasins(
|
||||
projectId: string,
|
||||
points: DetailPipeInput[],
|
||||
points: DetailPipeInput[] | null,
|
||||
auto?: "all" | "add",
|
||||
): Promise<DetailBasinResponse> {
|
||||
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/detail-basins`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ points }),
|
||||
body: JSON.stringify(auto ? { points, auto } : { points }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -342,7 +342,7 @@ def build_flow_arrows(analysis: Any, flow: Any) -> list[tuple[float, float, floa
|
||||
def _base_pipes(
|
||||
vertices: list[RouteVertex], stream_features: list[dict[str, Any]]
|
||||
) -> list[StructureCandidate]:
|
||||
"""도로 × 세류선 교차점을 기본 관 위치로 삼는다. 300m 보충 배치는 다음 단계다."""
|
||||
"""도로 × 세류선 교차점을 기본 관 위치로 삼는다. 간격 보충은 [자동 제안] 때만(369)."""
|
||||
pipes: list[StructureCandidate] = []
|
||||
for candidate in find_stream_crossings(vertices, stream_features):
|
||||
if pipes and candidate.chainage_m - pipes[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
격자 해석은 하지 않는다. 배수유역 분석이 저장해 둔 `03_road_routing` 산출물을 읽어
|
||||
|
||||
· 기본 관(도로 × 상류 세류선 교차점) + 최대 간격 자동 보충으로 관 목록을 만들고
|
||||
· 기본 관(도로 × 상류 세류선 교차점)으로 관 목록을 만들고([자동 제안] = 최대 간격 보충 · 369)
|
||||
· 계획노선 종단 Z로 노면 물이 어느 관으로 가는지 정해 세부유역을 나눈다
|
||||
|
||||
계산 알고리즘은 B05 사용자 화면과 공용이다(`common_util_drainage_detail`) — 같은 이름의
|
||||
@@ -67,6 +67,9 @@ 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 = (
|
||||
"배수유역 낡음 — 노선이 분석 때와 다릅니다. 유역을 다시 분석해야 세부 유역을 나눕니다."
|
||||
)
|
||||
@@ -78,20 +81,22 @@ 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 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다.
|
||||
있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의
|
||||
표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다.
|
||||
`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)
|
||||
detail = build_detail(directory, context.vertices, chainages, routing, upstream_lonlat, auto)
|
||||
if detail is None:
|
||||
return None, []
|
||||
if points is not None:
|
||||
_retag(detail.pipes, points)
|
||||
_retag(detail.pipes, points, keep_new=auto is not None)
|
||||
rebuilt = _rebuilt(detail)
|
||||
# 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관
|
||||
fresh = [
|
||||
@@ -132,11 +137,14 @@ _DETAIL_CACHE_SIZE = 4
|
||||
DETAIL_CACHE_FILENAME = "04_detail_cache.pkl"
|
||||
|
||||
|
||||
def _detail_key(context: DrainageContext, points: list[PipePoint] | None) -> str:
|
||||
def _detail_key(
|
||||
context: DrainageContext, points: list[PipePoint] | None, auto: str | None = 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]
|
||||
digest.update(json.dumps(listed, sort_keys=True, ensure_ascii=False).encode())
|
||||
# auto 를 열쇠에 — 같은 관 목록이라도 [자동 제안] 결과와 섞이지 않게(369 · 옛 저장본도 비껴감)
|
||||
digest.update(json.dumps([listed, auto], 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"]
|
||||
@@ -167,10 +175,10 @@ def _remember(key: str, value: tuple[DrainageDetail, list[PipePoint]]) -> None:
|
||||
|
||||
|
||||
def _build_cached(
|
||||
context: DrainageContext, points: list[PipePoint] | None
|
||||
context: DrainageContext, points: list[PipePoint] | None, auto: str | None = None
|
||||
) -> tuple[DrainageDetail | None, list[PipePoint]]:
|
||||
"""`_build` + 저장본 — 관 목록은 사본으로 돌려준다(저장이 좌표를 채워도 저장본은 그대로)."""
|
||||
key = _detail_key(context, points)
|
||||
key = _detail_key(context, points, auto)
|
||||
hit = _DETAIL_CACHE.get(key)
|
||||
if hit is None:
|
||||
try:
|
||||
@@ -180,7 +188,7 @@ def _build_cached(
|
||||
except (OSError, pickle.PickleError, EOFError, AttributeError, KeyError, TypeError):
|
||||
hit = None
|
||||
if hit is None:
|
||||
detail, carried = _build(context.stored_path, context, points)
|
||||
detail, carried = _build(context.stored_path, context, points, auto=auto)
|
||||
if detail is None:
|
||||
return None, []
|
||||
hit = (detail, carried)
|
||||
@@ -276,8 +284,20 @@ 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]) -> None:
|
||||
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다."""
|
||||
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
|
||||
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))
|
||||
@@ -452,11 +472,16 @@ 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)
|
||||
@@ -470,7 +495,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:
|
||||
if points is None or auto is not None: # 자동 자리 = 지금 노선 분석이 있어야(369)
|
||||
return JSONResponse(
|
||||
status_code=409, content={"status": "stale", "message": STALE_MESSAGE}
|
||||
)
|
||||
@@ -479,9 +504,11 @@ async def _resolve(
|
||||
|
||||
# [저장]은 정본으로 다시 셈 — 저장본은 GET · POST 만
|
||||
if fresh:
|
||||
result = await asyncio.to_thread(_build, context.stored_path, context, points)
|
||||
result = await asyncio.to_thread(
|
||||
lambda: _build(context.stored_path, context, points, auto=auto)
|
||||
)
|
||||
else:
|
||||
result = await asyncio.to_thread(_build_cached, context, points)
|
||||
result = await asyncio.to_thread(_build_cached, context, points, auto)
|
||||
detail, resolved = result
|
||||
if detail is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS})
|
||||
@@ -511,8 +538,9 @@ async def post_detail_basins(
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
|
||||
|
||||
`points` 칸 없이 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 —
|
||||
노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312).
|
||||
`points` 칸 없이 부르면 저장분을 무시하고 기본 관(세류선 교차)만으로 되돌린다 —
|
||||
[배관 배치 초기화] · 노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312).
|
||||
`auto` = [자동 제안](369) — "all" 전체 자동 · "add" 지금 관 + 빈 구간 보충.
|
||||
"""
|
||||
resolved = await _resolve(project_id, payload, use_stored=False)
|
||||
if isinstance(resolved, JSONResponse):
|
||||
@@ -523,7 +551,7 @@ async def post_detail_basins(
|
||||
|
||||
@router.delete("/{project_id}/drainage/pipe-points", response_model=None)
|
||||
async def delete_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
"""저장된 관 지점을 버리고 기본 관 + 자동 보충 배치로 되돌린다("초기화").
|
||||
"""저장된 관 지점을 버리고 기본 관(세류선 교차) 배치로 되돌린다("초기화").
|
||||
|
||||
화면만 되돌리면 다시 들어왔을 때 옛 관이 살아난다 — 저장분과 파생 산출물까지 지운다
|
||||
(2026-08-02 사용자 보고).
|
||||
|
||||
@@ -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 _build, _payload
|
||||
from B04_PreProcess.B04_PreProcess_Router_Basins import AUTO_MODES, _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
|
||||
@@ -78,7 +78,8 @@ def live_routing(preview: Any, route_xy: list[tuple[float, float]], to_lonlat: A
|
||||
async def post_drainage_live(
|
||||
project_id: UUID, payload: dict[str, Any] | None = None
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""몸통 = {generation, route: RouteDraft | null(저장 노선), points: 관 목록 | null(자동)}."""
|
||||
"""몸통 = {generation, route: RouteDraft | null(저장 노선),
|
||||
points: 관 목록 | null(세류선 교차만), auto: [자동 제안] 방식(all · add · 369) | 없음}."""
|
||||
body = payload or {}
|
||||
generation = int(body.get("generation") or 0)
|
||||
key = str(project_id)
|
||||
@@ -87,6 +88,9 @@ 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)
|
||||
@@ -139,7 +143,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(
|
||||
_build, context.stored_path, live, points, routing, upstream
|
||||
lambda: _build(context.stored_path, live, points, routing, upstream, auto=auto)
|
||||
)
|
||||
if detail is None:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -129,8 +129,8 @@ export interface RouteReplanResponse {
|
||||
route_id: number | null;
|
||||
total_length_m: number | null;
|
||||
vertex_count?: number;
|
||||
/** 노선 저장 결과(346-3 · 서버 `B05_Profile_Route_Save` 머리) — 관 지킴 · 새 교차 · 오목 보충. */
|
||||
pipes?: { kept: number; crossing: number; concave: number };
|
||||
/** 노선 저장 결과(346-3 · 서버 `B05_Profile_Route_Save` 머리) — 관 지킴 · 새 교차. */
|
||||
pipes?: { kept: number; crossing: number };
|
||||
structures?: { moved: number; far: number };
|
||||
/** 새 노선에 못 넘긴 횡단 고침(측점이 사라짐) 수. */
|
||||
cross_dropped?: number;
|
||||
@@ -229,7 +229,7 @@ export async function fetchRouteElevations(
|
||||
return payload.z;
|
||||
}
|
||||
|
||||
/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */
|
||||
/** 횡단 미리보기 한 장(평면 오른쪽 판) — 계산 묶음 그 측점을 이 꼴로 그림(365-3 · 서버 길 없음). */
|
||||
export interface CrossPreviewResponse {
|
||||
status: string;
|
||||
chainage_m: number;
|
||||
@@ -241,7 +241,7 @@ export interface CrossPreviewResponse {
|
||||
cross_half_width_m: number;
|
||||
/** 원지반 횡단 샘플. */
|
||||
samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>;
|
||||
/** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */
|
||||
/** 계획 횡단 — 묶음 cross 단계 설계(B06 카드와 같음). 계획고를 못 세우면 null. */
|
||||
design: {
|
||||
design_line: Array<{ offset_m: number; elevation_m: number }>;
|
||||
cut_area_m2: number;
|
||||
@@ -250,32 +250,6 @@ export interface CrossPreviewResponse {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface CrossPreviewRequest {
|
||||
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
|
||||
chainage_m: number;
|
||||
min_radius_m: number;
|
||||
station_interval_m: number;
|
||||
/** 횡단 반폭(m) — 없으면 서버 기본값. */
|
||||
cross_half_width_m?: number;
|
||||
/** [측점 스냅] 켬 — 호 안 측점 하나를 곡선 중점으로(요구 224-2). */
|
||||
station_snap?: boolean;
|
||||
}
|
||||
|
||||
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
|
||||
export async function fetchCrossPreview(
|
||||
projectId: string,
|
||||
request: CrossPreviewRequest,
|
||||
): Promise<CrossPreviewResponse> {
|
||||
return requestJson<CrossPreviewResponse>(
|
||||
`/projects/${projectId}/route/cross-preview`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }),
|
||||
},
|
||||
120000,
|
||||
);
|
||||
}
|
||||
|
||||
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
|
||||
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
|
||||
return requestJson<RouteReplanResponse>(
|
||||
|
||||
@@ -9,14 +9,14 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan
|
||||
stationSnap, terrain}` · 오면 `vertices` · `station_snap` · `terrain` 대신 이것.
|
||||
`as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로).
|
||||
응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial,
|
||||
initial_refreshed, pipes: {kept, crossing, concave}, structures: {moved, far}, cross_dropped,
|
||||
initial_refreshed, pipes: {kept, crossing}, structures: {moved, far}, cross_dropped,
|
||||
drainage_stale: true, seconds}`.
|
||||
오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) ·
|
||||
409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을
|
||||
옛것으로 되살린 뒤).
|
||||
|
||||
규칙:
|
||||
- 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관 · 오목 지점 보충만 더함
|
||||
- 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함
|
||||
(`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`).
|
||||
- 계획고 = 바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 지킴
|
||||
(`B05_Profile_Engine_Grade_Carry` · 바뀐 구간 = 옛 · 새 **계획 폴리라인** — 화면 planline 과
|
||||
@@ -37,15 +37,12 @@ from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B05_Profile.B05_Profile_Route_Save_Parts import (
|
||||
add_crossing_pipes,
|
||||
carry_cross_inputs,
|
||||
concave_chainages,
|
||||
fill_concave_pipes,
|
||||
follow_pipes,
|
||||
station_keys,
|
||||
vertices_of,
|
||||
@@ -161,40 +158,14 @@ def _xy(project_root: Path, route_data_path: str | None) -> list[list[float]]:
|
||||
return []
|
||||
|
||||
|
||||
def _ground_along(project_root: Path, selection: dict[str, Any], vertices: list) -> tuple:
|
||||
"""새 노선 지반 종단(2 m) — 오목 지점 찾기. 지표면을 못 열면 빈 배열(보충 건너뜀)."""
|
||||
from shapely.geometry import LineString
|
||||
|
||||
from B05_Profile.B05_Profile_Route_Save_Parts import CONCAVE_STEP_M
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
|
||||
try:
|
||||
sampler = build_surface_sampler(
|
||||
project_root / "B04_PreProcess" / "models",
|
||||
str(selection.get("source_filter")),
|
||||
str(selection.get("method") or "dtm"),
|
||||
bool(selection.get("smooth")),
|
||||
)
|
||||
except (FileNotFoundError, KeyError, OSError, ValueError):
|
||||
logger.warning("노선 저장: 지표면을 못 열어 오목 지점 보충을 건너뜀")
|
||||
return np.zeros(0), np.zeros(0)
|
||||
line = LineString([(vertex.x, vertex.y) for vertex in vertices])
|
||||
chainage = np.arange(0.0, line.length + 1e-9, CONCAVE_STEP_M)
|
||||
xy = np.array([line.interpolate(float(s)).coords[0] for s in chainage], dtype=np.float64)
|
||||
z, valid = sampler.sample_xy(xy)
|
||||
return chainage[valid], np.asarray(z, dtype=np.float64)[valid]
|
||||
|
||||
|
||||
async def _place_pipes(
|
||||
project_id: UUID,
|
||||
project_root: Path,
|
||||
stored_path: str,
|
||||
selection: dict[str, Any],
|
||||
old_pipes: list,
|
||||
old_xy: list[list[float]],
|
||||
points: list[dict[str, float]],
|
||||
) -> tuple[list, dict[str, int]]:
|
||||
"""관 따라감 → 새 교차점 기본 관 → 오목 지점 보충 → 정본 저장(새 노선 지문)."""
|
||||
"""관 따라감 → 새 교차점 기본 관 → 정본 저장(새 노선 지문) · 간격 보충 없음(369)."""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare
|
||||
from common_util.common_util_drainage_pipes import route_signature, save_pipe_points
|
||||
@@ -207,8 +178,6 @@ async def _place_pipes(
|
||||
pairs = follow_pipes(old_pipes, vertices)
|
||||
total = float(vertices[-1].chainage_m)
|
||||
crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total)
|
||||
chainage, ground = await asyncio.to_thread(_ground_along, project_root, selection, vertices)
|
||||
concave = fill_concave_pipes(pairs, concave_chainages(chainage, ground), vertices, old_xy)
|
||||
await asyncio.to_thread(
|
||||
save_pipe_points,
|
||||
stored_path,
|
||||
@@ -216,7 +185,7 @@ async def _place_pipes(
|
||||
[pipe for _, pipe in pairs],
|
||||
vertices,
|
||||
)
|
||||
return pairs, {"kept": len(old_pipes), "crossing": crossing, "concave": concave}
|
||||
return pairs, {"kept": len(old_pipes), "crossing": crossing}
|
||||
|
||||
|
||||
async def _carry_profile(
|
||||
@@ -431,10 +400,8 @@ async def run_route_save_chain(
|
||||
new_xy = _xy(project_root, solved.route_data_path)
|
||||
marks.append(("노선 풀기 · 구조물 따라감", time.perf_counter()))
|
||||
|
||||
pairs, pipes = await _place_pipes(
|
||||
project_id, project_root, stored_path, selection, old_pipes, old_xy, points
|
||||
)
|
||||
marks.append(("관 따라감 · 교차점 · 오목 보충", time.perf_counter()))
|
||||
pairs, pipes = await _place_pipes(project_id, stored_path, old_pipes, old_xy, points)
|
||||
marks.append(("관 따라감 · 교차점", time.perf_counter()))
|
||||
|
||||
seed = asdict(_section_options(request, old_section_options))
|
||||
seed.pop("extra_stations", None)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""노선 저장 길(346-3) 순수 부품 — 관 따라감 · 새 교차점 기본 관 · 오목 지점 보충 · 측점 열쇠 ·
|
||||
"""노선 저장 길(346-3) 순수 부품 — 관 따라감 · 새 교차점 기본 관 · 측점 열쇠 ·
|
||||
횡단 이월.
|
||||
|
||||
DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름). 차례 · 저장 · 되살림은
|
||||
@@ -7,7 +7,7 @@ DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름
|
||||
- 관 = 좌표가 정본 — 새 노선에 투영해 누가거리만 다시(`project_pipe_points` · 정수 m). 지우지 않음
|
||||
(사용자 관 편집 지킴).
|
||||
- 새 세류선 교차점(옛 노선 위에 없던 자리)에 관이 없으면 기본 관 하나(`_base_pipes` 결과를 받음).
|
||||
- 관 최대 간격 보충 = 옛 노선에서 벗어난 자리의 **오목 지점**(지반 종단 낮은 곳)에만 — 격자 없음.
|
||||
- 관 최대 간격 보충 없음(369) — 간격 보충은 배수유역 [자동 제안]만.
|
||||
- 측점 열쇠 = `common_util_station_key.station_key` 한 벌(sub2 짝) — 일반 `grid:k` ·
|
||||
관 `pipe:<id>` · 구조물 `structure:<id>` · 시종점 `end:BP` · `end:EP` ·
|
||||
id 없으면 `at:<누가거리 0.01 m>`.
|
||||
@@ -22,11 +22,9 @@ import copy
|
||||
import math
|
||||
from typing import Any, Sequence
|
||||
|
||||
import numpy as np
|
||||
from shapely.geometry import LineString, Point
|
||||
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PIPE_SOURCE_SPACING,
|
||||
PIPE_SOURCE_STREAM,
|
||||
PipePoint,
|
||||
project_pipe_points,
|
||||
@@ -35,14 +33,10 @@ from common_util.common_util_route_geometry import RouteVertex
|
||||
from common_util.common_util_station_key import chainage_key as station_chainage_key
|
||||
from common_util.common_util_station_key import station_key
|
||||
from common_util.common_util_structure_station import plan_stations, snap_structure_station
|
||||
from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M
|
||||
from config.config_system import DRAINAGE_PIPE_MIN_SPACING_M
|
||||
|
||||
# 옛 노선에서 이만큼(m) 넘게 떨어진 자리 = 노선이 바뀐 자리(교차점 · 보충 대상).
|
||||
MOVED_AWAY_M = 0.5
|
||||
# 오목 지점을 찾을 지반 표본 간격(m) · 고름 창(표본 수) — DTM 잔물결을 오목으로 안 읽게.
|
||||
# ponytail: 이동 평균 + 국소 최저 · 계곡이 얕아 놓치면 창을 줄이거나 흐름 강도와 섞을 것.
|
||||
CONCAVE_STEP_M = 2.0
|
||||
CONCAVE_WINDOW = 5
|
||||
_SAME_M = 0.01
|
||||
|
||||
|
||||
@@ -96,57 +90,6 @@ def add_crossing_pipes(
|
||||
return added
|
||||
|
||||
|
||||
def concave_chainages(chainage: np.ndarray, ground: np.ndarray) -> list[tuple[float, float]]:
|
||||
"""지반 종단의 오목 지점(이동 평균 국소 최저) → [(누가거리, 고른 지반고)]."""
|
||||
if ground.size < 3:
|
||||
return []
|
||||
kernel = np.ones(CONCAVE_WINDOW) / CONCAVE_WINDOW
|
||||
pad = CONCAVE_WINDOW // 2
|
||||
smooth = np.convolve(np.pad(ground, pad, mode="edge"), kernel, mode="valid")
|
||||
return [
|
||||
(float(chainage[i]), float(smooth[i]))
|
||||
for i in range(1, smooth.size - 1)
|
||||
if smooth[i] < smooth[i - 1] and smooth[i] <= smooth[i + 1]
|
||||
]
|
||||
|
||||
|
||||
def fill_concave_pipes(
|
||||
pairs: list[tuple[int, PipePoint]],
|
||||
concave: list[tuple[float, float]],
|
||||
vertices: list[RouteVertex],
|
||||
old_xy: Sequence[Sequence[float]] | None,
|
||||
) -> int:
|
||||
"""관 사이(시종점 포함)가 최대 간격을 넘으면 옛 노선 밖 오목 지점 중 가장 낮은 곳에 관."""
|
||||
line = LineString([(vertex.x, vertex.y) for vertex in vertices])
|
||||
old_line = LineString(old_xy) if old_xy and len(old_xy) >= 2 else None
|
||||
total = float(line.length)
|
||||
added = 0
|
||||
while True:
|
||||
edges = [0.0, *(pipe.chainage_m for _, pipe in pairs), total]
|
||||
best: tuple[float, float] | None = None
|
||||
for start, end in zip(edges, edges[1:]):
|
||||
if end - start <= DRAINAGE_PIPE_MAX_SPACING_M:
|
||||
continue
|
||||
for at, z in concave:
|
||||
if not (
|
||||
start + DRAINAGE_PIPE_MIN_SPACING_M < at < end - DRAINAGE_PIPE_MIN_SPACING_M
|
||||
):
|
||||
continue
|
||||
point = line.interpolate(at)
|
||||
if _moved_away(old_line, point.x, point.y) and (best is None or z < best[1]):
|
||||
best = (at, z)
|
||||
if best is None:
|
||||
return added
|
||||
at = snap_structure_station(best[0], [total])
|
||||
point = line.interpolate(at)
|
||||
pairs.append(
|
||||
(-1, PipePoint(chainage_m=at, source=PIPE_SOURCE_SPACING, x=point.x, y=point.y))
|
||||
)
|
||||
pairs.sort(key=lambda pair: pair[1].chainage_m)
|
||||
concave = [item for item in concave if abs(item[0] - best[0]) > _SAME_M]
|
||||
added += 1
|
||||
|
||||
|
||||
def _facility_owners(
|
||||
pipes: Sequence[PipePoint], structures: Sequence[Any]
|
||||
) -> dict[float, tuple[str, str | None]]:
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""계획노선 편집 중 **지반고만** 묻는 가벼운 통로.
|
||||
|
||||
편집 모달은 [확인] 전까지 아무 계산도 내보내지 않는다(계획서 0-2 확정 7). 다만 두 점을 찍어
|
||||
**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)와 한 측점의 **횡단도 미리보기**(⑧)는 지반고가
|
||||
있어야 한다. 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지
|
||||
**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)는 지반고가 있어야 한다(측점 횡단 미리보기 = 계산
|
||||
묶음 · 365-3). 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지
|
||||
않는다 — 노선을 갈아 끼우지도, 정본을 건드리지도 않는다.
|
||||
|
||||
표고 조회는 종·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`)를 연다.
|
||||
두 화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다.
|
||||
|
||||
POST /api/projects/{id}/route/elevations → 점 묶음의 지반고
|
||||
POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기
|
||||
GET /api/projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도)
|
||||
GET /api/projects/{id}/surface/models/{mid}/grid → DTM 격자 원시 이진(브라우저 표본기 · ETag)
|
||||
|
||||
@@ -30,13 +29,7 @@ from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
||||
SectionGenerationOptions,
|
||||
generate_sections,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||||
from common_util.common_util_http_cache import cached_file_response
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
AnalysisTerrain,
|
||||
@@ -121,143 +114,6 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) ->
|
||||
}
|
||||
|
||||
|
||||
class PreviewVertex(BaseModel):
|
||||
"""편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴."""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
curve: bool = True
|
||||
radius_m: float | None = None
|
||||
|
||||
|
||||
class CrossPreviewRequest(BaseModel):
|
||||
"""고치던 노선 그대로 한 측점의 횡단을 미리 본다."""
|
||||
|
||||
vertices: list[PreviewVertex] = Field(..., min_length=2)
|
||||
chainage_m: float = Field(..., ge=0)
|
||||
#: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다.
|
||||
min_radius_m: float = Field(12.0, gt=0)
|
||||
station_interval_m: float | None = None
|
||||
#: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값.
|
||||
cross_half_width_m: float | None = Field(None, gt=0)
|
||||
terrain: AnalysisTerrain | None = None
|
||||
#: [측점 스냅] 켬(요구 224-2) — 편집 창 단추 값 그대로.
|
||||
station_snap: bool = False
|
||||
|
||||
|
||||
def _cross_preview(
|
||||
project_root: Path,
|
||||
params: dict,
|
||||
request: CrossPreviewRequest,
|
||||
) -> dict | None:
|
||||
"""고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다.
|
||||
|
||||
**B05·B06 의 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 「기본 로직은 B06에
|
||||
존재함. 재사용」) — `generate_sections` 가 측점·접선·지반 샘플을, `compute_cross_design`
|
||||
이 설계선을 만든다. 여기서 기하를 새로 짜지 않는다.
|
||||
|
||||
⚠ **계획고는 아직 없다.** 계획고는 [확인] 뒤 전 체인이 낳는 값이라 편집 중에는 존재하지
|
||||
않는다. 그래서 그 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) — 절·성토가 사면
|
||||
기울기만으로 서는 「기본 계획 횡단」이며, 사용자가 보기로 한 것도 그것이다.
|
||||
"""
|
||||
try:
|
||||
sampler = build_surface_sampler(
|
||||
project_root / _MODELS_SUBDIR,
|
||||
str(params["source_filter"]),
|
||||
str(params["method"]),
|
||||
bool(params["smooth"]),
|
||||
)
|
||||
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
|
||||
logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc)
|
||||
return None
|
||||
|
||||
built = build_planned_polyline(
|
||||
[(vertex.x, vertex.y) for vertex in request.vertices],
|
||||
min_radius_m=request.min_radius_m,
|
||||
# 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`).
|
||||
simplify=False,
|
||||
curve_flags=[vertex.curve for vertex in request.vertices],
|
||||
radii=[vertex.radius_m for vertex in request.vertices],
|
||||
)
|
||||
interval = request.station_interval_m
|
||||
defaults = SectionGenerationOptions()
|
||||
options = SectionGenerationOptions(
|
||||
station_interval_m=float(interval)
|
||||
if interval and interval > 0
|
||||
else defaults.station_interval_m,
|
||||
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
|
||||
)
|
||||
# 곡선표 = 방금 지은 노선의 곡선 — 스냅 자리 · 확폭이 [확인] 뒤 서버 정본과 같은 규칙.
|
||||
result = generate_sections(
|
||||
built.vertices,
|
||||
sampler,
|
||||
options,
|
||||
design_curves=[curve.as_dict() for curve in built.curves],
|
||||
station_snap=request.station_snap,
|
||||
)
|
||||
sections = result["cross_sections"]
|
||||
if not sections:
|
||||
return None
|
||||
section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m))
|
||||
|
||||
design = None
|
||||
center_z = section.get("center_z")
|
||||
if center_z is not None:
|
||||
# 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다.
|
||||
section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut"
|
||||
design = compute_cross_design(
|
||||
section["samples"],
|
||||
float(center_z),
|
||||
ground_type="soil",
|
||||
section_mode=section_mode,
|
||||
**curve_widening_args(section),
|
||||
)
|
||||
return {
|
||||
"chainage_m": round(float(section["chainage_m"]), 3),
|
||||
"label": section.get("label"),
|
||||
"uphill_side": section.get("uphill_side"),
|
||||
"plan_radius_m": section.get("plan_radius_m"),
|
||||
"curve_widening_m": section.get("curve_widening_m"),
|
||||
# 샘플이 실제로 덮은 반폭 — 평면도 반폭 선이 이 값을 그대로 쓴다.
|
||||
"cross_half_width_m": options.cross_half_width_m,
|
||||
"samples": section["samples"],
|
||||
"design": design,
|
||||
"total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3)
|
||||
if result.get("longitudinal", {}).get("total_length_m") is not None
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/cross-preview", response_model=None)
|
||||
async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse:
|
||||
"""고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧).
|
||||
|
||||
정본을 건드리지 않는다 — 파일도 DB 도 쓰지 않고 그 자리에서 셈해 돌려주기만 한다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
if not stored:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
|
||||
)
|
||||
params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
params = analysis_terrain_params(params, request.terrain)
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored))
|
||||
preview = await asyncio.to_thread(_cross_preview, project_root, params, request)
|
||||
if preview is None:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.",
|
||||
},
|
||||
)
|
||||
return {"status": "success", "project_id": str(project_id), **preview}
|
||||
|
||||
|
||||
@router.get("/{project_id}/route/terrain-sources", response_model=None)
|
||||
async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse:
|
||||
"""편집 모달이 고를 수 있는 분석 지형 — 둘이면 고르기를 띄움 · 하나면 숨김."""
|
||||
|
||||
@@ -308,8 +308,8 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[]
|
||||
const side: "left" | "right" = wall.outward > 0 ? "left" : "right";
|
||||
const slope = trimSlopeOf(side);
|
||||
if (slope) pushTopSlope(side, slope.points);
|
||||
// 구조물 전면 실루엣 — 이음선 상단에서 전면 발끝까지 내려온다.
|
||||
push(side, [wall.topJoint, wall.bottomFront]);
|
||||
// 구조물 전면 실루엣 — 성토 위 이음에서 전면 발끝까지 내려온다(371-1).
|
||||
push(side, [wall.fillTop, wall.bottomFront]);
|
||||
spans[side] = wallSpanOf(section, wall.role === "inlet" ? "inlet" : "outlet");
|
||||
}
|
||||
const outletSegments = layout.outletFill.segments.filter((segment) => segment.kind !== "cut");
|
||||
@@ -321,13 +321,13 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[]
|
||||
addRun(outletSide, segment.points);
|
||||
}
|
||||
const wall = layout.extraWalls[step];
|
||||
if (wall) push(outletSide, [wall.topJoint, wall.bottomFront]);
|
||||
if (wall) push(outletSide, [wall.fillTop, wall.bottomFront]);
|
||||
}
|
||||
layout.basinFill.segments.forEach((segment) => {
|
||||
push(inletSide, segment.points);
|
||||
addRun(inletSide, segment.points);
|
||||
});
|
||||
layout.basinExtras.forEach((wall) => push(inletSide, [wall.topJoint, wall.bottomFront]));
|
||||
layout.basinExtras.forEach((wall) => push(inletSide, [wall.fillTop, wall.bottomFront]));
|
||||
|
||||
let basinCut = false;
|
||||
if (layout.basin) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 배선(토글·포인터·그리기)은 전부 본체가 맡는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
@@ -20,6 +21,8 @@ export interface DrainageChrome {
|
||||
analyzeButton: HTMLButtonElement;
|
||||
deleteButton: HTMLButtonElement;
|
||||
autoButton: HTMLButtonElement;
|
||||
/** [자동 제안](369) — 고름 창 · 배선은 `B05_Profile_UI_Drainage_Live`. */
|
||||
proposeButton: HTMLButtonElement;
|
||||
viewport: HTMLElement;
|
||||
backgroundImage: HTMLImageElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
@@ -88,13 +91,18 @@ export function createDrainageChrome(
|
||||
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;
|
||||
header.append(analyzeButton, deleteButton, autoButton);
|
||||
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);
|
||||
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "b05-drainage__viewport";
|
||||
@@ -155,6 +163,7 @@ export function createDrainageChrome(
|
||||
analyzeButton,
|
||||
deleteButton,
|
||||
autoButton,
|
||||
proposeButton,
|
||||
viewport,
|
||||
backgroundImage,
|
||||
canvas,
|
||||
|
||||
@@ -14,9 +14,14 @@
|
||||
* - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 흐림 + [업데이트] · 자동이면 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";
|
||||
@@ -37,6 +42,7 @@ import {
|
||||
} 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";
|
||||
import { pageHistory } from "./B05_Profile_UI_Profile_History";
|
||||
|
||||
const MODE_KEY = "drainage-mode";
|
||||
|
||||
@@ -47,7 +53,7 @@ export interface DrainageLiveDeps {
|
||||
basinList: HTMLElement;
|
||||
pipeRequest: () => DetailPipeInput[];
|
||||
runner: DetailRunner;
|
||||
/** [적용] 답이 화면에 오른 뒤 — 세션 초안에 담음. */
|
||||
/** [적용] · [자동 제안] 답이 화면에 오른 뒤 — 세션 초안에 담음. */
|
||||
rememberPipes: () => void;
|
||||
/** 저장 배수유역 다시 받기(열 때와 같은 길 · 조용히) — 답을 화면에 올렸으면 true. */
|
||||
loadSaved: () => Promise<boolean>;
|
||||
@@ -62,6 +68,8 @@ export interface DrainageLive {
|
||||
markStale(): void;
|
||||
/** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청 · 기다림도 끊음). */
|
||||
markFresh(): void;
|
||||
/** [배관 배치 초기화] — 세류선 교차 관만으로 초안(369 · 저장분은 [저장] 때). */
|
||||
resetToStreams(): void;
|
||||
readonly stale: boolean;
|
||||
suggest: DrainageSuggest;
|
||||
}
|
||||
@@ -69,7 +77,7 @@ export interface DrainageLive {
|
||||
/** 「지금 노선으로 셈」 — 서버 `post_drainage_live` · 작업본에 안 씀. */
|
||||
async function fetchLive(
|
||||
projectId: string,
|
||||
body: ReturnType<typeof liveRequestBody>,
|
||||
body: ReturnType<typeof liveRequestBody> | (Proposal & { generation: number }),
|
||||
signal: AbortSignal,
|
||||
): Promise<DetailBasinResponse & { status?: string; generation?: number }> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/drainage/live`, {
|
||||
@@ -88,16 +96,33 @@ 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" };
|
||||
|
||||
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 suggest = createDrainageSuggest(deps.pipeRequest, (points) => {
|
||||
const propose = header.querySelector<HTMLButtonElement>(".b05-drainage__propose");
|
||||
/** 관 초안 요청 — 답이 화면에 오르면 세션 초안 + 사진첩 한 걸음(400 ms 넘은 답도 제 걸음). */
|
||||
const draft = (request: (projectId: string) => Promise<DetailBasinResponse>): void => {
|
||||
const projectId = deps.runner.projectId();
|
||||
if (!projectId) return;
|
||||
void deps.runner
|
||||
.run(() => computeDetailBasins(projectId, points))
|
||||
.then((ok) => ok && deps.rememberPipes());
|
||||
});
|
||||
.run(() => request(projectId))
|
||||
.then((ok) => {
|
||||
if (!ok) return;
|
||||
deps.rememberPipes();
|
||||
pageHistory()?.record();
|
||||
});
|
||||
};
|
||||
const suggest = createDrainageSuggest(deps.pipeRequest, (points) =>
|
||||
draft((projectId) => computeDetailBasins(projectId, points)),
|
||||
);
|
||||
deps.basinList.before(suggest.root);
|
||||
let mode = readDrainageMode();
|
||||
let route: unknown = null;
|
||||
@@ -106,6 +131,8 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
let drafted = false;
|
||||
/** 저장분 다시 받기 답이 「낡음」이었나(`markStale` 이 세움). */
|
||||
let savedStale = false;
|
||||
/** 저장 분석이 옛 노선(서버 「낡음」 답 · [확정] 전까지) — [자동 제안]은 지금 노선으로 셈. */
|
||||
let analysisStale = false;
|
||||
let generation = 0;
|
||||
/** 마지막 단계 입력 지문 — 같으면 묶음이 다시 돈 것뿐. */
|
||||
let key = "";
|
||||
@@ -136,6 +163,10 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
const paint = (): void => {
|
||||
deps.root.classList.toggle("is-stale", stale && mode === "manual");
|
||||
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;
|
||||
};
|
||||
@@ -147,7 +178,8 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
inflight = null;
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
/** 지금 노선으로 셈 — `proposal` 이면 그 관 목록 · 방식으로(답 = 관 초안 · 사진첩 한 걸음). */
|
||||
function refresh(proposal?: Proposal): void {
|
||||
const projectId = deps.runner.projectId();
|
||||
if (!projectId) return;
|
||||
cancel();
|
||||
@@ -155,7 +187,7 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
const controller = new AbortController();
|
||||
inflight = controller;
|
||||
paint();
|
||||
const body = liveRequestBody(sent.generation, route, deps.pipeRequest());
|
||||
const body = { ...liveRequestBody(sent.generation, route, deps.pipeRequest()), ...proposal };
|
||||
void deps.runner
|
||||
.run(async () => {
|
||||
const response = await fetchLive(projectId, body, controller.signal);
|
||||
@@ -168,10 +200,30 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
if (inflight !== controller) return;
|
||||
inflight = null;
|
||||
if (applied) stale = false;
|
||||
if (applied && proposal) {
|
||||
deps.rememberPipes();
|
||||
pageHistory()?.record();
|
||||
}
|
||||
paint();
|
||||
});
|
||||
}
|
||||
|
||||
/** [자동 제안] · [배관 배치 초기화] — 저장 분석이 지금 노선과 같으면 빠른 길(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();
|
||||
@@ -223,6 +275,36 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
});
|
||||
}
|
||||
|
||||
/** [자동 제안] 고름 창 — ⚙ 창과 같은 틀 · 고르면 닫고 그 방식으로 초안. */
|
||||
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);
|
||||
@@ -232,8 +314,9 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
paint();
|
||||
}
|
||||
|
||||
update.addEventListener("click", refresh);
|
||||
update.addEventListener("click", () => refresh());
|
||||
gear.addEventListener("click", openSettings);
|
||||
propose?.addEventListener("click", openPropose);
|
||||
|
||||
return {
|
||||
stage(ctx) {
|
||||
@@ -247,10 +330,11 @@ 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);
|
||||
@@ -259,8 +343,10 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
markFresh() {
|
||||
cancel();
|
||||
stale = false;
|
||||
analysisStale = false;
|
||||
paint();
|
||||
},
|
||||
resetToStreams: () => proposeDraft({ points: null }),
|
||||
get stale() {
|
||||
return stale;
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
computeDetailBasins,
|
||||
fetchDetailPipePoints,
|
||||
getVWorldMapUrl,
|
||||
resetDetailPipePoints,
|
||||
saveDetailPipePoints,
|
||||
type DetailBasin,
|
||||
type DetailBasinResponse,
|
||||
@@ -35,6 +34,7 @@ import { snapStructureStation } from "../common_util/common_util_structure_stati
|
||||
import { createFacilityStore } 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 { pageReplaying } from "./B05_Profile_UI_Profile_History";
|
||||
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
|
||||
import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill";
|
||||
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
summaryText,
|
||||
basinIndexOfPipe,
|
||||
pipeMarkerColor,
|
||||
adoptDraftPipes,
|
||||
reconcilePipes,
|
||||
WIDTH_KEY,
|
||||
type DrainageLayer,
|
||||
@@ -416,10 +417,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
: fetchDetailPipePoints(projectId as string);
|
||||
};
|
||||
|
||||
/** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때
|
||||
* 옛 관이 살아난다(2026-08-02 사용자 보고). */
|
||||
const resetPipes = (): Promise<boolean> => run(() => resetDetailPipePoints(projectId as string));
|
||||
|
||||
/** 세부유역을 다시 나눈다(격자 해석 없음) — 같은 틱의 여러 부름은 한 번(342-3 · 옛 소수 관 옮김).
|
||||
* 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */
|
||||
const analyze = batched(() => run(() => computeDetailBasins(projectId as string, pipeRequest())));
|
||||
@@ -435,7 +432,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
});
|
||||
analyzeButton.addEventListener("click", () => void analyze());
|
||||
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
|
||||
autoButton.addEventListener("click", () => void resetPipes());
|
||||
autoButton.addEventListener("click", live.resetToStreams); // 세류선만 · 초안(369)
|
||||
|
||||
/** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인).
|
||||
*
|
||||
@@ -596,11 +593,13 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
const before = pipeEditor.pipes().map((pipe) => pipe.chainage_m);
|
||||
const next = reconcilePipes(pipeEditor.pipes(), chainages);
|
||||
if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다
|
||||
// 되돌리기 · 구조물 폼으로 옮겨진 관도 속성을 데려감
|
||||
facilityStore.follow(
|
||||
before,
|
||||
next.map((pipe) => pipe.chainage_m),
|
||||
);
|
||||
// 되돌리기 = 그 걸음 초안의 속성 · id · 초안 없는 걸음은 저장분(369) · 구조물 폼은 속성 데려감
|
||||
const pending = pageReplaying() ? readPendingPipes(projectId as string) : null;
|
||||
if (pageReplaying() && !pending) return void run(loadSaved, true);
|
||||
const draft = adoptDraftPipes(pending, next);
|
||||
const after = next.map((pipe) => pipe.chainage_m);
|
||||
if (draft) facilityStore.replaceFromResponse(draft);
|
||||
else facilityStore.follow(before, after);
|
||||
pipeEditor.setPipes(next);
|
||||
void analyze();
|
||||
},
|
||||
|
||||
@@ -343,6 +343,22 @@ export function mountDrainageToggles(
|
||||
|
||||
/** 밖에서 바뀐 관 목록을 현재 목록과 맞춘다. 같으면 null — 되먹임 고리를 끊는 지점이다.
|
||||
* 다르면 새 목록을 돌려주되, 원래 생성 사유는 자리로 맞춰 이어 붙인다(기본/자동/수동 표시 보존). */
|
||||
/** 되돌리기(369) — 되살린 걸음의 관 초안(세션)이 `next` 와 같은 자리면 그 초안(속성 정본) · id 도
|
||||
* `next` 에 옮김. 자리가 다르면(구조물 폼이 옮김 등) null — 부르는 쪽이 속성을 따라 옮김. */
|
||||
export function adoptDraftPipes<D extends { chainage_m: number; id?: string }>(
|
||||
draft: ReadonlyArray<D> | null,
|
||||
next: PipePoint[],
|
||||
): ReadonlyArray<D> | null {
|
||||
const same =
|
||||
draft?.length === next.length &&
|
||||
draft.every((item, index) => Math.abs(item.chainage_m - next[index].chainage_m) < 0.01);
|
||||
if (!draft || !same) return null;
|
||||
next.forEach((pipe, index) => {
|
||||
if (draft[index].id) pipe.id = draft[index].id;
|
||||
});
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function reconcilePipes(
|
||||
before: ReadonlyArray<PipePoint>,
|
||||
chainages: ReadonlyArray<number>,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Suggest.ts
|
||||
* 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류 · 관 자리 후보를 보여 주고 [적용] 한 번에 관 초안으로.
|
||||
* 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류 · 관 자리 후보를 보여 주고 [적용] 한 번에 관경 · 시설만
|
||||
* 관 초안으로(관 자리 = [자동 제안] · 369).
|
||||
* 모으기 · 얹기 규칙은 `logic/B05_Profile_Drainage_Suggest.ts` · 자동 바꿈 없음.
|
||||
* 관 자리 후보는 배수유역도에도 점선 원으로 그림(`candidates()` → 그리기 판).
|
||||
* ========================================================================== */
|
||||
@@ -26,7 +27,8 @@ 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} — 관 자리 후보(흐름이 모이는 오목 자리 · [자동 제안])`;
|
||||
if (item.kind === "facility")
|
||||
return `${at} — ${facilityName(item.from)} → ${facilityName(item.to)} 검토`;
|
||||
return `${at} — 관경 ${item.from === null ? "미정" : `Ø${item.from}`} → Ø${item.to}`;
|
||||
@@ -56,13 +58,19 @@ export function createDrainageSuggest(
|
||||
apply.className = "b05-drainage__analyze";
|
||||
apply.textContent = "적용";
|
||||
const applyTitle =
|
||||
"제안을 관 초안에 한 번에 얹습니다(관 더함 · 관경 · 시설 바꿈) — [저장] 때 남습니다.";
|
||||
"관경 · 시설 제안을 관 초안에 한 번에 얹습니다 — [저장] 때 남습니다(관 자리는 [자동 제안]).";
|
||||
apply.title = applyTitle;
|
||||
head.append(title, apply);
|
||||
const list = document.createElement("ul");
|
||||
list.className = "b05-drainage__suggest-list";
|
||||
root.append(head, list);
|
||||
let suggestions: Suggestion[] = [];
|
||||
let stale = false;
|
||||
/** [적용] 켜짐 — 낡지 않고 관경 · 시설 제안이 있을 때(관 자리 후보만이면 할 일 없음). */
|
||||
const paintApply = (): void => {
|
||||
apply.disabled = stale || !suggestions.some((item) => item.kind !== "position");
|
||||
apply.title = stale ? "노선이 바뀌어 제안이 낡음 — 새 계산 뒤 적용" : applyTitle;
|
||||
};
|
||||
|
||||
apply.addEventListener("click", () => {
|
||||
if (suggestions.length && !apply.disabled)
|
||||
@@ -77,6 +85,7 @@ export function createDrainageSuggest(
|
||||
: collectSuggestions(response.pipe_points, response.basins, response.pipe_candidates ?? []);
|
||||
root.hidden = suggestions.length === 0;
|
||||
title.textContent = `제안 ${suggestions.length}건`;
|
||||
paintApply();
|
||||
list.replaceChildren(
|
||||
...suggestions.map((item) => {
|
||||
const row = document.createElement("li");
|
||||
@@ -85,9 +94,9 @@ export function createDrainageSuggest(
|
||||
}),
|
||||
);
|
||||
},
|
||||
setStale(stale) {
|
||||
apply.disabled = stale;
|
||||
apply.title = stale ? "노선이 바뀌어 제안이 낡음 — 새 계산 뒤 적용" : applyTitle;
|
||||
setStale(next) {
|
||||
stale = next;
|
||||
paintApply();
|
||||
},
|
||||
candidates: () =>
|
||||
suggestions.filter((item) => item.kind === "position").map((item) => item.chainageM),
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
* ── 배치 (354-1 · 요구 235-7 「유토곡선도 분리」) ─────────────────────
|
||||
* 종단 열(그래프 · 표) 아래 칸에 따로 선다 — 종단도 · 표 위를 덮지 않고 높이 나눔 · 표 쌓기에도
|
||||
* 안 낀다. 높이는 판 위 경계 리사이저(`createPanelResizer`)로 조절하고 세션 동안 유지한다.
|
||||
* 머리줄([창 분할] · 접기 손잡이)은 접어도 남는다. 곡선 SVG는 판 안 자체 가로 스크롤러에
|
||||
* 들어가며, 종단 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다.
|
||||
* 접으면 손잡이만 남는다(367-2). 곡선은 늘 노선 전체를 한 화면에 맞춰 그린다 — 종단 측점과
|
||||
* 맞물리는 보기 · 스크롤 동기는 없앰(367-1).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
@@ -46,16 +46,13 @@ import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||
import {
|
||||
createMassHaulChart,
|
||||
createMassHaulLegend,
|
||||
createMassHaulWindowState,
|
||||
MASS_HAUL_MIN_HEIGHT,
|
||||
scheduleMassHaulSettle,
|
||||
} from "@util/common_util_mass_haul_view";
|
||||
import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import "@util/common_util_mass_haul.css";
|
||||
import "./B05_Profile_UI_Style_MassHaul.css";
|
||||
import { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel";
|
||||
import { frameOf } from "../common_util/common_util_view_window";
|
||||
|
||||
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
|
||||
@@ -76,10 +73,8 @@ const HEIGHT_VAR = "--b05-masshaul-height";
|
||||
* 표시 상태가 갈리면 "같은 곡선인데 왜 다르게 보이나"가 된다(2026-08-03 사용자 확정).
|
||||
* 정의처는 B06 `_UI_Section_View`와 이 파일 두 곳뿐이며 값이 반드시 같아야 한다.
|
||||
*/
|
||||
/** 처음 열 때는 **곡선만** 보인다 — 토량 분배(평형선·운반 블록)는 범례에서 켠다
|
||||
* (2026-09-06 사용자 지시). 판을 v4 → v5 로 올려 이미 켜 둔 브라우저도 새 기본값으로
|
||||
* 시작하게 한다. */
|
||||
const DEFAULT_VISIBLE: string[] = [...MASS_HAUL_DEFAULT_VISIBLE];
|
||||
/** 기본 = 횡단 기준 + 토량 분배 켬(367-1) — 판 v6. [토량 분배] 는 분배 그림 보임/숨김. */
|
||||
const DEFAULT_VISIBLE: string[] = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY];
|
||||
|
||||
/** 유토곡선 계산에 필요한 프로젝트 설정 — B05 Page가 `fetchSectionContext()`에서 받아 넘긴다. */
|
||||
export interface RouteMassHaulContext {
|
||||
@@ -150,12 +145,8 @@ export interface RouteMassHaulPanel {
|
||||
overlay: HTMLElement;
|
||||
isOpen(): boolean;
|
||||
setContext(next: RouteMassHaulContext | null): void;
|
||||
/** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */
|
||||
attachScrollSync(main: HTMLElement): void;
|
||||
/** 판 내용을 다시 그린다. 접혀 있으면 머리줄만 남기고 끝낸다. */
|
||||
/** 판 내용을 다시 그린다. 접혀 있으면 손잡이만 남기고 끝낸다. */
|
||||
draw(params: RouteMassHaulDrawParams): void;
|
||||
/** 가로 스크롤로 보이는 구간만 바뀜 — 창을 따를 때만 · 한 틀 한 번 · 다시 셈 없음(354-3). */
|
||||
drawWindow(params: RouteMassHaulDrawParams): void;
|
||||
}
|
||||
|
||||
function readVisible(): Set<string> {
|
||||
@@ -199,7 +190,7 @@ export function createRouteMassHaulPanel(
|
||||
handle.className = "b05-profile__masshaul-handle";
|
||||
handle.append(handleControl.root);
|
||||
handleControl.root.setAttribute("aria-label", "유토곡선 패널");
|
||||
// 머리줄 — 접어도 남아 [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임) · 손잡이가 닿는다(354-1).
|
||||
// 머리줄 — [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임 · 펼친 때만 보임) · 손잡이(접으면 판 위 경계에 매달림 · 367-2).
|
||||
const head = document.createElement("div");
|
||||
head.className = "b05-profile__masshaul-head";
|
||||
head.append(handle);
|
||||
@@ -248,10 +239,6 @@ export function createRouteMassHaulPanel(
|
||||
pendingChip.hidden = true;
|
||||
overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip);
|
||||
|
||||
// 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시).
|
||||
// scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로.
|
||||
attachWheelHorizontalScroll(scroll);
|
||||
|
||||
/** 곡선을 그리는가 — 펼침이거나 새 창으로 뗌(뗀 창엔 접기가 없음 · 354-1). */
|
||||
const shown = (): boolean => open || overlay.classList.contains("is-popout");
|
||||
|
||||
@@ -262,7 +249,7 @@ export function createRouteMassHaulPanel(
|
||||
// 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다.
|
||||
handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기";
|
||||
handle.classList.toggle("is-open", next);
|
||||
// 접으면 머리줄만 남는다(배수유역도처럼 손잡이는 늘 닿음)
|
||||
// 접으면 손잡이만 남는다(배수유역도처럼 손잡이는 늘 닿음)
|
||||
overlay.classList.toggle("is-collapsed", !next);
|
||||
onChanged();
|
||||
}
|
||||
@@ -279,29 +266,14 @@ export function createRouteMassHaulPanel(
|
||||
onChanged();
|
||||
}
|
||||
|
||||
// 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(354-3).
|
||||
// 토량 분배(전체 맞춤) 중에는 끊는다 — 유토곡선이 0 으로 붙으며 낸 scroll 이 종단 그래프 ·
|
||||
// 표까지 0 으로 되돌렸다(PLAN 79-1). 유토곡선만 스크롤이 없는 상태.
|
||||
let fitted = false;
|
||||
let syncedMain: HTMLElement | null = null;
|
||||
let putScroll: ((to: HTMLElement, left: number) => void) | null = null;
|
||||
/** 종단과 맞물려 그린 곡선이 놓인 문서 — 처음 · 분배 맞춤 뒤 · 창 옮김 뒤면 종단 자리로 맞춘다. */
|
||||
let syncedIn: Document | null = null;
|
||||
function attachScrollSync(main: HTMLElement): void {
|
||||
if (syncedMain === main) return;
|
||||
syncedMain = main;
|
||||
putScroll = linkScrollLeft(main, scroll, () => fitted);
|
||||
}
|
||||
|
||||
/** 곡선을 한 번이라도 그렸는지 — 재계산 대기 중 화면을 비우지 않기 위한 표시. */
|
||||
let drawnOnce = false;
|
||||
/** 마지막으로 낸 계열 — 가로 스크롤(보이는 구간만 바뀜)은 다시 셈 없이 이걸로 그린다(354-3). */
|
||||
/** 마지막으로 낸 계열 — 같은 계열이면 총괄값을 다시 내지 않는다. */
|
||||
let lastSeries: MassHaulSeries[] | null = null;
|
||||
|
||||
function note(message: string): void {
|
||||
drawnOnce = false;
|
||||
lastSeries = null;
|
||||
syncedIn = null;
|
||||
pendingChip.hidden = true;
|
||||
bar.replaceChildren();
|
||||
scroll.replaceChildren();
|
||||
@@ -312,11 +284,6 @@ export function createRouteMassHaulPanel(
|
||||
bar.append(empty);
|
||||
}
|
||||
|
||||
// 세로창 버티기·부드러운 이동 상태 — 이 패널이 사는 동안 하나만 둔다(2026-09-04).
|
||||
const windowState = createMassHaulWindowState();
|
||||
/** 마지막으로 그린 입력 — 이동이 아직 안 끝났으면 다음 프레임에 이걸로 한 번 더 그린다. */
|
||||
let lastParams: RouteMassHaulDrawParams | null = null;
|
||||
|
||||
/** 이미 낸 계열에서 총괄값만 꺼낸다 — 계산은 하지 않는다. */
|
||||
function emitSummary(series: MassHaulSeries[]): void {
|
||||
if (!onSummary) return;
|
||||
@@ -348,33 +315,16 @@ export function createRouteMassHaulPanel(
|
||||
);
|
||||
}
|
||||
|
||||
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. `reuse` = 마지막 계열로(다시 셈 없음). */
|
||||
function draw(params: RouteMassHaulDrawParams, reuse = false): void {
|
||||
pipelineWork("유토곡선", () => drawNow(params, reuse));
|
||||
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. */
|
||||
function draw(params: RouteMassHaulDrawParams): void {
|
||||
pipelineWork("유토곡선", () => drawNow(params));
|
||||
}
|
||||
|
||||
/** 가로 스크롤로 보이는 구간만 바뀜(354-3) — 곡선이 창을 따를 때만(열림 · 분배 맞춤 아님 · 붙음) ·
|
||||
* 한 틀 한 번 · 다시 셈 없이 세로 창만 다시 그림. 접힘 · 분배 · 새 창은 스크롤마다 할 일 없음. */
|
||||
let windowDirty = false;
|
||||
function drawWindow(params: RouteMassHaulDrawParams): void {
|
||||
if (!open || fitted || overlay.classList.contains("is-popout") || !lastSeries) return;
|
||||
lastParams = params;
|
||||
// 미끄러짐 틀이 이미 잡혀 있으면 그 틀이 이 값으로 그림
|
||||
if (windowDirty || windowState.frame) return;
|
||||
windowDirty = true;
|
||||
frameOf(overlay, () => {
|
||||
if (windowDirty && lastParams) draw(lastParams, true);
|
||||
});
|
||||
}
|
||||
|
||||
function drawNow(params: RouteMassHaulDrawParams, reuse: boolean): void {
|
||||
lastParams = params;
|
||||
windowDirty = false;
|
||||
function drawNow(params: RouteMassHaulDrawParams): void {
|
||||
overlay.classList.toggle("is-collapsed", !open);
|
||||
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
|
||||
// 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번).
|
||||
if (!shown()) {
|
||||
syncedIn = null;
|
||||
reportSummary(params);
|
||||
return;
|
||||
}
|
||||
@@ -407,17 +357,13 @@ export function createRouteMassHaulPanel(
|
||||
// (354-2 · 계산 코드는 공용에 남김).
|
||||
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
|
||||
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
|
||||
// 스크롤로 창만 바뀐 그리기(`reuse`)는 직전 계열 그대로 — 같은 입력의 두 번째 적분이었다(354-3).
|
||||
const series: MassHaulSeries[] =
|
||||
reuse && lastSeries
|
||||
? lastSeries
|
||||
: computeMassHaulSeries(
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
context.conversion,
|
||||
context.naturalSpoilMinSlope,
|
||||
["cross"],
|
||||
);
|
||||
const series: MassHaulSeries[] = computeMassHaulSeries(
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
context.conversion,
|
||||
context.naturalSpoilMinSlope,
|
||||
["cross"],
|
||||
);
|
||||
// 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다.
|
||||
if (series !== lastSeries) emitSummary(series);
|
||||
lastSeries = series;
|
||||
@@ -446,29 +392,16 @@ export function createRouteMassHaulPanel(
|
||||
(overlay.clientHeight || OVERLAY_DEFAULT_HEIGHT) - bar.offsetHeight - 12;
|
||||
const chartHeight = Math.max(scrollHeight, MASS_HAUL_MIN_HEIGHT);
|
||||
|
||||
// 토량 분배를 켜면 **곡선 전체가 한 화면에** 들어와야 한다(2026-09-06 사용자 지시).
|
||||
// 평형선·운반 블록은 노선 전체를 놓고 읽는 그림이라, 종단 그래프와의 X 맞춤(가로
|
||||
// 스크롤)과 보이는 구간만 보는 Y 창을 둘 다 버리고 스크롤러 안쪽 폭·전 구간 Y로 그린다.
|
||||
// 종단과 맞물릴 필요가 없으므로 반 칸 들여쓰기(originOffset)도 쓰지 않는다 — 그만큼
|
||||
// 좌우가 비어 곡선이 가운데로 쪼그라들었다(2026-09-06 사용자 보고: 「우측 여유 과다」).
|
||||
const fitAll = haulPlan !== null;
|
||||
fitted = fitAll;
|
||||
const widthPx = fitAll ? scroll.clientWidth || params.widthPx : params.widthPx;
|
||||
const chartAxis = fitAll
|
||||
? {
|
||||
...params.axis,
|
||||
padLeft: params.axis.axisX ?? params.axis.padLeft,
|
||||
padRight: MASS_FIT_PAD_RIGHT,
|
||||
viewRange: undefined,
|
||||
window: undefined,
|
||||
hideStations: true,
|
||||
}
|
||||
: {
|
||||
...params.axis,
|
||||
// 새 창으로 뗀 판은 넓어 메인 보이는 구간 세로면 잘림 — 노선 전체 세로(325-2).
|
||||
viewRange: overlay.classList.contains("is-popout") ? undefined : params.axis.viewRange,
|
||||
window: windowState,
|
||||
};
|
||||
// 늘 **곡선 전체가 한 화면에**(367-1 · 측점 연계 보기 없앰) — 스크롤러 안쪽 폭 · 전 구간 Y ·
|
||||
// 측점선 없음. 반 칸 들여쓰기(originOffset)도 안 씀(2026-09-06 「우측 여유 과다」).
|
||||
const widthPx = scroll.clientWidth || params.widthPx;
|
||||
const chartAxis = {
|
||||
...params.axis,
|
||||
padLeft: params.axis.axisX ?? params.axis.padLeft,
|
||||
padRight: MASS_FIT_PAD_RIGHT,
|
||||
viewRange: undefined,
|
||||
hideStations: true,
|
||||
};
|
||||
|
||||
let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
|
||||
const chart = createMassHaulChart(
|
||||
@@ -495,33 +428,17 @@ export function createRouteMassHaulPanel(
|
||||
wrap.append(chart);
|
||||
// 앵커는 첫 자식 — SVG 뒤면 흐름 위치가 밀려 스크롤 시 축이 안 보인다(B06과 같은 규칙).
|
||||
if (massAxis) wrap.prepend(buildStickyYAxis(massAxis, chartHeight));
|
||||
// 이미 종단과 맞물려 그린 곡선이면 제 자리 그대로 — 종단 값으로 되쓰면 그 사이 유토 쪽 휠이
|
||||
// 더 간 만큼 뒤로 튐(354-3). 처음 · 분배 맞춤 뒤 · 창 옮김 뒤만 종단 자리에서 시작한다.
|
||||
const keepLeft = !fitAll && syncedIn === scroll.ownerDocument ? scroll.scrollLeft : null;
|
||||
scroll.replaceChildren(wrap);
|
||||
// 전체 맞춤일 때는 스크롤할 것이 없으므로 왼쪽 끝에 둔다.
|
||||
if (fitAll) scroll.scrollLeft = 0;
|
||||
else if (keepLeft !== null) scroll.scrollLeft = keepLeft;
|
||||
else if (syncedMain) putScroll?.(scroll, syncedMain.scrollLeft);
|
||||
syncedIn = fitAll ? null : scroll.ownerDocument;
|
||||
// 스크롤할 것이 없으므로 왼쪽 끝에 둔다.
|
||||
scroll.scrollLeft = 0;
|
||||
|
||||
// 범례는 계열 · 켬이 바뀔 때만 — 스크롤 그리기는 그대로 둔다.
|
||||
if (!reuse || !legendLayer.firstChild)
|
||||
legendLayer.replaceChildren(
|
||||
createMassHaulLegend(series, visible, toggleSeries, () => {
|
||||
resetBalloonOffsets();
|
||||
onChanged();
|
||||
}),
|
||||
);
|
||||
drawnOnce = true;
|
||||
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다.
|
||||
scheduleMassHaulSettle(
|
||||
windowState,
|
||||
() => {
|
||||
if (lastParams) draw(lastParams, true);
|
||||
},
|
||||
overlay,
|
||||
legendLayer.replaceChildren(
|
||||
createMassHaulLegend(series, visible, toggleSeries, () => {
|
||||
resetBalloonOffsets();
|
||||
onChanged();
|
||||
}),
|
||||
);
|
||||
drawnOnce = true;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -531,8 +448,6 @@ export function createRouteMassHaulPanel(
|
||||
setContext(next) {
|
||||
context = next;
|
||||
},
|
||||
attachScrollSync,
|
||||
draw: (params) => draw(params),
|
||||
drawWindow,
|
||||
draw,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ export interface PanelToolsContext {
|
||||
base: () => AlignmentBase | null;
|
||||
alignment: () => ProfileAlignment | null;
|
||||
edits: () => AlignmentEdits;
|
||||
applyEdits: (next: AlignmentEdits) => void;
|
||||
/** false = 막힘(기울기 · 흙덮기). */
|
||||
applyEdits: (next: AlignmentEdits) => boolean;
|
||||
/** 고른 측점 id(없으면 null). */
|
||||
selectedStationId: () => string | null;
|
||||
/** 비정규(구조물) 측점 목록 — 좌우 이동 대상 판정에 쓴다. */
|
||||
@@ -157,10 +158,11 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
history.record();
|
||||
},
|
||||
canResetToSaved: ctx.canRestoreSaved,
|
||||
onUndo: () => inner.undo(),
|
||||
onRedo: () => inner.redo(),
|
||||
canUndo: () => inner.canUndo(),
|
||||
canRedo: () => inner.canRedo(),
|
||||
// 자유지정 동안 ↶ ↷ = 자유지정 안 한 걸음(367-3) — 큰 이력은 안 건드림.
|
||||
onUndo: () => (tools.mode() === "free" ? (free.undo(), freeView.redraw()) : inner.undo()),
|
||||
onRedo: () => (tools.mode() === "free" ? (free.redo(), freeView.redraw()) : inner.redo()),
|
||||
canUndo: () => (tools.mode() === "free" ? free.canUndo() : inner.canUndo()),
|
||||
canRedo: () => (tools.mode() === "free" ? free.canRedo() : inner.canRedo()),
|
||||
hasStraightRuns: () => {
|
||||
const alignment = ctx.alignment();
|
||||
return alignment ? hasStraightRuns(alignment) : false;
|
||||
@@ -327,6 +329,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
// 한 줄 되돌리기(346-4) — 평면 · 종단 · 횡단 · 관 · 구조물을 만진 차례 거꾸로.
|
||||
if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) {
|
||||
event.preventDefault();
|
||||
// 자유지정 동안은 자유지정 안만 — 되돌릴 것 없어도 큰 이력으로 안 감(367-3)
|
||||
if (tools.mode() === "free") {
|
||||
if (event.shiftKey || isCtrlKey(event, "y")) free.redo();
|
||||
else free.undo();
|
||||
freeView.redraw();
|
||||
return;
|
||||
}
|
||||
flushHold();
|
||||
if (event.shiftKey || isCtrlKey(event, "y")) history.redo();
|
||||
else history.undo();
|
||||
|
||||
@@ -71,7 +71,6 @@ export interface ProfileRenderContext {
|
||||
callbacks: RouteProfilePanelCallbacks | undefined;
|
||||
massHaul: {
|
||||
draw: (params: RouteMassHaulDrawParams) => void;
|
||||
drawWindow: (params: RouteMassHaulDrawParams) => void;
|
||||
};
|
||||
tableOverlay: {
|
||||
isOpen: () => boolean;
|
||||
@@ -362,8 +361,6 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
});
|
||||
// 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고
|
||||
// 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정).
|
||||
// 유토곡선 갱신기 — 아래에서 유토곡선을 그린 뒤 채워진다(그리기 순서상 여기서는 아직 없다).
|
||||
let massHaulUpdater: ((fromM: number, toM: number) => void) | null = null;
|
||||
ctx.setElevationWindowUpdater(() => {
|
||||
const from = Math.max(0, toChainage(body.scrollLeft));
|
||||
const to = Math.min(maxChainageM, toChainage(body.scrollLeft + body.clientWidth));
|
||||
@@ -374,7 +371,6 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
to,
|
||||
) ?? null,
|
||||
);
|
||||
massHaulUpdater?.(from, to);
|
||||
return applyElevationWindow(chartWrap, next);
|
||||
});
|
||||
|
||||
@@ -477,15 +473,5 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
onSelectStation: ctx.selectStation,
|
||||
};
|
||||
massHaul.draw(massHaulParams);
|
||||
// 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시
|
||||
// 그린다. 종단처럼 변환으로 옮기지 않는 이유: 곡선 위에 앉는 말풍선·EP 표·측점 점이
|
||||
// 세로로 늘어나면 안 되는 것들이라, 그것만 따로 옮기는 값이 곡선 자체를 다시 그리는
|
||||
// 값보다 크다(요소 수가 종단의 1/3). 열림 · 분배 맞춤 아닐 때만 · 한 틀 한 번 · 다시 셈 없음(354-3).
|
||||
massHaulUpdater = (fromM, toM) => {
|
||||
massHaul.drawWindow({
|
||||
...massHaulParams,
|
||||
axis: { ...massHaulParams.axis, viewRange: { fromM, toM } },
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
captureView,
|
||||
createViewMemory,
|
||||
fitCanvas,
|
||||
fitView,
|
||||
openingView,
|
||||
restoreSavedView,
|
||||
screenToMetric,
|
||||
@@ -92,6 +91,7 @@ import {
|
||||
import type { DesignPageHost } from "../A00_Common/design/design_page";
|
||||
import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft";
|
||||
import { bindRouteShift } from "./B05_Profile_UI_RouteEdit_Shift";
|
||||
import * as fit from "./logic/B05_Profile_RouteEdit_StationFit";
|
||||
import "./B05_Profile_UI_Style_RouteEdit.css";
|
||||
|
||||
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
|
||||
@@ -160,7 +160,7 @@ export async function mountRouteEditor(
|
||||
let apexLock: boolean[] = [];
|
||||
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
|
||||
let picked = -1;
|
||||
/** [측점 스냅](요구 224-2) — 보기 모드라 되돌리기 밖 · 열 때 서버 값 · [확인] 때 저장. */
|
||||
/** [측점 스냅](224-2 · 366-3) — 켬 = 곡선 MC 를 일반측점에 · 켬끔 자체는 되돌리기 밖 · [저장] 때 저장. */
|
||||
let stationSnap = false;
|
||||
let savedStationSnap = false;
|
||||
/** 편집 창 측점 한 곳 — 끔이면 null(간격마다) · 켬이면 호 안 하나를 곡선 중점으로.
|
||||
@@ -195,7 +195,7 @@ export async function mountRouteEditor(
|
||||
/** 등고선 자물쇠(353-2) — 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜. */
|
||||
const contourLock = createContourLock(
|
||||
canvas,
|
||||
() => contours && { layer: contours.layer, view, stepM: contourStepM(), rotation },
|
||||
() => contours && { ...contours, view, rotation, stepM: contourStepM(), picked: pickedContour },
|
||||
(index) => ((pickedContour = index), draw()),
|
||||
);
|
||||
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
|
||||
@@ -266,7 +266,7 @@ export async function mountRouteEditor(
|
||||
const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw());
|
||||
stationSnapButton.addEventListener("click", () => {
|
||||
setStationSnap(!stationSnap);
|
||||
void crossPreview.refresh(); // 떠 있는 횡단도 옮긴 측점 규칙으로.
|
||||
if (stationSnap && history) applyEdit("곡선 가운데(MC)를 일반측점에 맞췄습니다."); // 전체 · 한 걸음
|
||||
draw();
|
||||
});
|
||||
/** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */
|
||||
@@ -318,13 +318,6 @@ export async function mountRouteEditor(
|
||||
rotation.set(opening.rotationRad);
|
||||
viewReady = true;
|
||||
}
|
||||
// [맞춤] — 처음 그림과 같은 등고 영역 맞춤(회전 0 · 349-0). 셈은 `_View` 몫.
|
||||
overlay.querySelector('[data-act="fit"]')!.addEventListener("click", () => {
|
||||
if (!meta || view.width <= 0 || !planned.length) return;
|
||||
view = fitView(meta, view, planned, contours?.layer ?? null);
|
||||
rotation.set(0);
|
||||
draw();
|
||||
});
|
||||
|
||||
const toScreen = (vertex: Vertex): [number, number] =>
|
||||
meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0];
|
||||
@@ -423,6 +416,7 @@ export async function mountRouteEditor(
|
||||
violationCount: nodeInfo.filter((node) => node.violations.length).length,
|
||||
minRadiusM,
|
||||
fresh: nodeInfo.length === 0,
|
||||
offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, stationIntervalM) : 0,
|
||||
});
|
||||
|
||||
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
|
||||
@@ -453,8 +447,12 @@ export async function mountRouteEditor(
|
||||
const curveLabelBox = curveBar.label;
|
||||
const syncCurveBar = curveBar.sync;
|
||||
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
|
||||
function applyEdit(message: string, record = true): void {
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다.
|
||||
* 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤). */
|
||||
function applyEdit(message: string, record = true, fitting = true): void {
|
||||
const limits = { minRadiusM, limitRadiusM, limitArcM, intervalM: stationIntervalM };
|
||||
const from = fit.fitStart(history?.current?.(), snapshotNow());
|
||||
if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), limits, from);
|
||||
markEdited();
|
||||
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
|
||||
syncCurveBar();
|
||||
@@ -484,7 +482,7 @@ export async function mountRouteEditor(
|
||||
const locks = record ? routeLocksOf(planned, apexLock) : null;
|
||||
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot);
|
||||
if (locks) apexLock = lockColumn(planned, locks);
|
||||
applyEdit(message, record);
|
||||
applyEdit(message, record, false); // 되돌리기 · 추천 [적용] · 초안 되살림은 안 맞춤(366-3)
|
||||
}
|
||||
|
||||
const historyControls = bindHistoryControls({
|
||||
@@ -540,6 +538,7 @@ export async function mountRouteEditor(
|
||||
curveHint,
|
||||
contourHint,
|
||||
commit: () => {
|
||||
if (stationSnap) return applyEdit("곡선 가운데(MC)를 일반측점에 맞췄습니다."); // 놓을 때(366-3)
|
||||
history?.commit(snapshotNow());
|
||||
historyControls.sync();
|
||||
},
|
||||
@@ -684,7 +683,6 @@ export async function mountRouteEditor(
|
||||
useBackground(background);
|
||||
measure.clear();
|
||||
slope.refresh();
|
||||
void crossPreview.refresh();
|
||||
showStatus();
|
||||
draw();
|
||||
});
|
||||
|
||||
@@ -128,7 +128,6 @@ export function routeSaveSummary(saved: RouteReplanResponse, outside = 0): strin
|
||||
`구조물 옮김 ${saved.structures?.moved ?? 0}`,
|
||||
`멀어짐 ${saved.structures?.far ?? 0}`,
|
||||
];
|
||||
if (saved.pipes?.concave) parts.splice(2, 0, `오목 보충 관 ${saved.pipes.concave}`);
|
||||
const dropped = (saved.cross_dropped ?? 0) + outside;
|
||||
if (dropped) parts.push(`노선 밖 고침 ${dropped} 건`);
|
||||
if (saved.drainage_stale) parts.push("배수유역 낡음");
|
||||
|
||||
@@ -11,6 +11,7 @@ import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
|
||||
* **배치**(352-1) — 지도가 칸 전체 · 머리 줄 · 테두리 없음. 도구는 3D 와 같은 떠 있는 유리 단추
|
||||
* (지도 왼쪽 위 · ☰ 비킴), [?] 조작 설명은 도구줄 아래 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
|
||||
* 아래. 횡단 두 판은 지도 오른쪽을 덮는 판(아래 종단 판이 그 위).
|
||||
* 도구줄 차례(366-2) — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 절/성토선 · 거리 재기 | 측점 스냅 · 분석 지형.
|
||||
*
|
||||
* 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 ·
|
||||
* 닫기 단추는 걷었다 · [취소] · [확인] · [노선 반영] · [저장 전으로]는 없앰(346-3 · 352-2 — 노선은 페이지
|
||||
@@ -60,8 +61,31 @@ export function createRouteEditChrome(
|
||||
<div class="b05-routeedit__canvas-wrap" aria-label="계획노선 편집">
|
||||
<canvas class="b05-routeedit__canvas"></canvas>
|
||||
<div class="b05-route__view-controls b05-routeedit__tools">
|
||||
<button type="button" class="ui-btn ui-btn--glass b05-routeedit__hint-toggle"
|
||||
data-act="hint-toggle" aria-expanded="false" title="조작 도움말 펼치기 · 접기">?</button>
|
||||
<i class="b05-route__view-separator" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="undo"
|
||||
title="되돌리기 (Ctrl+Z)" aria-label="되돌리기" disabled>↶</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="redo"
|
||||
title="다시하기 (Ctrl+Y)" aria-label="다시하기" disabled>↷</button>
|
||||
<i class="b05-route__view-separator" aria-hidden="true"></i>
|
||||
<span data-slot="tools"></span>
|
||||
<i class="b05-route__view-separator" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-ccw"
|
||||
title="반시계로 돌리기" aria-label="반시계로 돌리기">${HALF_TURN_ICON.ccw}</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-cw"
|
||||
title="시계로 돌리기" aria-label="시계로 돌리기">${HALF_TURN_ICON.cw}</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="contour-minor"
|
||||
aria-pressed="false" disabled
|
||||
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="footprint"
|
||||
title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">절/성토선</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="measure"
|
||||
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)">거리 재기</button>
|
||||
<i class="b05-route__view-separator" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="station-snap"
|
||||
aria-pressed="false"
|
||||
title="켜면 곡선 가운데(MC)를 가까운 일반측점에 맞춥니다 — 놓을 때 고친 자리부터 교각점을 간격 절반 안에서 밂 · 고정 교각점은 그대로 · 못 맞춘 곡선은 상태줄 「측점 밖 곡선」 · [저장] 때 저장">측점 스냅</button>
|
||||
<label class="b05-routeedit__terrain" hidden
|
||||
title="노선 편집 분석(등고선 · 지반고 · 기울기 · 횡단 미리보기 · 추천)에 쓸 지형 — 확정 지표면 · 종횡단은 안 바뀝니다">분석 지형
|
||||
<select class="ui-select ui-select--sm" data-act="terrain">
|
||||
@@ -69,27 +93,8 @@ export function createRouteEditChrome(
|
||||
<option value="sheet">수치지형도</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="fit"
|
||||
title="등고 영역(등고선 범위 · 노선)을 평면 가운데 알맞게 맞춥니다 (회전은 풂)">맞춤</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="contour-minor"
|
||||
aria-pressed="false" disabled
|
||||
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="measure"
|
||||
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)">거리 재기</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="station-snap"
|
||||
aria-pressed="false"
|
||||
title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [저장] 때 저장">측점 스냅</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="footprint"
|
||||
title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">절/성토선</button>
|
||||
<i class="b05-route__view-separator" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="undo"
|
||||
title="되돌리기 (Ctrl+Z)" aria-label="되돌리기" disabled>↶</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="redo"
|
||||
title="다시하기 (Ctrl+Y)" aria-label="다시하기" disabled>↷</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__hint">
|
||||
<button type="button" class="ui-btn ui-btn--glass b05-routeedit__hint-toggle"
|
||||
data-act="hint-toggle" aria-expanded="false" title="조작 도움말 펼치기 · 접기">?</button>
|
||||
<div class="b05-routeedit__hint-body">
|
||||
<span>노드 끌기 = 옮기기</span><span>노드 클릭 = R 라벨</span>
|
||||
<span>선 두 번 클릭 = 노드 추가</span><span>노드 오른쪽 클릭 = 삭제</span>
|
||||
@@ -97,12 +102,6 @@ export function createRouteEditChrome(
|
||||
<span>가운데(휠) 버튼 끌기 = 지도 이동</span><span>휠 = 확대</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b05-routeedit__spin">
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-ccw"
|
||||
title="반시계로 돌리기" aria-label="반시계로 돌리기">${HALF_TURN_ICON.ccw}</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-cw"
|
||||
title="시계로 돌리기" aria-label="시계로 돌리기">${HALF_TURN_ICON.cw}</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__measure" hidden>
|
||||
<span class="b05-routeedit__measure-text"></span>
|
||||
<span data-slot="measure-close"></span>
|
||||
@@ -142,7 +141,7 @@ export function createRouteEditChrome(
|
||||
|
||||
// 도움말 = 기본 접힘 · [?] 한 번에 펼침(345-5 덧 3).
|
||||
const hint = overlay.querySelector<HTMLElement>(".b05-routeedit__hint")!;
|
||||
const hintToggle = hint.querySelector<HTMLButtonElement>('[data-act="hint-toggle"]')!;
|
||||
const hintToggle = overlay.querySelector<HTMLButtonElement>('[data-act="hint-toggle"]')!;
|
||||
const setHint = (open: boolean): void => {
|
||||
hintOpen = open;
|
||||
hint.classList.toggle("is-open", open);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_ContourLock.ts
|
||||
* 평면 등고선 자물쇠(353-2) — 등고선 위를 지나거나 고른 가닥 위에 뜨는 작은 단추.
|
||||
* 평면 등고선 자물쇠(353-2) — 가닥을 고른 때만 누른 자리 옆에 뜨는 작은 단추(366-1 · 지나기만 = 안 뜸).
|
||||
*
|
||||
* 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 다시 누르면 풂.
|
||||
* 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 잠근 높이 가닥을 다시
|
||||
* 누르면 풀기 단추 · 다음 클릭 · 휠 · 떠남(0.7 s)에 숨김.
|
||||
* 높이값으로 쥐어 지형을 바꿔도 같은 높이를 다시 짚음 · 페이지 동안만(저장 안 함).
|
||||
* 규칙은 `logic/B05_Profile_ContourLock` · 호버 판정은 집기와 같은 `hitPreparedLayer`.
|
||||
* 규칙은 `logic/B05_Profile_ContourLock` · 누름 판정은 집기와 같은 `hitPreparedLayer`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickPx } from "../A00_Common/touch_bridge";
|
||||
@@ -22,8 +23,10 @@ import {
|
||||
import "./B05_Profile_UI_Style_RouteEdit_ContourLock.css";
|
||||
|
||||
/** 집기(`_Pointer` CONTOUR_HIT_PX)와 같은 거리(px). */
|
||||
const HOVER_HIT_PX = 6;
|
||||
/** 커서가 선을 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */
|
||||
const HIT_PX = 6;
|
||||
/** 누름 ~ 뗌 사이 이만큼(px) 넘게 움직이면 끌기 — 고르기 아님. */
|
||||
const MOVE_PX = 4;
|
||||
/** 커서가 지도를 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */
|
||||
const HIDE_DELAY_MS = 700;
|
||||
/** 단추는 커서 오른쪽 위로 이만큼(px) — 선과 커서를 안 가림. */
|
||||
const OFFSET_PX = 12;
|
||||
@@ -33,12 +36,14 @@ const LOCK_SVG = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="t
|
||||
<path class="b05-contourlock__shackle" d="M5 7V5a3 3 0 0 1 6 0v2" fill="none"
|
||||
stroke="currentColor" stroke-width="1.6"/></svg>`;
|
||||
|
||||
/** 호버 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */
|
||||
/** 누름 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */
|
||||
export interface ContourLockScene {
|
||||
layer: PreparedLayer;
|
||||
view: ViewState;
|
||||
/** 지금 화면 등고선 간격 — 그리지 않은 줄은 호버도 안 됨. */
|
||||
/** 지금 화면 등고선 간격 — 그리지 않은 줄은 안 잡힘. */
|
||||
stepM: number;
|
||||
/** 고른 가닥 번호(없으면 -1) — 안 잠김이면 이 가닥을 누를 때만 뜸. */
|
||||
picked: number;
|
||||
rotation: { unrotate: (px: number, py: number) => [number, number] };
|
||||
}
|
||||
|
||||
@@ -68,8 +73,8 @@ export function createContourLock(
|
||||
/** 단추가 가리키는 가닥 — 누르면 이 가닥을 잠금. */
|
||||
let target = -1;
|
||||
let hideTimer = 0;
|
||||
let frame = 0;
|
||||
let last: PointerEvent | null = null;
|
||||
let downX = 0;
|
||||
let downY = 0;
|
||||
|
||||
const levelOf = (index: number): number | null =>
|
||||
index >= 0 ? (scene()?.layer.features[index]?.labelValue ?? null) : null;
|
||||
@@ -96,17 +101,24 @@ export function createContourLock(
|
||||
hideTimer = 0;
|
||||
};
|
||||
|
||||
const hover = (event: PointerEvent): void => {
|
||||
const hide = (): void => {
|
||||
keep();
|
||||
button.hidden = true;
|
||||
};
|
||||
|
||||
/** 누름을 뗀 자리 — 고른 가닥(잠김이면 잠근 높이 가닥)일 때만 그 옆에 띄움. */
|
||||
const showAt = (event: PointerEvent): void => {
|
||||
const now = scene();
|
||||
// 단추를 누른 채(노드 · 손잡이 끌기 · 화면 이동) 지나가면 안 봄
|
||||
if (!now || event.buttons !== 0) return hideLater();
|
||||
// 끌기 · 화면 이동 · 재기(Shift)는 고르기가 아님
|
||||
const moved = Math.hypot(event.clientX - downX, event.clientY - downY) > MOVE_PX;
|
||||
if (!now || event.button !== 0 || event.shiftKey || moved) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const [px, py] = now.rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||||
const hitPx = pickPx(HOVER_HIT_PX);
|
||||
const hitPx = pickPx(HIT_PX);
|
||||
const index = hitPreparedLayer(now.layer, now.view, px, py, hitPx, now.stepM);
|
||||
const state = lockButtonState(levelOf(index), lockedLevel);
|
||||
if (!state.visible) return hideLater();
|
||||
keep();
|
||||
if (!lockButtonState(levelOf(index), lockedLevel, index >= 0 && index === now.picked).visible) {
|
||||
return;
|
||||
}
|
||||
target = index;
|
||||
const origin = wrap.getBoundingClientRect();
|
||||
button.style.left = `${Math.round(event.clientX - origin.left + OFFSET_PX)}px`;
|
||||
@@ -115,17 +127,14 @@ export function createContourLock(
|
||||
paint();
|
||||
};
|
||||
|
||||
// 한 프레임에 한 번만 잼 — 도엽 등고선은 수천 가닥.
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
last = event;
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
if (last) hover(last);
|
||||
});
|
||||
// 다음 클릭 · 휠에 숨김 — 집기(`_Pointer`)보다 먼저 달려 있어 고른 뒤 pointerup 이 다시 띄움.
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
downX = event.clientX;
|
||||
downY = event.clientY;
|
||||
hide();
|
||||
});
|
||||
// 고른 직후(누름을 뗀 자리)에도 띄움 — 터치는 호버가 없음.
|
||||
canvas.addEventListener("pointerup", hover);
|
||||
canvas.addEventListener("pointerup", showAt);
|
||||
canvas.addEventListener("wheel", hide, { passive: true });
|
||||
canvas.addEventListener("pointerleave", hideLater);
|
||||
button.addEventListener("pointerenter", keep);
|
||||
button.addEventListener("pointerleave", hideLater);
|
||||
|
||||
@@ -2,22 +2,24 @@
|
||||
* B05_Profile_UI_RouteEdit_Cross.ts
|
||||
* 계획노선 편집 중 **측점 횡단** — 메인 창 오른쪽 세로 칸에 **붙박이 두 판**(계획서 0-9 ⑱⑲).
|
||||
*
|
||||
* · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점을 셈해 여기에 낸다.
|
||||
* · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점 횡단을 여기에 낸다.
|
||||
* · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간**
|
||||
* 위 판의 것이 이리로 내려오고 새로 셈한 것이 위로 올라간다 — 전후를 나란히 본다.
|
||||
* 위 판의 것이 이리로 내려오고 새 횡단이 위로 올라간다 — 전후를 나란히 본다.
|
||||
*
|
||||
* 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 ·
|
||||
* 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 계획 횡단선 ·
|
||||
* 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다.
|
||||
*
|
||||
* ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의
|
||||
* 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다.
|
||||
*
|
||||
* 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은
|
||||
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
|
||||
* 값 = 계산 묶음 그 측점(365-3) — 측점 · 지반 표본 = ground 단계 · 계획고 · 설계선 = 종단 → cross 단계
|
||||
* (B06 카드 · 새 노선 상세와 같은 함수 `stationSection`) · 노선 · 계획고가 바뀔 때마다(cross 차례) 다시 그림 ·
|
||||
* 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
|
||||
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
||||
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
||||
import { pagePipeline } from "../A00_Common/design/design_pipeline_page";
|
||||
import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline";
|
||||
import { useDesignStore } from "../A00_Common/design/design_store";
|
||||
import { stationSection } from "./logic/B05_Profile_Pipeline_Detail";
|
||||
import {
|
||||
buildCrossSvg,
|
||||
CROSS_PLOT_PAD,
|
||||
@@ -30,7 +32,7 @@ export interface CrossPreviewParams {
|
||||
projectId: string;
|
||||
/** 두 판이 들어앉을 오른쪽 세로 칸. */
|
||||
side: HTMLElement;
|
||||
/** 지금 편집값 — 셈을 부르는 순간에 읽는다. */
|
||||
/** 지금 편집값 — 측점 표기 간격만 씀(횡단은 계산 묶음 몫 · 365-3). */
|
||||
request: () => {
|
||||
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
|
||||
min_radius_m: number;
|
||||
@@ -51,7 +53,7 @@ export interface CrossWatch {
|
||||
export interface CrossPreviewWindow {
|
||||
/** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */
|
||||
open: (chainageM: number) => Promise<void>;
|
||||
/** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
|
||||
/** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */
|
||||
refresh: () => Promise<void>;
|
||||
/** 위 판에 떠 있는 측점과 반폭. 떠 있지 않으면 null. */
|
||||
watched: () => CrossWatch | null;
|
||||
@@ -158,54 +160,96 @@ function createPane(title: string, empty: string): CrossPane {
|
||||
};
|
||||
}
|
||||
|
||||
/** 묶음 출력에서 그 누가거리에 가장 가까운 측점 횡단 — 묶음이 아직 안 셌으면 null. */
|
||||
export function pipelineCrossAt(
|
||||
pipe: DesignPipeline | null,
|
||||
chainageM: number,
|
||||
intervalM: number,
|
||||
halfWidthM: number | undefined,
|
||||
): CrossPreviewResponse | null {
|
||||
const stations = pipe?.output("ground")?.stations ?? [];
|
||||
if (!pipe || !stations.length) return null;
|
||||
const station = stations.reduce((best, next) =>
|
||||
Math.abs(next.chainageM - chainageM) < Math.abs(best.chainageM - chainageM) ? next : best,
|
||||
);
|
||||
const section = stationSection(station, pipe.output("cross"), {}, intervalM);
|
||||
const reach = Math.max(0, ...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)));
|
||||
return {
|
||||
status: "success",
|
||||
chainage_m: section.chainage_m,
|
||||
label: section.label ?? null,
|
||||
uphill_side: section.uphill_side ?? null,
|
||||
plan_radius_m: section.plan_radius_m ?? null,
|
||||
curve_widening_m: section.curve_widening_m ?? null,
|
||||
cross_half_width_m: halfWidthM ?? reach,
|
||||
samples: section.samples,
|
||||
design: (section.design as CrossPreviewResponse["design"] | undefined) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
|
||||
const current = createPane("횡단", "측점 눈금을 누르면 그 측점 횡단이 뜹니다.");
|
||||
const previous = createPane("이전 횡단", "노선을 고치면 고치기 전 횡단이 여기 남습니다.");
|
||||
params.side.append(current.root, previous.root);
|
||||
const interval = (): number => params.request().station_interval_m;
|
||||
|
||||
/** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */
|
||||
let watching: number | null = null;
|
||||
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료. */
|
||||
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */
|
||||
let shown: CrossPreviewResponse | null = null;
|
||||
/** 지금 부른 셈 — 늦게 온 응답을 새 자리에 적지 않으려고 든다. */
|
||||
let ticket = 0;
|
||||
let drawnRoute: unknown = null;
|
||||
|
||||
async function load(chainageM: number, keepPrevious: boolean): Promise<void> {
|
||||
const mine = ++ticket;
|
||||
const request = params.request();
|
||||
if (keepPrevious && shown) previous.show(shown, request.station_interval_m);
|
||||
current.wait("읽는 중…");
|
||||
try {
|
||||
const preview = await fetchCrossPreview(params.projectId, {
|
||||
...request,
|
||||
chainage_m: chainageM,
|
||||
cross_half_width_m: appliedHalfWidth(params.projectId),
|
||||
});
|
||||
if (mine !== ticket) return; // 그 사이 다른 측점을 눌렀다.
|
||||
shown = preview;
|
||||
current.show(preview, request.station_interval_m);
|
||||
} catch (error) {
|
||||
if (mine !== ticket) return;
|
||||
shown = null;
|
||||
current.wait(error instanceof Error ? error.message : "횡단을 읽지 못했습니다.");
|
||||
}
|
||||
function redraw(): void {
|
||||
if (watching === null) return;
|
||||
const route = pipe?.input("route") ?? null;
|
||||
if (route !== drawnRoute && shown) previous.show(shown, interval());
|
||||
drawnRoute = route;
|
||||
shown = pipelineCrossAt(pipe, watching, interval(), appliedHalfWidth(params.projectId));
|
||||
if (shown) current.show(shown, interval());
|
||||
else current.wait("계산 중…");
|
||||
params.onShown?.();
|
||||
}
|
||||
|
||||
// 묶음 cross 차례마다 다시 그림(노선 · 계획고) — 묶음은 페이지가 세우므로 차례 번호가 날 때 붙음
|
||||
let pipe: DesignPipeline | null = null;
|
||||
let stopPipe = (): void => undefined;
|
||||
let connected = false;
|
||||
const attach = (): void => {
|
||||
if (current.root.isConnected) connected = true;
|
||||
else if (connected) return stop(); // 편집 창이 닫혔다
|
||||
const now = pagePipeline(params.projectId);
|
||||
if (now === pipe) return;
|
||||
stopPipe();
|
||||
pipe = now;
|
||||
stopPipe =
|
||||
now?.subscribe((event) => {
|
||||
if (event.kind === "stage" && event.stage === "cross") redraw();
|
||||
}, "평면 횡단 판") ?? ((): void => undefined);
|
||||
};
|
||||
const stopStore = useDesignStore(params.projectId, null).subscribe("generation", attach);
|
||||
const stop = (): void => {
|
||||
stopStore();
|
||||
stopPipe();
|
||||
};
|
||||
attach();
|
||||
|
||||
return {
|
||||
async open(chainageM) {
|
||||
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
|
||||
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
|
||||
previous.show(null, params.request().station_interval_m);
|
||||
previous.show(null, interval());
|
||||
watching = chainageM;
|
||||
await load(chainageM, false);
|
||||
attach();
|
||||
drawnRoute = pipe?.input("route") ?? null;
|
||||
shown = null;
|
||||
redraw();
|
||||
},
|
||||
async refresh() {
|
||||
if (watching === null) return;
|
||||
await load(watching, true);
|
||||
attach();
|
||||
redraw();
|
||||
},
|
||||
// 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다.
|
||||
// 반폭은 마지막으로 셈한 횡단이 덮은 폭 — 좌·우 같고, 원지반 선 범위와 한 값이다.
|
||||
// 반폭 = 적용해 둔 「횡단 반폭」 · 없으면 그린 원지반 선 범위 — 좌·우 같음.
|
||||
watched: () =>
|
||||
watching === null || !shown
|
||||
? null
|
||||
|
||||
@@ -88,8 +88,9 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 접음 = 판 높이 0 · 손잡이만 판 위 경계에 매달림(배수유역 접힘 꼴 · 367-2). */
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) {
|
||||
height: auto;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head) {
|
||||
@@ -97,10 +98,13 @@
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head {
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 2px var(--spacing-8);
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
}
|
||||
|
||||
/* [창 분할]은 펼친 때만(367-2) — 새 창으로 뗀 [창 결합]은 그대로. */
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) .b05-subpanel__popout {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 종단 패널을 접으면 판도 함께 숨는다(배수유역도와 같음). */
|
||||
@@ -129,14 +133,12 @@
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 곡선 가로 스크롤러 — 종단 스크롤러와 scrollLeft를 동기화한다(JS). 세로는 잘라낸다.
|
||||
스크롤바는 항상 표시(scroll) — auto면 생겼다 없어질 때 곡선 높이가 널뛰고,
|
||||
clientHeight 기반 높이 계산도 함께 흔들린다. */
|
||||
/* 곡선 칸 — 늘 전체 맞춤이라 스크롤 없음(367-1). */
|
||||
.b05-profile__masshaul-scroll {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-x: scroll;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
--b05-side-open: 460px;
|
||||
}
|
||||
|
||||
/* 도구줄 — 3D 도구줄 규격 그대로 · 오른쪽 회전 단추 · 횡단 판을 비켜 줄바꿈. */
|
||||
/* 도구줄 — 3D 도구줄 규격 그대로 · 회전 단추도 이 줄(366-2) · 넘치면 줄바꿈. */
|
||||
.b05-routeedit__tools {
|
||||
right: calc(var(--spacing-12) + 112px); /* 오른쪽 위 회전 단추 자리 */
|
||||
right: var(--spacing-12);
|
||||
flex-wrap: wrap;
|
||||
pointer-events: none; /* 빈 자리는 지도 조작을 통과 */
|
||||
}
|
||||
|
||||
@@ -352,8 +352,9 @@
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
/* 지나온 선 · 미리 보기 · 점 = 녹색(367-4) — 범위 띠 · 경계는 보라 그대로. */
|
||||
.b05-profile-freemark__line {
|
||||
stroke: var(--color-primary);
|
||||
stroke: var(--color-success);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 6 4;
|
||||
}
|
||||
@@ -368,7 +369,7 @@
|
||||
|
||||
.b05-profile-freemark__dot {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-primary);
|
||||
stroke: var(--color-success);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,13 +21,15 @@ export function featureOfLevel(levels: ContourLevels, level: number | null): num
|
||||
return levels.findIndex((value) => value === level);
|
||||
}
|
||||
|
||||
/** 커서 아래 가닥 높이 · 잠근 높이 → 단추 — 안 잠김 = 높이 있는 가닥 위에서 · 잠김 = 그 높이 위에서만. */
|
||||
/** 누른 가닥 높이 · 잠근 높이 · 고른 가닥인지 → 단추(366-1) — 안 잠김 = 높이 있는 고른 가닥을
|
||||
* 누를 때만 · 잠김 = 잠근 높이의 아무 가닥을 누를 때(풀기). 지나가기만 하면 안 뜸. */
|
||||
export function lockButtonState(
|
||||
hoverLevel: number | null,
|
||||
hitLevel: number | null,
|
||||
lockedLevel: number | null,
|
||||
selected: boolean,
|
||||
): LockButtonState {
|
||||
if (lockedLevel !== null) return { visible: hoverLevel === lockedLevel, pressed: true };
|
||||
return { visible: hoverLevel !== null, pressed: false };
|
||||
if (lockedLevel !== null) return { visible: hitLevel === lockedLevel, pressed: true };
|
||||
return { visible: hitLevel !== null && selected, pressed: false };
|
||||
}
|
||||
|
||||
/** 단추 누름 — 잠김이면 풂(null) · 아니면 그 가닥 높이로 잠금. */
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Drainage_Suggest.ts
|
||||
* 배수유역 제안(347-3) — 관경 · 시설 종류 · 관 자리 후보를 모으고 [적용] 때 관 초안에 한 번에 얹음.
|
||||
* 배수유역 제안(347-3) — 관경 · 시설 종류 · 관 자리 후보를 모으고 [적용] 때 관경 · 시설만 관 초안에 얹음.
|
||||
* 순수 함수만(화면 = `B05_Profile_UI_Drainage_Suggest.ts`).
|
||||
*
|
||||
* - 관경 = 유역 추천이 배관이고 추천 관경이 지금 관경과 다를 때(「Ø→Ø 배관 제안」).
|
||||
* - 시설 = 유역 추천이 BOX암거 · 세월교이고 지금 관이 그 종류가 아닐 때.
|
||||
* - 관 자리 = 서버 `suggest_pipe_positions`(간격 보충 규칙 · 기존 관에서 먼 곳).
|
||||
* - 관 자리 = 서버 `suggest_pipe_positions`(간격 보충 규칙 · 기존 관에서 먼 곳) — 보이기만 · 더하기는
|
||||
* [자동 제안] ②(369 · 서버가 새 관에 등록부 기본값).
|
||||
* - 자동 바꿈 없음 — 모으기만 하고 바꾸는 것은 `applySuggestions`([적용])뿐.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -60,7 +61,7 @@ export function collectSuggestions(
|
||||
return found.sort((a, b) => a.chainageM - b.chainageM);
|
||||
}
|
||||
|
||||
/** [적용] — 제안을 관 초안에 한 번에(관 더함 · 관경 · 시설 바꿈). 입력 목록은 안 고침. */
|
||||
/** [적용] — 제안을 관 초안에 한 번에(관경 · 시설 바꿈 · 관 자리는 안 더함 · 369). 입력 목록은 안 고침. */
|
||||
export function applySuggestions<P extends PipeLike>(
|
||||
pipes: ReadonlyArray<P>,
|
||||
suggestions: ReadonlyArray<Suggestion>,
|
||||
@@ -69,15 +70,11 @@ export function applySuggestions<P extends PipeLike>(
|
||||
(pipe) => ({ ...pipe, options: pipe.options && { ...pipe.options } }) as P,
|
||||
);
|
||||
suggestions.forEach((item) => {
|
||||
if (item.kind === "position") {
|
||||
if (!next.some((pipe) => near(pipe.chainage_m, item.chainageM)))
|
||||
next.push({ chainage_m: item.chainageM, source: "spacing" } as P);
|
||||
return;
|
||||
}
|
||||
if (item.kind === "position") return;
|
||||
const pipe = next.find((candidate) => near(candidate.chainage_m, item.chainageM));
|
||||
if (!pipe) return;
|
||||
if (item.kind === "facility") pipe.facility = item.to;
|
||||
else pipe.options = { ...(pipe.options ?? {}), pipe_diameter_mm: item.to };
|
||||
});
|
||||
return next.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -102,6 +102,80 @@ function atChainage(own: SectionSpecs, chainageM: number): SectionSpecs {
|
||||
return out as SectionSpecs;
|
||||
}
|
||||
|
||||
/** 저장 상세 → 묶음 cross 출력(365-4 · 저장 노선 = 다시 셈 창구 한 벌이 고친 설계 그대로) — 측점이 상세와
|
||||
* 다르면(갈아끼움 전) null. */
|
||||
export function detailCross(
|
||||
detail: SectionDetailResponse,
|
||||
stations: ReadonlyArray<PipelineStation>,
|
||||
): CrossOutput | null {
|
||||
const sections = detail.cross_sections;
|
||||
const same =
|
||||
sections.length === stations.length &&
|
||||
stations.every((station, i) => Math.abs(station.chainageM - sections[i].chainage_m) < 1e-3);
|
||||
if (!same) return null;
|
||||
const byKey: CrossOutput["byKey"] = {};
|
||||
stations.forEach((station, i) => {
|
||||
const { design, samples } = sections[i];
|
||||
if (!design) return;
|
||||
byKey[station.key] = {
|
||||
key: station.key,
|
||||
chainageM: station.chainageM,
|
||||
cutAreaM2: design.cut_area_m2,
|
||||
fillAreaM2: design.fill_area_m2,
|
||||
detail: design,
|
||||
...(samples ? { samples } : {}),
|
||||
} as CrossOutput["byKey"][string];
|
||||
});
|
||||
return { byKey };
|
||||
}
|
||||
|
||||
/** 묶음 측점 하나 → 횡단(머리 · 지반 표본 · 설계) — 새 노선 상세 · B05 오른쪽 횡단 판(365-3)이 같은 값. */
|
||||
export function stationSection(
|
||||
station: PipelineStation,
|
||||
cross: CrossOutput | null,
|
||||
own: SectionSpecs,
|
||||
intervalM: number,
|
||||
): CrossSection {
|
||||
const center = station.ground?.centerZ ?? null;
|
||||
const head: SectionStation = {
|
||||
station_id: stationIdOf(station.chainageM),
|
||||
chainage_m: station.chainageM,
|
||||
label: stationLabel(station.chainageM, intervalM),
|
||||
kind: kindOf(station),
|
||||
...(own.structure ? { structure: own.structure } : {}),
|
||||
center_z: center,
|
||||
azimuth_deg: station.azimuthDeg,
|
||||
plan_radius_m: station.planRadiusM,
|
||||
curve_outer_side: station.curveOuterSide,
|
||||
curve_widening_m: station.curveWideningM,
|
||||
center_x: station.x,
|
||||
center_y: station.y,
|
||||
uphill_side: uphillSide(station),
|
||||
frame: { left_xy: station.left },
|
||||
};
|
||||
const sample = (offset: number, z: number | null) => ({
|
||||
offset_m: offset,
|
||||
elevation_m: z,
|
||||
valid: z !== null && Number.isFinite(z),
|
||||
});
|
||||
const samples = [
|
||||
...[...(station.ground?.right ?? [])].reverse().map((s) => sample(-s.offsetM, s.z)),
|
||||
sample(0, center),
|
||||
...(station.ground?.left ?? []).map((s) => sample(s.offsetM, s.z)),
|
||||
];
|
||||
const entry = cross?.byKey[station.key] as { detail?: unknown; samples?: unknown[] } | undefined;
|
||||
const design = entry?.detail as CrossSection["design"] | undefined;
|
||||
// 설계는 사본 — 구조물 면적 얹기(`applyStructureAreaRows`)가 cross 단계 기억을 안 고침(349-0j)
|
||||
return {
|
||||
...head,
|
||||
...own,
|
||||
// 넓힌 표본(서버 [저장]도 넓힌 표본을 상세에 남김 — 구조물 면적이 그 지반으로 닫힘 · 349-0n)
|
||||
samples: (entry?.samples as CrossSection["samples"] | undefined) ?? samples,
|
||||
...(design ? { design: { ...design } } : {}),
|
||||
preview: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름 · 계획선 묶음)는 그대로 · 측점 · 지반 · 횡단은 새 노선. */
|
||||
export function draftSectionDetail(
|
||||
saved: SectionDetailResponse,
|
||||
@@ -111,48 +185,14 @@ export function draftSectionDetail(
|
||||
specs: ReadonlyMap<string, SectionSpecs>,
|
||||
intervalM: number,
|
||||
): SectionDetailResponse {
|
||||
const sections: CrossSection[] = ground.stations.map((station) => {
|
||||
const own = atChainage(specs.get(station.key) ?? {}, station.chainageM);
|
||||
const center = station.ground?.centerZ ?? null;
|
||||
const head: SectionStation = {
|
||||
station_id: stationIdOf(station.chainageM),
|
||||
chainage_m: station.chainageM,
|
||||
label: stationLabel(station.chainageM, intervalM),
|
||||
kind: kindOf(station),
|
||||
...(own.structure ? { structure: own.structure } : {}),
|
||||
center_z: center,
|
||||
azimuth_deg: station.azimuthDeg,
|
||||
plan_radius_m: station.planRadiusM,
|
||||
curve_outer_side: station.curveOuterSide,
|
||||
curve_widening_m: station.curveWideningM,
|
||||
center_x: station.x,
|
||||
center_y: station.y,
|
||||
uphill_side: uphillSide(station),
|
||||
frame: { left_xy: station.left },
|
||||
};
|
||||
const sample = (offset: number, z: number | null) => ({
|
||||
offset_m: offset,
|
||||
elevation_m: z,
|
||||
valid: z !== null && Number.isFinite(z),
|
||||
});
|
||||
const samples = [
|
||||
...[...(station.ground?.right ?? [])].reverse().map((s) => sample(-s.offsetM, s.z)),
|
||||
sample(0, center),
|
||||
...(station.ground?.left ?? []).map((s) => sample(s.offsetM, s.z)),
|
||||
];
|
||||
const entry = cross?.byKey[station.key] as
|
||||
{ detail?: unknown; samples?: unknown[] } | undefined;
|
||||
const design = entry?.detail as CrossSection["design"] | undefined;
|
||||
// 설계는 사본 — 구조물 면적 얹기(`applyStructureAreaRows`)가 cross 단계 기억을 안 고침(349-0j)
|
||||
return {
|
||||
...head,
|
||||
...own,
|
||||
// 넓힌 표본(서버 [저장]도 넓힌 표본을 상세에 남김 — 구조물 면적이 그 지반으로 닫힘 · 349-0n)
|
||||
samples: (entry?.samples as CrossSection["samples"] | undefined) ?? samples,
|
||||
...(design ? { design: { ...design } } : {}),
|
||||
preview: true,
|
||||
};
|
||||
});
|
||||
const sections: CrossSection[] = ground.stations.map((station) =>
|
||||
stationSection(
|
||||
station,
|
||||
cross,
|
||||
atChainage(specs.get(station.key) ?? {}, station.chainageM),
|
||||
intervalM,
|
||||
),
|
||||
);
|
||||
const { chainageM, z } = ground.longitudinal;
|
||||
return {
|
||||
longitudinal: {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts
|
||||
* 측점 스냅 켬 = 곡선 가운데(MC)를 일반측점에(366-3 · 요구 240-11) — 손 편집 평면.
|
||||
*
|
||||
* 추천 노선 맞춤(`B05_Profile_Engine_RouteInitial_Station.py` `_try` · `_solve`)을 줄인 꼴:
|
||||
* · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격/2 안에서
|
||||
* 밀어 MC(BC · EC 누가거리 가운데)를 가까운 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠.
|
||||
* · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로.
|
||||
* · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음.
|
||||
* 파이썬 짝 아님 — [저장] 때 서버가 저장된 IP 로 선을 다시 짬(`buildEditedPolyline` 짝)이라 MC 그대로.
|
||||
* 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `B05_Profile_UI_RouteEdit`.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
buildEditedPolyline,
|
||||
type EditedCurve,
|
||||
type EditedPolyline,
|
||||
type Vertex,
|
||||
} from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import {
|
||||
applyArcLocks,
|
||||
applyCurveLimits,
|
||||
curveShortfalls,
|
||||
shortfallCrossed,
|
||||
type NodeColumns,
|
||||
} from "./B05_Profile_UI_RouteEdit_Edits";
|
||||
import { curveSpans } from "./B05_Profile_UI_RouteEdit_Frame";
|
||||
|
||||
/** MC 가 측점에서 이만큼 안이면 「위」(m) — 파이썬 `MC_TOL_M` 과 같은 값. */
|
||||
export const MC_TOL_M = 0.05;
|
||||
|
||||
/** 선을 짜는 값 — 편집 창이 쥔 하한 · 간격. */
|
||||
export interface FitLimits {
|
||||
minRadiusM: number;
|
||||
limitRadiusM: number;
|
||||
limitArcM: number;
|
||||
intervalM: number;
|
||||
}
|
||||
|
||||
type Columns = Pick<NodeColumns, "curveOn" | "curveRadius" | "curveLock" | "curveArc" | "apexLock">;
|
||||
|
||||
/** 편집 창 `markEdited` 와 같은 길(길이 고정 R · 하한 끌어올림 → 선) — 값은 사본에. */
|
||||
function build(cols: Columns, planned: Vertex[], limits: FitLimits): EditedPolyline {
|
||||
const radius = [...cols.curveRadius];
|
||||
applyArcLocks(planned, cols.curveLock, cols.curveArc, radius);
|
||||
applyCurveLimits(planned, cols.curveOn, radius, limits.limitRadiusM, limits.limitArcM);
|
||||
return buildEditedPolyline(planned, cols.curveOn, radius, limits.minRadiusM);
|
||||
}
|
||||
|
||||
/** 꺾임점 `k` 곡선의 MC 누가거리 — 곡선이 없으면 null. */
|
||||
function mcOf(built: EditedPolyline, k: number): number | null {
|
||||
const curve = built.curves.find((item) => item.node_first === k);
|
||||
const span = curve ? curveSpans(built.vertices, [curve])[0] : undefined;
|
||||
return span ? (span[0] + span[1]) / 2 : null;
|
||||
}
|
||||
|
||||
/** 측점 밖 곡선 수 — MC 가 일반측점(No.1 이상) 위가 아닌 곡선 · 한 측점에 둘이면 하나만 위. */
|
||||
export function offStationCount(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
curves: ReadonlyArray<EditedCurve>,
|
||||
intervalM: number,
|
||||
): number {
|
||||
if (!(intervalM > 0)) return 0;
|
||||
const spans = curveSpans(line, curves);
|
||||
const hits = new Set<number>();
|
||||
for (const [start, end] of spans) {
|
||||
const mc = (start + end) / 2;
|
||||
const n = Math.round(mc / intervalM);
|
||||
if (n >= 1 && Math.abs(mc - n * intervalM) <= MC_TOL_M) hits.add(n);
|
||||
}
|
||||
return spans.length - hits.size;
|
||||
}
|
||||
|
||||
/** 맞춤을 시작할 꺾임점 — 지난 걸음과 처음 다른 자리 바로 앞(그 곡선 끝 접선도 바뀜) · 같으면 1. */
|
||||
export function fitStart(
|
||||
before: (Columns & Pick<NodeColumns, "planned">) | null | undefined,
|
||||
now: Columns & Pick<NodeColumns, "planned">,
|
||||
): number {
|
||||
if (!before) return 1;
|
||||
const keys = ["curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"] as const;
|
||||
const at = now.planned.findIndex(
|
||||
(vertex, i) =>
|
||||
vertex[0] !== before.planned[i]?.[0] ||
|
||||
vertex[1] !== before.planned[i]?.[1] ||
|
||||
keys.some((key) => now[key][i] !== before[key][i]),
|
||||
);
|
||||
const changed =
|
||||
at < 0 && now.planned.length !== before.planned.length ? now.planned.length - 1 : at;
|
||||
return Math.max(1, changed - 1);
|
||||
}
|
||||
|
||||
const round4 = (value: number): number => Math.round(value * 1e4) / 1e4;
|
||||
|
||||
/** 곡선 하나 맞춤 — IP `k` 를 들어오는 접선 따라 d 밀어 MC = target(할선법 · 기울기 ≈ 1). */
|
||||
function solve(
|
||||
cols: Columns,
|
||||
limits: FitLimits,
|
||||
planned: Vertex[],
|
||||
k: number,
|
||||
target: number,
|
||||
mc: number,
|
||||
): { planned: Vertex[]; built: EditedPolyline; mc: number } | null {
|
||||
const base = planned[k];
|
||||
const before = planned[k - 1];
|
||||
const length = Math.hypot(base[0] - before[0], base[1] - before[1]);
|
||||
if (!(length > 1e-9)) return null;
|
||||
const ux = (base[0] - before[0]) / length;
|
||||
const uy = (base[1] - before[1]) / length;
|
||||
const reach = limits.intervalM / 2;
|
||||
const clamp = (d: number): number => Math.max(-reach, Math.min(reach, d));
|
||||
const at = (d: number) => {
|
||||
const next = planned.slice();
|
||||
next[k] = [round4(base[0] + ux * d), round4(base[1] + uy * d)];
|
||||
const built = build(cols, next, limits);
|
||||
const found = mcOf(built, k);
|
||||
return found === null ? null : { planned: next, built, mc: found };
|
||||
};
|
||||
let d0 = 0;
|
||||
let f0 = mc - target;
|
||||
let d1 = clamp(-f0);
|
||||
for (let step = 0; step < 10; step += 1) {
|
||||
const tried = at(d1);
|
||||
if (!tried || d1 === d0) return null;
|
||||
const f1 = tried.mc - target;
|
||||
if (Math.abs(f1) <= MC_TOL_M / 5) return tried;
|
||||
const slope = (f1 - f0) / (d1 - d0);
|
||||
if (Math.abs(slope) < 0.2) return null;
|
||||
d0 = d1;
|
||||
f0 = f1;
|
||||
d1 = clamp(d1 - f1 / slope);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `from` 꺾임점부터 끝까지 곡선 MC 를 일반측점에 — 새 IP 목록(못 놓은 곡선 IP 는 그대로). */
|
||||
export function fitCurveStations(
|
||||
cols: Columns & Pick<NodeColumns, "planned">,
|
||||
limits: FitLimits,
|
||||
from: number,
|
||||
): Vertex[] {
|
||||
let planned = cols.planned.map((vertex): Vertex => [vertex[0], vertex[1]]);
|
||||
const interval = limits.intervalM;
|
||||
if (!(interval > 0) || planned.length < 3) return planned;
|
||||
let built = build(cols, planned, limits);
|
||||
const floor = curveShortfalls(built.nodes, limits.limitRadiusM, limits.limitArcM);
|
||||
let lastStation = 0; // 앞 곡선이 놓인 측점 번호(없으면 0 = 시점)
|
||||
let previous = -1; // 앞 곡선 꺾임점
|
||||
for (let k = 1; k < planned.length - 1; k += 1) {
|
||||
const mc = mcOf(built, k);
|
||||
if (mc === null) continue;
|
||||
const near = Math.max(lastStation + 1, Math.round(mc / interval));
|
||||
const onStation = Math.abs(mc - near * interval) <= MC_TOL_M;
|
||||
const fitted =
|
||||
onStation || k < from || cols.apexLock[k] || Math.abs(near * interval - mc) > interval / 2
|
||||
? null
|
||||
: solve(cols, limits, planned, k, near * interval, mc);
|
||||
const keep =
|
||||
fitted &&
|
||||
!shortfallCrossed(
|
||||
floor,
|
||||
curveShortfalls(fitted.built.nodes, limits.limitRadiusM, limits.limitArcM),
|
||||
) &&
|
||||
(previous < 0 ||
|
||||
Math.abs((mcOf(fitted.built, previous) ?? Infinity) - (mcOf(built, previous) ?? 0)) <=
|
||||
MC_TOL_M / 5);
|
||||
if (fitted && keep) ({ planned, built } = fitted);
|
||||
if (onStation || keep) lastStation = near;
|
||||
previous = k;
|
||||
}
|
||||
return planned;
|
||||
}
|
||||
@@ -168,6 +168,8 @@ export interface CurveSummaryInput {
|
||||
minRadiusM: number;
|
||||
/** 아직 한 번도 안 그렸나(막 열었을 때). */
|
||||
fresh: boolean;
|
||||
/** 측점 스냅 켬 때 MC 가 일반측점 밖인 곡선 수(366-3) — 끔이면 0. */
|
||||
offStation?: number;
|
||||
}
|
||||
|
||||
/** 상태줄 꼬리 — 곡선 수·기준 미달·사용자가 손댄 자리를 한 줄로. */
|
||||
@@ -192,6 +194,7 @@ export function curveSummary(input: CurveSummaryInput): string {
|
||||
return (
|
||||
`곡선 ${input.curveCount}곳(하한 R ${input.minRadiusM}m)` +
|
||||
`${input.violationCount ? ` · 기준 미달 ${input.violationCount}곳` : ""}` +
|
||||
`${input.offStation ? ` · 측점 밖 곡선 ${input.offStation}개` : ""}` +
|
||||
`${edits ? ` · ${edits}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -435,7 +435,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
paved: pavedBySpecs(section),
|
||||
// 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원)
|
||||
tilted: Boolean(section.ford_pavement || section.underdrain),
|
||||
tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null,
|
||||
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
|
||||
standard,
|
||||
berm: bermAt(section.chainage_m, design),
|
||||
rockBoundaryOffsetM,
|
||||
|
||||
@@ -103,6 +103,16 @@ class _SectionGeometry:
|
||||
low_left = ground_at(self.left_extent) < ground_at(-self.right_extent)
|
||||
self.slope_per_offset = -slope if low_left else slope
|
||||
|
||||
# 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 노견(폭 고정) · 측구 · 사면이
|
||||
# 같이 바깥으로 · 늘어난 폭은 차도 · 단면유형 · 측구 판정은 **옮긴 뒤** 노견에서(아래 ·
|
||||
# 370-1 — 마루를 넘으면 그 쪽 사면 기울기로 원지반까지). ⚠ TS 짝 `SectionGeometry`.
|
||||
self.shoulder_extend_left = max(shoulder_extend_left_m, 0.0)
|
||||
self.shoulder_extend_right = max(shoulder_extend_right_m, 0.0)
|
||||
self.half_road_left += self.shoulder_extend_left
|
||||
self.half_road_right += self.shoulder_extend_right
|
||||
self.left_extent += self.shoulder_extend_left
|
||||
self.right_extent += self.shoulder_extend_right
|
||||
|
||||
# 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다
|
||||
# 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서
|
||||
# 자연 도출된다. 사용자 입력 section_mode는 측구 방향(ditch_side) 기본값에만 쓰고
|
||||
@@ -156,16 +166,6 @@ class _SectionGeometry:
|
||||
if tilted:
|
||||
self.has_ditch = False
|
||||
|
||||
# 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전**
|
||||
# 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도.
|
||||
# ⚠ TS 짝 `SectionGeometry`.
|
||||
self.shoulder_extend_left = max(shoulder_extend_left_m, 0.0)
|
||||
self.shoulder_extend_right = max(shoulder_extend_right_m, 0.0)
|
||||
self.half_road_left += self.shoulder_extend_left
|
||||
self.half_road_right += self.shoulder_extend_right
|
||||
self.left_extent += self.shoulder_extend_left
|
||||
self.right_extent += self.shoulder_extend_right
|
||||
|
||||
# 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
|
||||
self.ditch_points: list[tuple[float, float]] = []
|
||||
edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""구조물 자리 기울기 — 물넘이포장 · 맹암거 측점의 노면(287-4 · 288-1).
|
||||
|
||||
그 자리 횡단 설계 = 측구 없음 · 노면 = 한 경사로 지대 낮은 쪽(`compute_cross_design(tilted=…,
|
||||
tilt_pct=…)`). 경사 = 물넘이 경사각도(%) · 맹암거 암거 경사(%) · 비면 표준 횡단경사.
|
||||
tilt_pct=…)`). 경사 = 물넘이 경사각도(%) · 맹암거 = 표준 횡단경사(암거 경사는 형상 하단 · 371-3).
|
||||
· 물넘이포장 = 관 지점 정본(pipe_points) 범위(기준 ± 월류 폭/2).
|
||||
· 맹암거 = 구조물 정본(structures.json) A군 점형 · 기준 측점 한 곳 — 횡단 dict 에
|
||||
`underdrain`(높이 · 경사)을 얹어 횡단도가 돌 무늬 형상을 그림(TS 짝
|
||||
@@ -78,7 +78,8 @@ def tilt_ranges(project_root: Path | None) -> list[TiltRange]:
|
||||
half = float(spec.get("span_m") or 0.0) / 2.0
|
||||
out.append((at - half, at + half, spec.get("slope_pct")))
|
||||
for drain in load_underdrains(project_root):
|
||||
out.append((drain["chainage_m"], drain["chainage_m"], drain["slope_pct"]))
|
||||
# 노면 = 표준 경사 · 암거 경사는 맹암거 하단 경계만(371-3 · TS `facilityRanges` 짝)
|
||||
out.append((drain["chainage_m"], drain["chainage_m"], None))
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import { useDesignStore } from "../A00_Common/design/design_store";
|
||||
import { setGeneralStations, setPlusStations } from "../common_util/common_util_structure_station";
|
||||
import { loadSavedPlusStations, plusStationsOf } from "../B05_Profile/B05_Profile_Api_PlusStations";
|
||||
import {
|
||||
@@ -160,7 +161,9 @@ export async function swapSectionStations(
|
||||
if (swap.moved) carryStationKeys(projectId, routeId, nextKeys);
|
||||
else useStationKeyList(projectId, routeId, nextKeys);
|
||||
cache.set(key, swap.detail);
|
||||
writeState("section-detail", swap.detail, projectId, routeId);
|
||||
// 노선 초안 동안 = 메모리만(365-1) — 세션 상세는 저장 노선 그대로(새로고침 · B06 바로 열기 = 묶음이 초안 다시 셈)
|
||||
if (!useDesignStore(projectId, null).get("routeDraft"))
|
||||
writeState("section-detail", swap.detail, projectId, routeId);
|
||||
await withDraftShapes(swap.detail, projectId); // 새 측점에도 구조물 그림 자료(323-4)
|
||||
withStructureAreas(swap.detail);
|
||||
if (cache.get(key) !== swap.detail) return null; // 그 사이 다른 갈아끼움 · 새로 받음
|
||||
|
||||
@@ -158,17 +158,23 @@ export function appendCardHeader(
|
||||
card.append(header);
|
||||
}
|
||||
|
||||
/** 하단 정보행 — 중심고와 (암 지반일 때) 암 경계선·암 절토 경사 제어. */
|
||||
/** 하단 정보행 — 왼쪽 = 선 공용 칸(370-3 · 안 고르면 암 경계선) · 오른쪽 = (암 지반일 때) 암 절토 경사. */
|
||||
export function appendCardFooter(
|
||||
card: HTMLElement,
|
||||
section: CrossSection,
|
||||
rockBoundary?: RockBoundaryControl,
|
||||
cutSlope?: CutSlopeControl,
|
||||
/** 설계선 고름 공용 칸(370-3 · `_Cross_Line_Cell`) — 있으면 왼쪽 칸(안 고르면 암 · 고르면 그 선). */
|
||||
lineCell?: HTMLElement,
|
||||
): void {
|
||||
// 중심고는 그래프 Y축 라벨 상자로 옮김(318-2 · `_Cross_Axes`) — 하단 띠는 암 제어만 · 비어도 띠 높이 그대로
|
||||
const footer = document.createElement("footer");
|
||||
// 암 경계선 제어는 하단 정보 행 왼쪽(318-2) — 암 지반만.
|
||||
if (rockBoundary && section.design?.geometry_preset === "rock") {
|
||||
if (lineCell) {
|
||||
lineCell.classList.add("b06-cross-card__rockb");
|
||||
footer.append(lineCell);
|
||||
}
|
||||
// 암 경계선 제어는 하단 정보 행 왼쪽(318-2) — 암 지반만 · 설계선 고름이 없는 카드.
|
||||
else if (rockBoundary && section.design?.geometry_preset === "rock") {
|
||||
const rockControl = buildRockBoundaryControl(section, rockBoundary);
|
||||
rockControl.classList.add("b06-cross-card__rockb");
|
||||
footer.append(rockControl);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* · 판정 원 = 중심 십자 자리 · 노면 판정보다 위(`stackPickTargets`) · 다른 고름과 같은 칸(하나만)
|
||||
* · 고르면 ▲▼ = 종단 계획고(지금 길 · 종단도 듣개) · ◀▶ · 가로 끌기 = 그 측점 노선을 좌우로
|
||||
* · 끄는 동안 · 키를 잇는 동안은 십자만 옮겨 보이고 · 놓을 때(키는 멈춘 뒤) 한 번 보냄
|
||||
* · 한 축 끌기(`bindAxisDrag`)는 암 경계선 세로 끌기(370-3)도 씀
|
||||
* · 좌우 = 노선 편집기가 등록한 host(`setRouteShiftHost` · B05 `_RouteEdit_Shift`) — B06 을 바로 열어
|
||||
* 편집기가 없으면 종단 보기를 숨긴 채 세움(`setRouteShiftOpener` · 페이지가 닮 · 알림도 페이지 것)
|
||||
* ========================================================================== */
|
||||
@@ -107,26 +108,14 @@ export function bindCenterShift(input: {
|
||||
if (!deltaM) return preview(0);
|
||||
void shiftRouteAt(chainageM, deltaM).then((moved) => moved || preview(0));
|
||||
};
|
||||
hit.addEventListener("pointerdown", (event) => {
|
||||
const ctm = layer.getScreenCTM();
|
||||
if (event.button !== 0 || !active() || !ctm) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // 카드 팬으로 안 번짐
|
||||
const startX = event.clientX;
|
||||
let deltaM = 0;
|
||||
const move = (next: PointerEvent): void => {
|
||||
deltaM = snapStep((next.clientX - startX) / (ctm.a * kx));
|
||||
preview(deltaM);
|
||||
};
|
||||
const up = (): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("pointercancel", up);
|
||||
send(deltaM);
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
window.addEventListener("pointercancel", up);
|
||||
bindAxisDrag({
|
||||
hit,
|
||||
layer,
|
||||
axis: "x",
|
||||
pxPerM: kx,
|
||||
active,
|
||||
preview,
|
||||
send,
|
||||
});
|
||||
let pending = 0;
|
||||
let timer = 0;
|
||||
@@ -144,6 +133,50 @@ export function bindCenterShift(input: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 고른 판정을 한 축으로 끌기(356-3 중심점 가로 · 370-3 암 경계선 세로) — 고른 뒤에만(안 고른 것을 끌면 카드
|
||||
* 팬 그대로) · 걸음 0.1 m · 끄는 동안 `preview` · 놓을 때 움직였으면 한 번 `send`(안 움직이면 `preview(0)`) ·
|
||||
* 놓은 자리 click 은 한 번 막음(같은 것 다시 누름 = 풂으로 안 번짐). `pxPerM` = 그 축 화면 px / m(layer 좌표).
|
||||
*/
|
||||
export function bindAxisDrag(input: {
|
||||
hit: SVGElement;
|
||||
layer: SVGGraphicsElement;
|
||||
axis: "x" | "y";
|
||||
pxPerM: number;
|
||||
active: () => boolean;
|
||||
preview: (deltaM: number) => void;
|
||||
send: (deltaM: number) => void;
|
||||
}): void {
|
||||
const { hit, layer, axis, pxPerM, active } = input;
|
||||
const at = (event: PointerEvent): number => (axis === "x" ? event.clientX : event.clientY);
|
||||
hit.addEventListener("pointerdown", (event) => {
|
||||
const ctm = layer.getScreenCTM();
|
||||
if (event.button !== 0 || !active() || !ctm) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // 카드 팬으로 안 번짐
|
||||
const start = at(event);
|
||||
const scale = (axis === "x" ? ctm.a : ctm.d) * pxPerM;
|
||||
let deltaM = 0;
|
||||
const move = (next: PointerEvent): void => {
|
||||
deltaM = snapStep((at(next) - start) / scale);
|
||||
input.preview(deltaM);
|
||||
};
|
||||
const up = (): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("pointercancel", up);
|
||||
if (!deltaM) return input.preview(0);
|
||||
const swallow = (click: Event): void => click.stopPropagation();
|
||||
window.addEventListener("click", swallow, { capture: true, once: true });
|
||||
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
|
||||
input.send(deltaM);
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
window.addEventListener("pointercancel", up);
|
||||
});
|
||||
}
|
||||
|
||||
/** 조정창 읽기 글 — 미리 볼 이동(m · 0 = 안내). */
|
||||
export function centerPreviewText(deltaM: number): string {
|
||||
if (!deltaM) return "▲▼ = 종단 계획고 · ◀▶ · 끌기 = 이 측점 노선 좌우";
|
||||
|
||||
@@ -313,12 +313,12 @@ export function appendCulvertOverlay(
|
||||
|
||||
const kindLabel = culvert.pipe_kind ? `${culvert.pipe_kind} ` : "";
|
||||
const wallThickness = pipeWallThicknessM(culvert.pipe_kind, diameter);
|
||||
const pipeInfo = `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipeLengthText(pipe.lengthM)}`;
|
||||
// 관 기울기 = L 뒤 소수 한 자리 · 역경사는 0.0(371-2)
|
||||
const pipeInfo = `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipeLengthText(pipe.lengthM)}, S=${Math.max(pipe.slopePct, 0).toFixed(1)}%`;
|
||||
// 관 정보 글 = 노폭 · 노견 라벨 위 줄(225-1 · B06 카드만 — 그 라벨 없는 덧그림엔 안 그림)
|
||||
if (!hidePipe) appendPipeInfoText(layer, pipeInfo, null);
|
||||
const tip =
|
||||
pipeInfo +
|
||||
` · 매설 경사 ${pipe.slopePct.toFixed(1)}%(수평 가능)` +
|
||||
` · 관 두께 ${(wallThickness * 1000).toFixed(1)}㎜(외경선 기준)` +
|
||||
` · 최소 토피 ${culvert.min_cover_m.toFixed(1)}m(별표2 복토 교차 참조)`;
|
||||
// 외경(관 두께 바깥) → 내경 순으로 2겹. 도면처럼 관벽 두께가 보이게 한다(사용자 ③).
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* (2026-08-22 사용자 확정 규칙):
|
||||
* · 기슭막이 사이 성토사면은 **정확히 1:1.2** · 자동 자리 = 사면 1.0 m 뒤 벽 상단(원지반 무관 · 91-3).
|
||||
* · 아랫단 벽 상단은 윗단 벽 **하단(수평 기초선)을 뚫고 올라갈 수 없다**.
|
||||
* · 다음 단 성토선 시작점 = 윗단 벽 **하단 수평선 +0.5m와 전면 경사선(1:n · 표준경사)의
|
||||
* 교차점**(배관 기슭막이의 관 하단 꼭짓점과 같은 자리 — 배관만 없다).
|
||||
* · 다음 단 성토선 시작점 = 윗단 벽 **발끝**(하단 수평선 ∩ 전면 경사선 · 원지반 무관 · 371-1).
|
||||
* 마지막 벽 → 끝 구간만 하단 수평선 +0.5m ∩ 전면(관 하단 꼭짓점과 같은 자리) · 뜬 벽은 발끝.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
materialFromForm,
|
||||
revetFormOf,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_FILL_TOP_M,
|
||||
REVET_LEAN_RATIO,
|
||||
REVET_THICKNESS_M,
|
||||
WALL_MIN_PURE_HEIGHT_M,
|
||||
@@ -208,11 +209,25 @@ export function inletGroundConnector(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이·기울기에 따라 커진다. */
|
||||
/** 벽 중심(하단 중점)에서 성토 위 이음까지의 수평거리 — 높이·기울기에 따라 커진다(371-1). */
|
||||
function jointRunOf(height: number, lean: number): number {
|
||||
return 0.25 * REVET_THICKNESS_M + (lean / 2) * height;
|
||||
return 0.75 * REVET_THICKNESS_M - REVET_FILL_TOP_M + (lean / 2) * height;
|
||||
}
|
||||
|
||||
/** 벽 아래 성토 시작점(371-1) — 아래 단이 있으면(`tiered`) 늘 발끝(전면 ∩ 하단 수평선 ·
|
||||
* 원지반 무관) · 끝 구간은 발끝이 원지반 위에 뜬 벽만 발끝 · 땅에 선 · 묻힌 벽은 `grounded`
|
||||
* (기준선 ∩ 전면 · 묻힘 판정 그대로). */
|
||||
export const floatFillStart = (
|
||||
wall: WallLayout,
|
||||
grounded: OffsetPoint,
|
||||
groundAt: (offset: number) => number,
|
||||
tiered: boolean,
|
||||
): OffsetPoint =>
|
||||
tiered ||
|
||||
(wall.floatGapM > 1e-6 && groundAt(wall.bottomFront.offset) < wall.bottomFront.elevation - 0.01)
|
||||
? wall.bottomFront
|
||||
: grounded;
|
||||
|
||||
/** 끝 성토부선 — src에서 1:1.2로 내려가며 원지반을 만나면 끝(지반이 높으면 지반 따름). */
|
||||
function trailingFill(
|
||||
src: OffsetPoint,
|
||||
@@ -359,22 +374,28 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
? { offset: src.offset + outward * shelfRun, elevation: src.elevation }
|
||||
: null;
|
||||
const slopeFrom = shelf ?? src;
|
||||
const run = Math.abs(wall.topJoint.offset - slopeFrom.offset);
|
||||
const rise = slopeFrom.elevation - wall.topJoint.elevation;
|
||||
const run = Math.abs(wall.fillTop.offset - slopeFrom.offset);
|
||||
const rise = slopeFrom.elevation - wall.fillTop.elevation;
|
||||
segments.push({
|
||||
points: shelf ? [src, shelf, wall.topJoint] : [src, wall.topJoint],
|
||||
points: shelf ? [src, shelf, wall.fillTop] : [src, wall.fillTop],
|
||||
lengthM: Math.hypot(run, rise),
|
||||
ratio: rise > 1e-9 ? run / rise : FILL_SLOPE_RATIO_MIN,
|
||||
overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6,
|
||||
});
|
||||
// 다음 단 시작점 = 이 벽 바닥 +0.5m(= 기준선) 수평선과 전면
|
||||
// 경사선(1:0.3)의 교차점(2026-08-22 사용자 — 관 끝점 규칙과 동일 기준).
|
||||
// 다음 단 시작점 = 벽 발끝(전면 경사 ∩ 하단 수평선 · 원지반 무관 · 371-1). 마지막 벽 →
|
||||
// 끝 구간은 바닥 +0.5m(= 기준선) ∩ 전면(2026-08-22 사용자) · 뜬 벽만 발끝.
|
||||
const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
|
||||
src = {
|
||||
offset:
|
||||
wall.points[2].offset + outward * wall.lean * (wall.topJoint.elevation - startElevation),
|
||||
elevation: startElevation,
|
||||
};
|
||||
src = floatFillStart(
|
||||
wall,
|
||||
{
|
||||
offset:
|
||||
wall.points[2].offset +
|
||||
outward * wall.lean * (wall.topJoint.elevation - startElevation),
|
||||
elevation: startElevation,
|
||||
},
|
||||
groundAt,
|
||||
i < input.adjusts.length - 1,
|
||||
);
|
||||
prevBottom = wall.bottomBack.elevation;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
BASIN_INNER_WIDTH_M,
|
||||
inletChoiceAvailability,
|
||||
} from "./logic/B06_Section_UI_Cross_Culvert_Basin";
|
||||
import { inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import { floatFillStart, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import { withBackCut } from "./logic/B06_Section_UI_Cross_Structure_Cut";
|
||||
import {
|
||||
buildCulvertTiers,
|
||||
@@ -377,17 +377,17 @@ export function computeCulvertLayout(
|
||||
lean,
|
||||
roadElevation: side.elevation_m,
|
||||
});
|
||||
const topJoint = wall.topJoint.offset;
|
||||
const topElevation = wall.topJoint.elevation;
|
||||
const fillTop = wall.fillTop.offset;
|
||||
const topElevation = wall.fillTop.elevation;
|
||||
const topFront = wall.points[2].offset;
|
||||
const bottomFront = wall.bottomFront;
|
||||
walls.push(wall);
|
||||
// 성토 사면선은 **이음선 상단점**에서 끊고, 끝단 표고도 그 점에 맞춘다(사용자 ②).
|
||||
// 성토 사면선은 **성토 위 이음**에서 끊고, 끝단 표고도 그 점에 맞춘다(사용자 ② · 371-1).
|
||||
if (outward > 0) {
|
||||
trimMax = topJoint;
|
||||
trimMax = fillTop;
|
||||
trimMaxElevation = topElevation;
|
||||
} else {
|
||||
trimMin = topJoint;
|
||||
trimMin = fillTop;
|
||||
trimMinElevation = topElevation;
|
||||
}
|
||||
// 관 끝단 마감면 = 기슭막이의 **계류측 변**(전면)을 그대로 복사(사용자 ③).
|
||||
@@ -507,11 +507,24 @@ export function computeCulvertLayout(
|
||||
// 다단 흙막이(유출측 · 집수정 계류측) — `_Culvert_Tiers` 가 푼다.
|
||||
const { extras, basinExtras, extrasForArea, basinExtrasForArea } = buildCulvertTiers({
|
||||
section,
|
||||
outletStart: outletWall ? pipeCorners.outlet.bottom : null,
|
||||
outletStart: outletWall
|
||||
? floatFillStart(
|
||||
outletWall,
|
||||
pipeCorners.outlet.bottom,
|
||||
groundAt,
|
||||
(revetShift?.extras ?? []).length > 0,
|
||||
)
|
||||
: null,
|
||||
outletBottom: outletWall?.bottomBack.elevation ?? 0,
|
||||
basinStart: culvert.hidden_pipe
|
||||
? inletWall
|
||||
? (guardFloorStart(section, body, inletWall) ?? pipeCorners.inlet.bottom)
|
||||
? (guardFloorStart(section, body, inletWall) ??
|
||||
floatFillStart(
|
||||
inletWall,
|
||||
pipeCorners.inlet.bottom,
|
||||
groundAt,
|
||||
(revetShift?.basinExtras ?? []).length > 0,
|
||||
))
|
||||
: null
|
||||
: basinFillStart,
|
||||
basinBottom: culvert.hidden_pipe ? (inletWall?.bottomBack.elevation ?? 0) : basinFillBottom,
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
GUARD_CUT_BERM_M,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_TRAP_TOP_M,
|
||||
REVET_FILL_TOP_M,
|
||||
} from "./logic/B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { OutletExtrasResult } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
@@ -26,7 +26,7 @@ export const slopeZeroAnchor = (
|
||||
outward: number,
|
||||
wallHeight: number,
|
||||
lean: number,
|
||||
): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_TRAP_TOP_M);
|
||||
): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_FILL_TOP_M);
|
||||
|
||||
/** 유출 관 하단 끝점 목표 = 벽 바닥 기준 0.5m 상단(기준선)과 **전면 경사선의
|
||||
* 교차점**(2026-08-22 사용자 ① — 성토부선 시작 규칙과 같은 자리). */
|
||||
@@ -106,7 +106,7 @@ export function buildCulvertTiers(input: {
|
||||
};
|
||||
const extras = buildExtrasAt(input.outletStart, outletInput);
|
||||
// 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 —
|
||||
// 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자).
|
||||
// 유출측(`extras`)과 같은 시작점(`floatFillStart` · 371-1)·같은 `bextra` 키(2026-08-29 사용자).
|
||||
const basinInput = {
|
||||
cutBerm: cutBerm(culvert?.body ? (culvert.inlet.revet_berm_m ?? GUARD_CUT_BERM_M) : 0),
|
||||
startBottomElevation: input.basinBottom,
|
||||
|
||||
@@ -123,8 +123,10 @@ export interface WallLayout {
|
||||
points: OffsetPoint[];
|
||||
/** 상단 배면 꼭짓점 — 성토 사면선과의 접점(설계선 트림 경계). */
|
||||
topBack: OffsetPoint;
|
||||
/** 이음선 상단점(평행사변형↔사다리꼴 상단 교차점) — 성토 사면선이 여기서 끝난다. */
|
||||
/** 이음선 상단점(평행사변형↔사다리꼴 상단 교차점) — 안 무늬 이음선 자리. */
|
||||
topJoint: OffsetPoint;
|
||||
/** 성토 위 이음 — 윗면 사면 쪽 끝에서 0.1 m 안(371-1). 성토 사면선이 여기서 끝난다. */
|
||||
fillTop: OffsetPoint;
|
||||
/** 하단선 양 끝 — **수평 기초**(2026-08-22 사용자 확정): 발자국 최저 지반(또는 관
|
||||
* invert) − 0.5m 표고의 수평선. 전면 발끝은 1:0.3 경사선을 하단까지 연장한 자리다. */
|
||||
bottomBack: OffsetPoint;
|
||||
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
} from "./B06_Section_UI_Cross_Design_Surface";
|
||||
export { appendCrossDesignOverlay } from "./B06_Section_UI_Cross_Design_Line";
|
||||
import { openCrossDetail, refreshCrossDetail } from "./B06_Section_UI_Cross_Detail";
|
||||
import { buildLineCell, rockCellState, type RockCellSpec } from "./B06_Section_UI_Cross_Line_Cell";
|
||||
|
||||
/** 넘침 정리 한 장 몫 — 쓰기·읽기·쓰기 세 토막으로 갈라 두어 프레임 단위로 묶는다. */
|
||||
interface ReflowCard {
|
||||
@@ -205,112 +206,29 @@ function toggle(
|
||||
return { wrap, button };
|
||||
}
|
||||
|
||||
/**
|
||||
* 길게 누르는 동안 같은 동작을 반복한다(B05 종단 선 제어 패턴 재사용). 0.5초 유지하면
|
||||
* 0.1초 간격(0.1m씩) 반복이 시작된다. 버튼이 재빌드로 사라져도 반복이 끊기지 않도록
|
||||
* 타이머·종료 감지를 버튼이 아니라 window 이벤트로 들고 있는다.
|
||||
*/
|
||||
function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } {
|
||||
let delayTimer = 0;
|
||||
let repeatTimer = 0;
|
||||
function stop(): void {
|
||||
window.clearTimeout(delayTimer);
|
||||
window.clearInterval(repeatTimer);
|
||||
delayTimer = 0;
|
||||
repeatTimer = 0;
|
||||
window.removeEventListener("pointerup", stop);
|
||||
window.removeEventListener("pointercancel", stop);
|
||||
window.removeEventListener("blur", stop);
|
||||
}
|
||||
function start(action: () => void): void {
|
||||
stop();
|
||||
delayTimer = window.setTimeout(() => {
|
||||
repeatTimer = window.setInterval(action, 100);
|
||||
}, 500);
|
||||
window.addEventListener("pointerup", stop);
|
||||
window.addEventListener("pointercancel", stop);
|
||||
window.addEventListener("blur", stop);
|
||||
}
|
||||
return { start, stop };
|
||||
}
|
||||
|
||||
/** 암 경계선 상/하/리셋 컨트롤(B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용). E-7: X축 행 배치용 export. */
|
||||
/** 암 경계선 상/하/리셋 컨트롤 — 아래 띠 공용 칸(370-3 · `_Cross_Line_Cell`)의 「안 고름」 꼴. 설계선 고름이
|
||||
* 없는 카드(설계 없음)에서 띠 왼쪽 칸으로 씀. */
|
||||
export function buildRockBoundaryControl(
|
||||
section: CrossSection,
|
||||
control: RockBoundaryControl,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b06-design__seg b06-design__rockb";
|
||||
const legendEl = document.createElement("span");
|
||||
legendEl.className = "b06-design__seg-legend";
|
||||
legendEl.textContent = L("B06_Design_RockBoundary_Legend");
|
||||
wrap.append(legendEl);
|
||||
const cell = buildLineCell();
|
||||
cell.show(rockCellState(rockCellSpec(section, control)));
|
||||
return cell.root;
|
||||
}
|
||||
|
||||
const group = document.createElement("div");
|
||||
group.className = "b06-design__seg-buttons";
|
||||
const readout = document.createElement("span");
|
||||
readout.className = "b06-design__rockb-readout";
|
||||
const currentOffset = control.offsetFor(section);
|
||||
readout.textContent = `${currentOffset >= 0 ? "+" : ""}${currentOffset.toFixed(1)}m`;
|
||||
|
||||
const repeater = createHoldRepeater();
|
||||
const makeButton = (
|
||||
label: string,
|
||||
title: string,
|
||||
className: string,
|
||||
onClick: () => void,
|
||||
hold = false,
|
||||
): HTMLButtonElement => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `b06-design__rockb-btn ${className}`;
|
||||
button.textContent = label;
|
||||
button.title = hold ? `${title}\n(길게 누르면 0.5초 뒤부터 연속 조정)` : title;
|
||||
if (hold) {
|
||||
// 포인터로 누르면 즉시 1회 반응하고 반복을 예약한다. 이어지는 click은 중복이라 삼킨다.
|
||||
let swallowClick = false;
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
event.stopPropagation();
|
||||
swallowClick = true;
|
||||
onClick();
|
||||
repeater.start(onClick);
|
||||
});
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
if (swallowClick) {
|
||||
swallowClick = false;
|
||||
return;
|
||||
}
|
||||
onClick();
|
||||
});
|
||||
} else {
|
||||
button.addEventListener("click", onClick);
|
||||
}
|
||||
return button;
|
||||
/** 암 경계선 칸 제원 — 공용 칸(안 고름 · 암 고름)이 같이 씀. */
|
||||
export function rockCellSpec(section: CrossSection, control: RockBoundaryControl): RockCellSpec {
|
||||
return {
|
||||
legend: L("B06_Design_RockBoundary_Legend"),
|
||||
offsetM: control.offsetFor(section),
|
||||
stepM: control.stepM,
|
||||
upTitle: L("B06_Design_RockBoundary_Up"),
|
||||
downTitle: L("B06_Design_RockBoundary_Down"),
|
||||
resetTitle: L("B06_Design_RockBoundary_Reset"),
|
||||
adjust: (deltaM) => control.adjust(section.chainage_m, deltaM),
|
||||
reset: () => control.reset(section.chainage_m),
|
||||
};
|
||||
|
||||
group.append(
|
||||
makeButton(
|
||||
"▲",
|
||||
`${L("B06_Design_RockBoundary_Up")} (+${control.stepM}m)`,
|
||||
"is-up",
|
||||
() => control.adjust(section.chainage_m, control.stepM),
|
||||
true,
|
||||
),
|
||||
makeButton(
|
||||
"▼",
|
||||
`${L("B06_Design_RockBoundary_Down")} (-${control.stepM}m)`,
|
||||
"is-down",
|
||||
() => control.adjust(section.chainage_m, -control.stepM),
|
||||
true,
|
||||
),
|
||||
makeButton("↺", L("B06_Design_RockBoundary_Reset"), "is-reset", () =>
|
||||
control.reset(section.chainage_m),
|
||||
),
|
||||
readout,
|
||||
);
|
||||
wrap.append(group);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,14 +4,18 @@
|
||||
*
|
||||
* 그려진 보라 선(`b06-chart__design-cross`)을 화면 좌표에서 m 로 되돌려 같은 방향 토막을
|
||||
* 직선 조각 하나로 합치고, 벽 · 본체 폴리곤에 가린 부분을 뺀 **보이는 조각**마다 투명 판정
|
||||
* 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 두 줄로 적는다.
|
||||
* 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 아래 띠 공용 칸(370-3 · `_Cross_Line_Cell`)에
|
||||
* 제목 · 축에 맞는 버튼 · 치수 한 줄을 적는다(안 고르면 암 경계선 칸 · 떠 있는 선 조정창 · 우측 위 라벨 걷음).
|
||||
* 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다.
|
||||
* 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다.
|
||||
* 고른 조각 쪽 사면을 노견째 이동 패널(◀ ↺ ▶ · 방향키 · 0.1 m)로 옮김(139-6 쉬프트 · 151-3 ·
|
||||
* 고른 조각 쪽 사면을 노견째 ◀ ↺ ▶ · 방향키 · 0.1 m 로 옮김(139-6 쉬프트 · 151-3 ·
|
||||
* 노폭 안쪽 금지 = 0 에서 멈춤). 축 제약(193-1) — 절 · 성토측 = 그 쪽 바깥 가로만(반대측 고정 ·
|
||||
* 구조물은 노견 끝에 서서 한 벌로 감) · 도로측(노면 · 노견) = 높이만(▲▼ = 종단 계획고).
|
||||
* 소단 수평 단(194) = 그 쪽 깊이만(▲▼ · ◀▶ 막음 · 노면 끝 위 금지) · 좌측 소단 상자 켬.
|
||||
* 고름은 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각(194-2).
|
||||
* 교차점 손잡이(370-2) — 쪽마다 설계선 ∩ 원지반 원(키 `toe:left|right` · 늘 보임 · ◀▶) · ◀▶ 가 노견 끝 =
|
||||
* 원지반 자리를 지나면 한 번 섬(`extendStop`) · 사면 키가 절 ↔ 성으로 바뀌면 같은 쪽 손잡이가 이어받음.
|
||||
* 암 경계선(370-3) — 점선 위 판정 선(키 `rock`) · ▲▼↺ · ◀▶ 막음 · 방향키 = 암만 · 세로 끌기(놓을 때 한 번).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch";
|
||||
@@ -19,18 +23,30 @@ import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design";
|
||||
import { clipSegment, type XY } from "../A00_Common/b_polygon_clip";
|
||||
import { sideRole } from "@util/common_util_cross_design_geometry";
|
||||
import { BERM_DEFAULT_DEPTH_M } from "@util/common_util_cross_berm";
|
||||
import { arrowsBelongToProfile, bindArrowKeys } from "./B06_Section_UI_Cross_Panel_Base";
|
||||
import {
|
||||
arrowsBelongToProfile,
|
||||
bindArrowKeys,
|
||||
buildPanelShell,
|
||||
dpadButton,
|
||||
makeRow,
|
||||
} from "./B06_Section_UI_Cross_Panel_Base";
|
||||
import { bindCenterShift, centerHit, centerPreviewText } from "./B06_Section_UI_Cross_Center";
|
||||
bindAxisDrag,
|
||||
bindCenterShift,
|
||||
centerHit,
|
||||
centerPreviewText,
|
||||
} from "./B06_Section_UI_Cross_Center";
|
||||
import {
|
||||
buildLineCell,
|
||||
rockCellState,
|
||||
rockValueText,
|
||||
type LineCellState,
|
||||
type RockCellSpec,
|
||||
} from "./B06_Section_UI_Cross_Line_Cell";
|
||||
import { extendStop, toePoints } from "./logic/B06_Section_UI_Cross_Fit";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const DESIGN_CLASS = "b06-chart__design-cross";
|
||||
const HIT_CLASS = "b06-chart__design-hit";
|
||||
/** 교차점 손잡이 원(370-2) — 중심점 판정 원 클래스에 덧붙임(고르기 차례 같음). */
|
||||
const TOE_CLASS = "b06-chart__toe-hit";
|
||||
/** 암 경계선 점선 · 그 위 판정 선(370-3 · 판정 선은 보라 선 판정 클래스에 덧붙임). */
|
||||
const ROCK_LINE_CLASS = "b06-chart__rock-boundary";
|
||||
export const ROCK_HIT_CLASS = "b06-chart__rock-hit";
|
||||
/** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */
|
||||
export const DESIGN_LINE_ATTR = "data-design-line";
|
||||
/** 보라 선을 덮는 벽 · 본체 폴리곤. */
|
||||
@@ -114,7 +130,7 @@ export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): MoveAxis
|
||||
isBermPiece(design, piece) ? "depth" : pieceZone(design, piece).zone === "road" ? "y" : "x";
|
||||
/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1) ·
|
||||
* center = 중심점(▲▼ 종단 계획고 · ◀▶ 노선 좌우 · 356-3). */
|
||||
export type MoveAxis = "x" | "y" | "depth" | "center";
|
||||
export type MoveAxis = "x" | "y" | "depth" | "center" | "rock";
|
||||
|
||||
/** 그 쪽 소단 깊이(노면 끝 아래 m) — 옮긴 값 → 구간 값 → 기본 1.0(194-1). */
|
||||
export const bermDepthOf = (design: ZoneDesign, side: CrossSide): number =>
|
||||
@@ -282,7 +298,10 @@ const keysByStation = new Map<string, string[]>();
|
||||
export function pickedLineAt(stationId: string): number | null {
|
||||
if (picked?.stationId !== stationId) return null;
|
||||
const keys = keysByStation.get(stationId);
|
||||
const index = keys ? keys.indexOf(picked.key) : Number(picked.key);
|
||||
let index = keys ? keys.indexOf(picked.key) : Number(picked.key);
|
||||
// 사면이 절 ↔ 성으로 넘어가 키가 바뀌거나 길이 0 으로 사라지면 같은 쪽 교차점 손잡이(370-2)
|
||||
const slope = /^(?:cut|fill)(?:\+s)?:(left|right):/.exec(picked.key);
|
||||
if (index < 0 && keys && slope) index = keys.indexOf(`toe:${slope[1]}`);
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
export function setPickedLine(stationId: string, index: number | null): void {
|
||||
@@ -312,7 +331,12 @@ const pointsOf = (element: Element): XY[] => {
|
||||
export interface ShoulderShift {
|
||||
valueOf: (side: CrossSide) => number;
|
||||
set: (side: CrossSide, valueM: number) => void;
|
||||
/** 한 걸음 now → next 사이 노견 끝 = 원지반 자리가 있으면 그 값(370-2 · 없으면 next). */
|
||||
stopAt?: (side: CrossSide, now: number, next: number) => number;
|
||||
}
|
||||
/** 이동값 글 — 0.1 m 눈금이면 한 자리 · 멈춤 자리(370-2)면 두 자리. */
|
||||
const extendText = (valueM: number): string =>
|
||||
valueM.toFixed(Math.abs(valueM * 10 - Math.round(valueM * 10)) < 1e-6 ? 1 : 2);
|
||||
/** 쉬프트 걸음(m). */
|
||||
export const SHIFT_STEP_M = 0.1;
|
||||
|
||||
@@ -345,11 +369,22 @@ function tellBermHost(chainageM: number | null): void {
|
||||
|
||||
/** 카드 → 고름 이동 창구 — 노견 확장(139-6) · 소단 깊이(194-1) 둘 다 카드 버튼과 같은 창구. */
|
||||
export function designLineMoves(
|
||||
section: Pick<CrossSection, "station_id" | "chainage_m" | "design">,
|
||||
section: Pick<CrossSection, "station_id" | "chainage_m" | "design" | "samples">,
|
||||
onDesignChange?: (chainageM: number, change: CrossDesignChange) => void,
|
||||
): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl; center?: number } {
|
||||
/** 암 경계선 칸 제원(`rockCellSpec`) — 암 지반일 때만 씀(370-3). */
|
||||
rockSpec?: RockCellSpec,
|
||||
): {
|
||||
stationId: string;
|
||||
shift?: ShoulderShift;
|
||||
berm?: BermDepthControl;
|
||||
center?: number;
|
||||
toes?: ReturnType<typeof toePoints>;
|
||||
rock?: RockCellSpec;
|
||||
} {
|
||||
const design = section.design;
|
||||
if (!onDesignChange || !design) return { stationId: section.station_id };
|
||||
// 암 경계선 = 암 지반만(370-3 · 띠 칸과 같은 창구)
|
||||
const rock = design?.geometry_preset === "rock" ? rockSpec : undefined;
|
||||
if (!onDesignChange || !design) return { stationId: section.station_id, rock };
|
||||
const send = (change: Partial<CrossDesignChange>) =>
|
||||
onDesignChange(section.chainage_m, {
|
||||
...(design as unknown as CrossDesignChange),
|
||||
@@ -357,11 +392,14 @@ export function designLineMoves(
|
||||
});
|
||||
return {
|
||||
stationId: section.station_id,
|
||||
rock,
|
||||
center: section.chainage_m, // 중심점 좌우 = 이 측점 노선(356-3)
|
||||
shift: {
|
||||
valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0,
|
||||
set: (side, valueM) => send({ shoulder_extend: { side, valueM } }),
|
||||
stopAt: (side, now, next) => extendStop(section.samples, design.road_edges, side, now, next),
|
||||
},
|
||||
toes: toePoints(section), // 교차점 손잡이(370-2)
|
||||
berm: {
|
||||
set: (side, valueM) => send({ berm_depth: { side, valueM } }),
|
||||
// 끔은 이 측점이 켠 때만 — 다른 카드 소단을 고르며 옛 카드가 풀릴 때 새 켬을 안 지움
|
||||
@@ -375,10 +413,8 @@ export function designLineMoves(
|
||||
|
||||
export interface DesignLinePick {
|
||||
pieces: DesignPiece[];
|
||||
/** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */
|
||||
label: HTMLElement;
|
||||
/** 사면 이동 패널(151-3) — 카드가 그림 칸에 붙임 · `shift` 없거나 고름 없으면 숨김. */
|
||||
panel: HTMLElement;
|
||||
/** 아래 띠 왼쪽 공용 칸(370-3) — 카드가 띠에 붙임 · 고른 선 제목 · 버튼 · 값 한 줄(안 고르면 암). */
|
||||
cell: HTMLElement;
|
||||
setActive: (index: number | null) => void;
|
||||
}
|
||||
|
||||
@@ -400,6 +436,10 @@ export function attachDesignLinePick(input: {
|
||||
stationId?: string;
|
||||
/** 있으면 중심점 고름(끝 조각 · 키 `center`) — 그 측점 누가거리(356-3). */
|
||||
center?: number;
|
||||
/** 있으면 쪽마다 교차점 손잡이(설계선 ∩ 원지반 · 키 `toe:left|right` · 늘 보임 · 370-2). */
|
||||
toes?: ReturnType<typeof toePoints>;
|
||||
/** 있으면 암 경계선 고름(키 `rock` · ▲▼↺ · 세로 끌기) · 안 고른 때 공용 칸 = 암(370-3). */
|
||||
rock?: RockCellSpec;
|
||||
}): DesignLinePick {
|
||||
const { layer, design, x, toDisplayY } = input;
|
||||
const x0 = x(0);
|
||||
@@ -429,7 +469,14 @@ export function attachDesignLinePick(input: {
|
||||
: edges.right.elevation_m -
|
||||
((edges.left.elevation_m - edges.right.elevation_m) * edges.right.offset_m) /
|
||||
(edges.left.offset_m - edges.right.offset_m);
|
||||
const keys = [...pieceKeys(design, pieces), ...(centerZ === null ? [] : ["center"])];
|
||||
const toeSides = (["left", "right"] as const).filter((at) => input.toes?.[at]);
|
||||
const rockLines = input.rock ? kids.filter((el) => el.classList.contains(ROCK_LINE_CLASS)) : [];
|
||||
const keys = [
|
||||
...pieceKeys(design, pieces),
|
||||
...(centerZ === null ? [] : ["center"]),
|
||||
...toeSides.map((at) => `toe:${at}`),
|
||||
...(rockLines.length ? ["rock"] : []),
|
||||
];
|
||||
if (input.stationId) keysByStation.set(input.stationId, keys);
|
||||
|
||||
// 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1).
|
||||
@@ -448,6 +495,26 @@ export function attachDesignLinePick(input: {
|
||||
// 중심점(356-3) = 끝 조각 · 판정 원은 고르기 차례가 보라 선 위로(`stackPickTargets`)
|
||||
const center = centerZ === null ? null : centerHit(x0, toDisplayY(centerZ));
|
||||
if (center) hits.push(center);
|
||||
// 교차점 손잡이(370-2) — 쪽마다 원 · 늘 보임 · 고르면 그 쪽 사면 ◀▶(중심점 원과 같은 고르기 차례)
|
||||
for (const at of toeSides) {
|
||||
const toe = input.toes?.[at] as { offset_m: number; elevation_m: number };
|
||||
const hit = centerHit(x(toe.offset_m), toDisplayY(toe.elevation_m));
|
||||
hit.classList.add(TOE_CLASS);
|
||||
hit.setAttribute("r", "5");
|
||||
hits.push(hit);
|
||||
}
|
||||
// 암 경계선(370-3) — 점선(`appendRockBoundaryOverlay`) 위 판정 선 하나(끊긴 토막도 한 path) · 키 rock
|
||||
let rockHit: SVGElement | null = null;
|
||||
if (rockLines.length) {
|
||||
rockHit = document.createElementNS(SVG_NS, "path");
|
||||
const path = (el: Element) =>
|
||||
`M${pointsOf(el)
|
||||
.map(([px, py]) => `${px},${py}`)
|
||||
.join(" L")}`;
|
||||
rockHit.setAttribute("d", rockLines.map(path).join(" "));
|
||||
rockHit.setAttribute("class", `${HIT_CLASS} ${ROCK_HIT_CLASS}`);
|
||||
hits.push(rockHit);
|
||||
}
|
||||
hits.forEach((hit, index) => {
|
||||
hit.addEventListener("click", (event) => {
|
||||
event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음
|
||||
@@ -456,23 +523,10 @@ export function attachDesignLinePick(input: {
|
||||
layer.insertBefore(hit, firstStructureHit ?? null);
|
||||
});
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "b06-cross-card__line-dims";
|
||||
label.hidden = true;
|
||||
const cell = buildLineCell();
|
||||
let side: CrossSide | null = null;
|
||||
let axis: MoveAxis = "x";
|
||||
let active: number | null = null;
|
||||
const { shift, berm } = input;
|
||||
|
||||
// 사면 이동 패널(151-3) — B06 공용 조정창 꼴(머리 · 「이동」 줄 십자 · 방향키 ←→).
|
||||
// ✕ = 같은 조각 다시 고름(카드가 풂) · ↺ = 그 쪽 0.
|
||||
const shell = buildPanelShell(() => {
|
||||
if (active !== null) input.onPick(active);
|
||||
});
|
||||
const readout = document.createElement("div");
|
||||
readout.className = "b06-structure-panel__value";
|
||||
const move = makeRow("이동");
|
||||
move.controls.classList.add("b06-structure-panel__buttons");
|
||||
const { shift, berm, rock } = input;
|
||||
const centerShift =
|
||||
center && input.center !== undefined
|
||||
? bindCenterShift({
|
||||
@@ -481,28 +535,44 @@ export function attachDesignLinePick(input: {
|
||||
chainageM: input.center,
|
||||
kx,
|
||||
active: () => axis === "center",
|
||||
preview: (deltaM) => (readout.textContent = centerPreviewText(deltaM)),
|
||||
preview: (deltaM) => cell.setValue(centerPreviewText(deltaM)),
|
||||
})
|
||||
: null;
|
||||
// 암 경계선 세로 끌기(370-3) — 고른 뒤에만 · 걸음 0.1 m · 끄는 동안 점선만 옮겨 보이고 놓을 때 한 번
|
||||
if (rockHit && rock)
|
||||
bindAxisDrag({
|
||||
hit: rockHit,
|
||||
layer: layer as SVGGraphicsElement,
|
||||
axis: "y",
|
||||
pxPerM: ky,
|
||||
active: () => axis === "rock",
|
||||
preview: (deltaM) => {
|
||||
for (const el of [rockHit, ...rockLines])
|
||||
(el as SVGElement).style.setProperty("translate", deltaM ? `0px ${deltaM * ky}px` : "");
|
||||
const next = rockValueText(Math.min(rock.offsetM + deltaM, 0));
|
||||
cell.setValue(deltaM ? `${next} — 놓으면 반영` : rockValueText(rock.offsetM));
|
||||
},
|
||||
send: (deltaM) => rock.adjust(deltaM),
|
||||
});
|
||||
const step = (dir: -1 | 1) => () => {
|
||||
if (axis === "center") return centerShift?.step(dir);
|
||||
if (side && shift) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx));
|
||||
if (!side || !shift) return;
|
||||
// 노견 끝 = 원지반(절도 성도 아닌 점)을 지나면 그 자리에 한 번 섬(370-2)
|
||||
const now = shift.valueOf(side);
|
||||
const next = shiftedExtend(now, side, dir, kx);
|
||||
shift.set(side, shift.stopAt?.(side, now, next) ?? next);
|
||||
};
|
||||
// 툴팁 = 고른 것의 실제 동작(364-7 · 사면 = 노견째 이동 · 중심점 = 그 측점 노선)
|
||||
const stepTitle = (screen: "왼쪽" | "오른쪽"): string =>
|
||||
axis === "center"
|
||||
? `이 측점 노선을 화면 ${screen}으로 0.1 m`
|
||||
: `고른 사면을 화면 ${screen}으로 0.1 m(노견째 · 노폭 안쪽은 못 감)`;
|
||||
move.controls.append(
|
||||
dpadButton("◀", "left", stepTitle("왼쪽"), step(-1)),
|
||||
dpadButton("↺", "reset", "이 쪽 이동 0", () => {
|
||||
if (side && shift) shift.set(side, 0);
|
||||
}),
|
||||
dpadButton("▶", "right", stepTitle("오른쪽"), step(1)),
|
||||
);
|
||||
// 소단 높이 줄(194-1) — ▲ = 얕게(노면 끝 위 금지 = 0 에서 멈춤) · ▼ = 깊게(사면 끝 앞에서 멈춤)
|
||||
const lift = makeRow("높이");
|
||||
lift.controls.classList.add("b06-structure-panel__buttons");
|
||||
const sideways = (reset: boolean): LineCellState["buttons"] => ({
|
||||
left: { title: stepTitle("왼쪽"), act: step(-1) },
|
||||
...(reset && { reset: { title: "이 쪽 이동 0", act: () => side && shift?.set(side, 0) } }),
|
||||
right: { title: stepTitle("오른쪽"), act: step(1) },
|
||||
});
|
||||
// 소단 높이(194-1) — ▲ = 얕게(노면 끝 위 금지 = 0 에서 멈춤) · ▼ = 깊게(사면 끝 앞에서 멈춤)
|
||||
const toeZ = (at: CrossSide): number =>
|
||||
Math.min(
|
||||
...pieces
|
||||
@@ -517,23 +587,23 @@ export function attachDesignLinePick(input: {
|
||||
if (dir > 0 && edgeZ !== undefined && edgeZ - next < toeZ(side) + SHIFT_STEP_M) return;
|
||||
if (next !== now) berm.set(side, next);
|
||||
};
|
||||
lift.controls.append(
|
||||
dpadButton("▲", "up", "고른 소단을 0.1 m 위로(노면 끝보다 높을 수 없음)", deepen(-1)),
|
||||
dpadButton("↺", "reset", "이 쪽 소단 = 구간 기본 깊이", () => {
|
||||
if (side && berm) berm.set(side, design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M);
|
||||
}),
|
||||
dpadButton("▼", "down", "고른 소단을 0.1 m 아래로", deepen(1)),
|
||||
);
|
||||
shell.root.append(readout, move.row, lift.row);
|
||||
const arrows = bindArrowKeys(shell.root, () => [move.controls, lift.controls]);
|
||||
// 못 가는 축의 방향키는 먹어 막음(193-1) — 사면 ▲▼ 가 종단 계획고로 새지 않고 · 노면 ◀▶ 는 안 감.
|
||||
// 노면 ▲▼ 는 그대로 종단도(계획고 · 종단 함께)로 넘김.
|
||||
const lift: LineCellState["buttons"] = {
|
||||
up: { title: "고른 소단을 0.1 m 위로(노면 끝보다 높을 수 없음)", act: deepen(-1) },
|
||||
reset: {
|
||||
title: "이 쪽 소단 = 구간 기본 깊이",
|
||||
act: () => side && berm?.set(side, design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M),
|
||||
},
|
||||
down: { title: "고른 소단을 0.1 m 아래로", act: deepen(1) },
|
||||
};
|
||||
const arrows = bindArrowKeys(cell.root, () => [cell.buttons]);
|
||||
// 못 가는 축의 방향키는 먹어 막음(193-1) — 사면 ▲▼ 가 종단 계획고로 새지 않고 · 노면 ◀▶ 는 안 감 ·
|
||||
// 암 ◀▶ 막음(370-3). 노면 ▲▼ 는 그대로 종단도(계획고 · 종단 함께)로 넘김.
|
||||
const blockOffAxis = (event: KeyboardEvent): void => {
|
||||
if (!shell.root.isConnected) {
|
||||
if (!cell.root.isConnected) {
|
||||
document.removeEventListener("keydown", blockOffAxis);
|
||||
return;
|
||||
}
|
||||
if (shell.root.classList.contains("is-hidden") || arrowsBelongToProfile()) return;
|
||||
if (arrowsBelongToProfile()) return;
|
||||
const focus = event.target as HTMLElement | null;
|
||||
if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return;
|
||||
const vertical = event.key === "ArrowUp" || event.key === "ArrowDown";
|
||||
@@ -543,53 +613,55 @@ export function attachDesignLinePick(input: {
|
||||
|
||||
return {
|
||||
pieces,
|
||||
label,
|
||||
panel: shell.root,
|
||||
cell: cell.root,
|
||||
setActive: (index) => {
|
||||
const onCenter = !!centerShift && index === pieces.length;
|
||||
const piece = index === null ? undefined : pieces[index];
|
||||
hits.forEach((hit, i) =>
|
||||
hit.classList.toggle("is-active", (!!piece || onCenter) && i === index),
|
||||
);
|
||||
label.hidden = !piece && !onCenter;
|
||||
active = piece || onCenter ? index : null;
|
||||
side = piece ? pieceZone(design, piece).side : null;
|
||||
axis = onCenter ? "center" : piece ? pieceMoveAxis(design, piece) : "x";
|
||||
const key = index === null ? undefined : keys[index];
|
||||
const onCenter = !!centerShift && key === "center";
|
||||
const onRock = !!rockHit && key === "rock";
|
||||
const toeSide = key?.startsWith("toe:") ? (key.slice(4) as CrossSide) : null;
|
||||
const piece = index === null || index >= pieces.length ? undefined : pieces[index];
|
||||
const chosen = !!piece || onCenter || onRock || !!toeSide;
|
||||
hits.forEach((hit, i) => hit.classList.toggle("is-active", chosen && i === index));
|
||||
side = piece ? pieceZone(design, piece).side : toeSide;
|
||||
axis = onRock ? "rock" : onCenter ? "center" : piece ? pieceMoveAxis(design, piece) : "x";
|
||||
const extend = side && shift && axis === "x" ? shift.valueOf(side) : 0;
|
||||
label.textContent = onCenter
|
||||
? "중심점"
|
||||
: piece
|
||||
? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "")
|
||||
: "";
|
||||
const open = onCenter || !!(piece && (axis === "depth" ? berm : shift));
|
||||
shell.root.classList.toggle("is-hidden", !open);
|
||||
// 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단)
|
||||
// 중심점 = 이동 줄 ◀▶(노선 좌우 · ↺ 없음) · ▲▼ 는 종단도로(356-3)
|
||||
const sideways = axis === "x" || axis === "center";
|
||||
move.row.classList.toggle("is-hidden", !sideways);
|
||||
move.controls.classList.toggle("is-hidden", !sideways);
|
||||
move.controls.children[1]?.classList.toggle("is-hidden", axis === "center");
|
||||
const [toLeft, , toRight] = move.controls.children as unknown as HTMLElement[];
|
||||
[toLeft.title, toRight.title] = [stepTitle("왼쪽"), stepTitle("오른쪽")];
|
||||
lift.row.classList.toggle("is-hidden", axis !== "depth");
|
||||
lift.controls.classList.toggle("is-hidden", axis !== "depth");
|
||||
const sideName = side === "left" ? "(좌)" : "(우)";
|
||||
shell.title.textContent =
|
||||
axis === "center"
|
||||
? "중심점"
|
||||
: axis === "y"
|
||||
? "노면 높이"
|
||||
: axis === "depth"
|
||||
? `소단 높이 ${sideName}`
|
||||
: `사면 이동 ${sideName}`;
|
||||
readout.textContent =
|
||||
axis === "center"
|
||||
? centerPreviewText(0)
|
||||
: axis === "y"
|
||||
? "방향키 ▲▼ = 종단 계획고(종단도 함께)"
|
||||
: axis === "depth" && side
|
||||
? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m`
|
||||
: `이동 ${extend.toFixed(1)}m`;
|
||||
const moved = extend > 0 ? ` · 이동 +${extendText(extend)}m` : "";
|
||||
const toe = toeSide ? input.toes?.[toeSide] : null;
|
||||
const dims = piece ? pieceLabel(design, piece).replace("\n", " · ") : "";
|
||||
// 아래 띠 공용 칸(370-3) — 안 고르면 암(암 지반만) · 고르면 그 선 제목 · 버튼 · 값 한 줄
|
||||
const state: LineCellState | null =
|
||||
onRock || (!chosen && rock)
|
||||
? rockCellState(rock as RockCellSpec)
|
||||
: onCenter
|
||||
? { title: "중심점", value: centerPreviewText(0), buttons: sideways(false) }
|
||||
: toe
|
||||
? {
|
||||
title: `교차점 이동 ${sideName}`,
|
||||
value: `노선에서 ${Math.abs(toe.offset_m).toFixed(2)}m${moved}`,
|
||||
buttons: shift ? sideways(true) : {},
|
||||
}
|
||||
: !piece
|
||||
? null
|
||||
: axis === "y"
|
||||
? { title: "노면 높이", value: `${dims} · ▲▼ = 종단 계획고`, buttons: {} }
|
||||
: axis === "depth" && side
|
||||
? {
|
||||
title: `소단 높이 ${sideName}`,
|
||||
value: `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m · ${dims}`,
|
||||
buttons: berm ? lift : {},
|
||||
}
|
||||
: {
|
||||
title: `사면 이동 ${sideName}`,
|
||||
value: dims + moved,
|
||||
buttons: shift ? sideways(true) : {},
|
||||
};
|
||||
cell.show(state);
|
||||
const open =
|
||||
onCenter ||
|
||||
onRock ||
|
||||
(!!toeSide && !!shift) ||
|
||||
!!(piece && (axis === "depth" ? berm : shift));
|
||||
berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2)
|
||||
arrows.detach();
|
||||
document.removeEventListener("keydown", blockOffAxis);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* (`_Engine_Cad_Table.cross_note_cad_entities`).
|
||||
* 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없으면 미리 넣음
|
||||
* (265-2 · 관 주인 측점 = 원형 마크 · 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 =
|
||||
* 「이름 조합 미지정」 · 이어서 좌측 패널 옵션 줄 = `structureOptionLines` · 166-1) · 그 밖 = 빈 도면.
|
||||
* 종류 이름만(같은 측점 같은 종류 한 줄) · 이어서 좌측 패널 옵션 줄 = `structureOptionLines` · 166-1) · 그 밖 = 빈 도면.
|
||||
* ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `noteEntities` ·
|
||||
* `structureNoteLines` · `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연
|
||||
* 측점은 B07 이 그것으로 그림 · 거울 시험 `test_260_note_cad.py` · `test_265_note_cad.py`.
|
||||
@@ -158,10 +158,11 @@ export const thingsAt = (things: readonly NoteThing[], chainageM: number): NoteT
|
||||
|
||||
/**
|
||||
* 구조물 조합 줄 — 구조물마다 지정된 조합(본체 → 그룹 차례) 이름 한 줄씩(이름 = 일위대가 이름 +
|
||||
* 조합 경로 옵션 값) · 같은 구조물의 같은 이름은 한 줄 · 미지정 = 「{구조물 이름} 조합 미지정」 ·
|
||||
* 조합 경로 옵션 값) · 같은 구조물의 같은 이름은 한 줄 · 미지정 = 종류 이름만(같은 측점 같은 종류는 한 줄) ·
|
||||
* 배관은 본체 줄 없음(원형 마크) — 보호공 · 흙막이 · 도수로 조합만 · 이어서 그 구조물 좌측 패널 옵션 줄(166-1).
|
||||
*/
|
||||
export function structureNoteLines(things: readonly NoteThing[], chainageM: number): string[] {
|
||||
const kinds = new Set<string>();
|
||||
return thingsAt(things, chainageM).flatMap((thing) => {
|
||||
const own = readSummaryItem(thing.summary_item);
|
||||
const slots: Array<[string, SummaryItem]> = Object.entries(readGroupItems(thing.group_items));
|
||||
@@ -171,9 +172,10 @@ export function structureNoteLines(things: readonly NoteThing[], chainageM: numb
|
||||
.map(([, item]) => summaryLabel(item));
|
||||
const combos = slots.length
|
||||
? [...new Set(names)]
|
||||
: thing.pipe
|
||||
: thing.pipe || kinds.has(thing.name)
|
||||
? []
|
||||
: [`${thing.name} 조합 미지정`];
|
||||
: [thing.name];
|
||||
if (!slots.length) kinds.add(thing.name);
|
||||
return [...combos, ...(thing.optionLines ?? [])];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,15 +53,17 @@ function targetsOf(svg: Element, design: CrossDesign | undefined, key: string):
|
||||
const ppm = Number(svg.getAttribute("data-px-per-m"));
|
||||
if (!design || !center || !(ppm > 0)) return [];
|
||||
const x0 = Number(center.getAttribute("x1"));
|
||||
return [...svg.querySelectorAll(".b06-chart__design-hit")].filter((hit) => {
|
||||
const xs = (hit.getAttribute("points") ?? "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((pair) => Number(pair.split(",")[0]));
|
||||
const mid = (xs[0] + xs[xs.length - 1]) / 2;
|
||||
// x = 왼쪽 여백 + (최대 offset − offset) × ppm → offset = (x(0) − x) ÷ ppm
|
||||
return lineZone(design, (x0 - mid) / ppm) === target.zone;
|
||||
});
|
||||
return [...svg.querySelectorAll(".b06-chart__design-hit:not(.b06-chart__rock-hit)")].filter(
|
||||
(hit) => {
|
||||
const xs = (hit.getAttribute("points") ?? "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((pair) => Number(pair.split(",")[0]));
|
||||
const mid = (xs[0] + xs[xs.length - 1]) / 2;
|
||||
// x = 왼쪽 여백 + (최대 offset − offset) × ppm → offset = (x(0) − x) ÷ ppm
|
||||
return lineZone(design, (x0 - mid) / ppm) === target.zone;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 그림 복제 안 강조 — 묻어 온 `.is-active` 를 지우고 `key` 대상만 켬(null = 모두 끔). */
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Line_Cell.ts
|
||||
* 횡단 카드 아래 띠 왼쪽 **공용 칸**(370-3) — 고른 선의 제목 · 축에 맞는 버튼 · 값 한 줄.
|
||||
*
|
||||
* · 안 고르면 암 경계선 ▲▼↺ · 값(암 지반만 · 토사는 빈 칸 · 띠 높이 그대로)
|
||||
* · 고르면 그 선(노면 · 사면 · 소단 · 중심점 · 교차점 · 암) — `_Cross_Design_Pick` 이 상태를 넣음
|
||||
* · 버튼 다섯 칸(◀ ▲ ↺ ▼ ▶)은 한 벌 · 상태마다 보임 · 툴팁 · 동작만 바꿈 · 방향키는 보이는 슬롯만 찾음
|
||||
* · ▲▼ 길게 누름 = 0.5 초 뒤 0.1 초마다 반복(암 · B05 종단 선 제어 꼴)
|
||||
* ========================================================================== */
|
||||
|
||||
export type CellSlot = "left" | "up" | "reset" | "down" | "right";
|
||||
const SLOTS: Array<[CellSlot, string]> = [
|
||||
["left", "◀"],
|
||||
["up", "▲"],
|
||||
["reset", "↺"],
|
||||
["down", "▼"],
|
||||
["right", "▶"],
|
||||
];
|
||||
|
||||
export interface CellButton {
|
||||
title: string;
|
||||
act: () => void;
|
||||
/** 길게 누르면 반복. */
|
||||
hold?: boolean;
|
||||
}
|
||||
export interface LineCellState {
|
||||
title: string;
|
||||
buttons: Partial<Record<CellSlot, CellButton>>;
|
||||
value: string;
|
||||
}
|
||||
export interface LineCell {
|
||||
root: HTMLElement;
|
||||
/** 버튼 줄 — 방향키 묶음(`bindArrowKeys`)이 슬롯 버튼을 찾는 그릇. */
|
||||
buttons: HTMLElement;
|
||||
/** null = 빈 칸(자리는 남음). */
|
||||
show: (state: LineCellState | null) => void;
|
||||
/** 값 글만 바꿈(끄는 동안 미리 보기). */
|
||||
setValue: (text: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 길게 누르는 동안 같은 동작을 반복한다(B05 종단 선 제어 패턴 재사용). 0.5초 유지하면
|
||||
* 0.1초 간격 반복이 시작된다. 버튼이 재빌드로 사라져도 반복이 끊기지 않도록
|
||||
* 타이머·종료 감지를 버튼이 아니라 window 이벤트로 들고 있는다.
|
||||
*/
|
||||
function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } {
|
||||
let delayTimer = 0;
|
||||
let repeatTimer = 0;
|
||||
function stop(): void {
|
||||
window.clearTimeout(delayTimer);
|
||||
window.clearInterval(repeatTimer);
|
||||
delayTimer = 0;
|
||||
repeatTimer = 0;
|
||||
window.removeEventListener("pointerup", stop);
|
||||
window.removeEventListener("pointercancel", stop);
|
||||
window.removeEventListener("blur", stop);
|
||||
}
|
||||
function start(action: () => void): void {
|
||||
stop();
|
||||
delayTimer = window.setTimeout(() => {
|
||||
repeatTimer = window.setInterval(action, 100);
|
||||
}, 500);
|
||||
window.addEventListener("pointerup", stop);
|
||||
window.addEventListener("pointercancel", stop);
|
||||
window.addEventListener("blur", stop);
|
||||
}
|
||||
return { start, stop };
|
||||
}
|
||||
|
||||
/** 버튼 칸 표시(보임과 무관 · 시험이 찾음) — 슬롯 클래스(`b06-structure-panel__btn--*`)는 보일 때만. */
|
||||
export const CELL_BTN = "b06-cross-card__cell-btn--";
|
||||
const HOLD_HINT = "\n(길게 누르면 0.5초 뒤부터 연속 조정)";
|
||||
|
||||
export function buildLineCell(): LineCell {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-design__seg b06-design__rockb b06-cross-card__linecell";
|
||||
root.addEventListener("click", (event) => event.stopPropagation()); // 카드 고름 · 팬으로 안 번짐
|
||||
const legend = document.createElement("span");
|
||||
legend.className = "b06-design__seg-legend";
|
||||
const group = document.createElement("div");
|
||||
group.className = "b06-design__seg-buttons";
|
||||
const readout = document.createElement("span");
|
||||
readout.className = "b06-design__rockb-readout";
|
||||
const repeater = createHoldRepeater();
|
||||
let current: LineCellState | null = null;
|
||||
const run = (slot: CellSlot) => current?.buttons[slot]?.act();
|
||||
const buttons = SLOTS.map(([slot, text]) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `b06-design__rockb-btn ${CELL_BTN}${slot}`;
|
||||
if (slot === "reset") button.classList.add("is-reset");
|
||||
button.textContent = text;
|
||||
// 길게 누름 — 누르면 즉시 1회 · 반복 예약 · 이어지는 click 은 삼킴
|
||||
let swallowClick = false;
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
if (!current?.buttons[slot]?.hold) return;
|
||||
event.stopPropagation();
|
||||
swallowClick = true;
|
||||
run(slot);
|
||||
repeater.start(() => run(slot));
|
||||
});
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
if (swallowClick) {
|
||||
swallowClick = false;
|
||||
return;
|
||||
}
|
||||
run(slot);
|
||||
});
|
||||
group.append(button);
|
||||
return [slot, button] as const;
|
||||
});
|
||||
group.append(readout);
|
||||
root.append(legend, group);
|
||||
const cell: LineCell = {
|
||||
root,
|
||||
buttons: group,
|
||||
show: (state) => {
|
||||
current = state;
|
||||
root.classList.toggle("is-empty", !state);
|
||||
legend.textContent = state?.title ?? "";
|
||||
readout.textContent = state?.value ?? "";
|
||||
readout.title = state?.value ?? "";
|
||||
for (const [slot, button] of buttons) {
|
||||
const spec = state?.buttons[slot];
|
||||
button.hidden = !spec;
|
||||
// 방향키 묶음은 슬롯 클래스로 찾음 — 숨은 버튼은 슬롯을 떼어 키를 안 삼킴(노면 ▲▼ = 종단도)
|
||||
button.classList.toggle(`b06-structure-panel__btn--${slot}`, !!spec);
|
||||
button.title = spec ? spec.title + (spec.hold ? HOLD_HINT : "") : "";
|
||||
}
|
||||
},
|
||||
setValue: (text) => {
|
||||
readout.textContent = text;
|
||||
readout.title = text;
|
||||
},
|
||||
};
|
||||
cell.show(null);
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** 암 경계선 칸 상태(안 고름 · 암 고름 둘 다) — ▲ = 위로 · ▼ = 아래로 · ↺ = 기본. */
|
||||
export interface RockCellSpec {
|
||||
legend: string;
|
||||
offsetM: number;
|
||||
stepM: number;
|
||||
upTitle: string;
|
||||
downTitle: string;
|
||||
resetTitle: string;
|
||||
adjust: (deltaM: number) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
export const rockValueText = (offsetM: number): string =>
|
||||
`${offsetM >= 0 ? "+" : ""}${offsetM.toFixed(1)}m`;
|
||||
export function rockCellState(rock: RockCellSpec): LineCellState {
|
||||
return {
|
||||
title: rock.legend,
|
||||
value: rockValueText(rock.offsetM),
|
||||
buttons: {
|
||||
up: {
|
||||
title: `${rock.upTitle} (+${rock.stepM}m)`,
|
||||
act: () => rock.adjust(rock.stepM),
|
||||
hold: true,
|
||||
},
|
||||
down: {
|
||||
title: `${rock.downTitle} (-${rock.stepM}m)`,
|
||||
act: () => rock.adjust(-rock.stepM),
|
||||
hold: true,
|
||||
},
|
||||
reset: { title: rock.resetTitle, act: rock.reset },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
PIPE_WALL_DEFAULT_RUN_M,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_THICKNESS_M,
|
||||
REVET_FILL_TOP_M,
|
||||
WALL_MIN_PURE_HEIGHT_M,
|
||||
} from "./logic/B06_Section_UI_Cross_Culvert_Const";
|
||||
import { outlineLeanOf } from "./logic/B06_Section_UI_Cross_Lean";
|
||||
@@ -208,7 +208,7 @@ export function computeRevetmentLayout(
|
||||
});
|
||||
const roadSlope = roadSlopePerOutward(design, side, outward);
|
||||
const autoOffset =
|
||||
edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_THICKNESS_M / 2);
|
||||
edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_FILL_TOP_M);
|
||||
// x 원점 = 성토사면(1:1.2) 경사길이 4m 지점 — 저장된 x 의 절대 자리 그대로(79b).
|
||||
const autoRun = PIPE_WALL_DEFAULT_RUN_M;
|
||||
/**
|
||||
@@ -285,7 +285,7 @@ export function computeRevetmentLayout(
|
||||
const walls: WallLayout[] = [tier1, ...extras.walls];
|
||||
// 물빼기 파이프 단면 = 표 형상칸.weep(322-1 · 옹벽)
|
||||
if (catalogRow(spec.type_id)?.shapeOpts.weep) for (const wall of walls) wall.retaining = true;
|
||||
const jt = tier1.topJoint;
|
||||
const jt = tier1.fillTop;
|
||||
// 노견 → (물매를 탄 노견 연장) → 벽 이음선 상단점. 연장 구간은 노면 물매 그대로 가고,
|
||||
// 꺾임점에서 성토 물매 1:1.2로 벽 상단에 닿는다(2026-08-29 사용자 확정).
|
||||
// D = 노견에서 벽 상단까지 바깥 방향 거리, R0 = 노견과 벽 상단의 낙차
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* B06_Section_UI_Cross_Underdrain.ts
|
||||
* 맹암거 횡단 형상(288-1 · 요구 183-3).
|
||||
*
|
||||
* · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음.
|
||||
* · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도.
|
||||
* · 노면 = 설계 노면 — 서버 · TS 짝이 표준 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음.
|
||||
* · 옵셋선(하단 경계) = 높은 쪽 노면 끝에서 암거 높이 아래 · 암거 경사로 낮은 쪽으로 내림(371-3) ·
|
||||
* 경사가 비면 노면과 같은 각도.
|
||||
* · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남.
|
||||
* · 양측절토(낮은 쪽 늘인 노면 `tilt_reach`) = 늘인 노면이 원지반과 만나는 점에서 수직으로 끊음(356-1).
|
||||
* · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(306-1) — 켜마다 반 칸 엇갈리고 이웃과 겹친 돌 ·
|
||||
@@ -42,16 +43,18 @@ export function drawCrossedStones(group: SVGElement, frame: BandFrame, heightM:
|
||||
|
||||
type Edge = { offset_m: number; elevation_m: number };
|
||||
|
||||
/** 하단 경계(옵셋선) — 표고 = `z0 + grade · (o − o0)`. */
|
||||
type Bottom = { o0: number; z0: number; grade: number };
|
||||
|
||||
/** 한쪽 둘레(노면 끝 → 옵셋선과 만나는 자리) — `outward` = 바깥 부호(+1 좌 · −1 우). */
|
||||
function sidePath(
|
||||
edge: Edge,
|
||||
outward: 1 | -1,
|
||||
line: readonly Edge[],
|
||||
roadGrade: number,
|
||||
heightM: number,
|
||||
bottom: Bottom,
|
||||
): OffsetPoint[] {
|
||||
const start = { offset: edge.offset_m, elevation: edge.elevation_m };
|
||||
const offZ = (o: number) => edge.elevation_m + roadGrade * (o - edge.offset_m) - heightM;
|
||||
const offZ = (o: number) => bottom.z0 + bottom.grade * (o - bottom.o0);
|
||||
const outside = line
|
||||
.filter((p) => (p.offset_m - edge.offset_m) * outward > 1e-6)
|
||||
.sort((a, b) => (a.offset_m - b.offset_m) * outward);
|
||||
@@ -60,7 +63,8 @@ function sidePath(
|
||||
if (first.elevation_m >= edge.elevation_m) {
|
||||
// 절토 — 사면을 안쪽 아래로 늘여 옵셋선과 만남
|
||||
const k = (first.elevation_m - edge.elevation_m) / (first.offset_m - edge.offset_m);
|
||||
const d = Math.abs(k - roadGrade) < 1e-9 ? 0 : -heightM / (k - roadGrade);
|
||||
const g = bottom.grade;
|
||||
const d = Math.abs(k - g) < 1e-9 ? 0 : (offZ(edge.offset_m) - edge.elevation_m) / (k - g);
|
||||
const offset = edge.offset_m + d;
|
||||
return [start, { offset, elevation: offZ(offset) }];
|
||||
}
|
||||
@@ -83,25 +87,38 @@ function sidePath(
|
||||
return path;
|
||||
}
|
||||
|
||||
/** 하단 경계 — 높은 쪽(상류) 노면 끝 − 높이에서 암거 경사로 낮은 쪽으로 내림(371-3) · 경사가 비면 노면과 평행. */
|
||||
function bottomOf(left: Edge, right: Edge, heightM: number, slopePct: number | null): Bottom {
|
||||
const roadGrade = (left.elevation_m - right.elevation_m) / (left.offset_m - right.offset_m);
|
||||
const high = left.elevation_m >= right.elevation_m ? left : right;
|
||||
const low = high === left ? right : left;
|
||||
const grade =
|
||||
slopePct && slopePct > 0
|
||||
? (Math.sign(low.offset_m - high.offset_m) * -slopePct) / 100
|
||||
: roadGrade;
|
||||
return { o0: high.offset_m, z0: high.elevation_m - heightM, grade };
|
||||
}
|
||||
|
||||
/** 맹암거 형상 둘레 — 좌 노면 끝 → 우 노면 끝 → 우 둘레 → 옵셋선 → 좌 둘레(위로). 높이 없으면 null. */
|
||||
export function underdrainOutline(
|
||||
design: CrossDesign,
|
||||
heightM: number | null,
|
||||
slopePct: number | null = null,
|
||||
): OffsetPoint[] | null {
|
||||
const edges = design.road_edges;
|
||||
if (!edges || !(heightM && heightM > 0)) return null;
|
||||
const { left, right } = edges;
|
||||
const span = left.offset_m - right.offset_m;
|
||||
if (!(Math.abs(span) > 1e-9)) return null;
|
||||
const grade = (left.elevation_m - right.elevation_m) / span;
|
||||
const bottom = bottomOf(left, right, heightM, slopePct);
|
||||
// 양측절토(356-1) — 낮은 쪽 늘인 노면이 원지반과 만나는 점(tilt_reach)에서 끊음 · 너머 점 거름 → 꼬리가 수직으로 닫음
|
||||
const reach = design.tilt_reach;
|
||||
const outward = reach?.side === "left" ? 1 : -1;
|
||||
const line = (design.design_line ?? []).filter(
|
||||
(p) => !reach || (p.offset_m - reach.offset_m) * outward <= 1e-6,
|
||||
);
|
||||
const leftPath = sidePath(left, 1, line, grade, heightM);
|
||||
const rightPath = sidePath(right, -1, line, grade, heightM);
|
||||
const leftPath = sidePath(left, 1, line, bottom);
|
||||
const rightPath = sidePath(right, -1, line, bottom);
|
||||
return [
|
||||
{ offset: left.offset_m, elevation: left.elevation_m },
|
||||
...rightPath,
|
||||
@@ -117,7 +134,7 @@ export function appendUnderdrainOverlay(
|
||||
x: (offset: number) => number,
|
||||
y: (elevation: number) => number,
|
||||
): SVGGElement | null {
|
||||
const outline = spec ? underdrainOutline(design, spec.height_m) : null;
|
||||
const outline = spec ? underdrainOutline(design, spec.height_m, spec.slope_pct) : null;
|
||||
if (!spec || !outline) return null;
|
||||
const px = (p: OffsetPoint): [number, number] => [x(p.offset), y(p.elevation)];
|
||||
const points = outline.map((p) => px(p).join(",")).join(" ");
|
||||
@@ -136,15 +153,19 @@ export function appendUnderdrainOverlay(
|
||||
clip.append(clipShape);
|
||||
const stones = document.createElementNS(SVG_NS, "g");
|
||||
stones.setAttribute("clip-path", `url(#${clipId})`);
|
||||
const height = spec.height_m!;
|
||||
const offsets = outline.map((p) => p.offset);
|
||||
const [lo, hi] = [Math.min(...offsets), Math.max(...offsets)];
|
||||
const { left, right } = design.road_edges;
|
||||
const grade = (left.elevation_m - right.elevation_m) / (left.offset_m - right.offset_m);
|
||||
const roadZ = (o: number) => left.elevation_m + grade * (o - left.offset_m);
|
||||
const bottom = bottomOf(left, right, spec.height_m!, spec.slope_pct);
|
||||
const bottomZ = (o: number) => bottom.z0 + bottom.grade * (o - bottom.o0);
|
||||
// 돌 띠 = 노면 ~ 하단 경계(두께가 자리마다 다름 · 켜 수는 두꺼운 쪽)
|
||||
const height = Math.max(roadZ(lo) - bottomZ(lo), roadZ(hi) - bottomZ(hi), spec.height_m!);
|
||||
const at = (u: number, v: number): OffsetPoint => {
|
||||
const offset = lo + (hi - lo) * u;
|
||||
return { offset, elevation: roadZ(offset) - height * (v + 0.5) };
|
||||
const top = roadZ(offset);
|
||||
return { offset, elevation: top + (bottomZ(offset) - top) * (v + 0.5) };
|
||||
};
|
||||
const [ax, ay] = px(at(0, 0));
|
||||
const [bx, by] = px(at(1, 0));
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
appendCrossDesignOverlay,
|
||||
appendPavementOverlay,
|
||||
appendRockBoundaryOverlay,
|
||||
rockCellSpec,
|
||||
type RockBoundaryControl,
|
||||
} from "./B06_Section_UI_Cross_Design";
|
||||
import { appendCardFooter, appendCardHeader } from "./B06_Section_UI_Cross_Card_Chrome";
|
||||
@@ -202,6 +203,7 @@ export function createCrossSectionCard(
|
||||
const culvertState = emptyCardCulvertState();
|
||||
/** 보라 선 고름(137-2) — 고름은 페이지 한 칸(`_Cross_Design_Pick`) · 강조는 그린 뒤 붙음. */
|
||||
let setLineActive: (index: number | null) => void = () => undefined;
|
||||
let linePick: DesignLinePick | null = null;
|
||||
const dropLine = (): void => {
|
||||
setPickedLine(section.station_id, null);
|
||||
setLineActive(null);
|
||||
@@ -413,7 +415,6 @@ export function createCrossSectionCard(
|
||||
const toDisplayY = (elevation: number): number =>
|
||||
y(elevationMid + (elevation - elevationMid) * exaggeration);
|
||||
appendGroundProfile(plotLayer, drawSamples, x, toDisplayY);
|
||||
let linePick: DesignLinePick | null = null;
|
||||
|
||||
if (section.design) {
|
||||
// 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다.
|
||||
@@ -607,8 +608,12 @@ export function createCrossSectionCard(
|
||||
x,
|
||||
toDisplayY,
|
||||
onPick: pickLine,
|
||||
// 고른 선 쪽 노견 확장(139-6) · 소단 높이(194-1) · 조각 키(194-2) — 카드 버튼과 같은 창구
|
||||
...designLineMoves(section, onDesignChange),
|
||||
// 고른 선 쪽 노견 확장(139-6) · 소단 높이(194-1) · 조각 키(194-2) · 암(370-3) — 카드 버튼과 같은 창구
|
||||
...designLineMoves(
|
||||
section,
|
||||
onDesignChange,
|
||||
rockBoundary && rockCellSpec(section, rockBoundary),
|
||||
),
|
||||
});
|
||||
setLineActive = linePick.setActive;
|
||||
setLineActive(pickedLineAt(section.station_id));
|
||||
@@ -679,9 +684,7 @@ export function createCrossSectionCard(
|
||||
fit: () => stationWidth.request(section.chainage_m, crossHalfWidth ?? maxOffset),
|
||||
};
|
||||
const zoomBar = buildZoomControls(zoomPan, widthActions);
|
||||
if (linePick) zoomBar.prepend(linePick.label); // 치수 = [+ − ⤢] 왼쪽(137-2)
|
||||
chartWrap.append(svg, readout.root, zoomBar, panel.root);
|
||||
if (linePick) chartWrap.append(linePick.panel); // 사면 이동 패널(151-3)
|
||||
for (const panel of bodies.panels) chartWrap.append(panel.root);
|
||||
if (activeArea) {
|
||||
setBandActive(activeArea);
|
||||
@@ -690,6 +693,6 @@ export function createCrossSectionCard(
|
||||
card.append(chartWrap);
|
||||
}
|
||||
|
||||
appendCardFooter(card, section, rockBoundary, cutSlope);
|
||||
appendCardFooter(card, section, rockBoundary, cutSlope, linePick?.cell); // 왼쪽 = 선 공용 칸(370-3)
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_LEAN_RATIO,
|
||||
REVET_THICKNESS_M,
|
||||
WALL_FILL_TOP_INSET_M,
|
||||
WALL_MIN_PURE_HEIGHT_M,
|
||||
} from "./logic/B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { RevetMaterial } from "./logic/B06_Section_UI_Cross_Culvert_Const";
|
||||
@@ -104,6 +105,7 @@ export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayou
|
||||
outward,
|
||||
topBack,
|
||||
topJoint: { offset: topJoint, elevation: topElevation },
|
||||
fillTop: { offset: topFront - outward * WALL_FILL_TOP_INSET_M, elevation: topElevation },
|
||||
bottomBack,
|
||||
bottomFront,
|
||||
points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront],
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* - `reconcileStaleDesigns`: 낡은 design 일괄 재계산(B05 와 같은 창구)
|
||||
* - `syncBermSpans` : 구조물 목록의 소단을 세션 사본으로 펴기
|
||||
* - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영]
|
||||
* 고친 뒤 계산 묶음 cross 입력도 다시(`pipelineCross` · 노선 초안 동안 카드 = 묶음 새 노선 상세 · 365-2).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_
|
||||
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail";
|
||||
import { useStructureIds } from "./B06_Section_Station_Keys";
|
||||
import { pipelineCross } from "../A00_Common/design/design_pipeline_page";
|
||||
|
||||
export interface DesignSyncContext {
|
||||
projectId: string | null;
|
||||
@@ -125,6 +127,7 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
|
||||
.detail()
|
||||
?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01)?.design;
|
||||
if (design && design.ground_type !== "soil") void reconcileStaleDesigns({ force: true });
|
||||
else if (projectId) pipelineCross(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,6 +187,7 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
|
||||
}
|
||||
pipelineCross(projectId); // 세션 고침 · 표준 → 묶음(같은 입력이면 안 셈)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* 종단 보기도 같은 가게를 듣고 같은 함수를 부름 — 먼저 부른 쪽이 갈아끼우고 다음 쪽은 「같은 목록」.
|
||||
* 노선 초안 동안(346-2 덧 3) 카드 = 묶음 새 노선 상세(`pipelineDetail` · cross 단계 결과) — 새 상세가 오면
|
||||
* 같은 길로 다시 그림 · 초안을 버리면(상세 null) 저장 노선 카드로.
|
||||
* 묶음 차례는 cross 뒤에만 다시 그림(profile 뒤 중간 상세는 안 그림 · 365-4) — 겹친 신호(갈아끼움 · 묶음 · 초안)는
|
||||
* 횡단 보기 다시 읽기가 한 번으로 묶음(`design_view_cross` refreshDetailForStructures · 최신 차례만).
|
||||
* ========================================================================== */
|
||||
|
||||
import { useDesignStore } from "../A00_Common/design/design_store";
|
||||
@@ -42,7 +44,7 @@ export function followSwaps(
|
||||
};
|
||||
}
|
||||
|
||||
/** 묶음 새 노선 상세가 바뀌면(profile · cross 단계 뒤) 부름 — 묶음은 종단 보기가 세우므로 차례 번호가 날 때 붙음. */
|
||||
/** 묶음 새 노선 상세가 바뀌면(cross 단계 뒤) 부름 — 묶음은 페이지가 세우므로 차례 번호가 날 때 붙음. */
|
||||
function followDraftDetail(projectId: string, changed: () => void): () => void {
|
||||
let pipe: DesignPipeline | null = null;
|
||||
let stopPipe = (): void => undefined;
|
||||
@@ -54,8 +56,7 @@ function followDraftDetail(projectId: string, changed: () => void): () => void {
|
||||
pipe = now;
|
||||
stopPipe =
|
||||
now?.subscribe((event) => {
|
||||
if (event.kind !== "stage" || (event.stage !== "profile" && event.stage !== "cross"))
|
||||
return;
|
||||
if (event.kind !== "stage" || event.stage !== "cross") return;
|
||||
const next = pipelineDetail(projectId);
|
||||
if (next === drawn) return;
|
||||
drawn = next;
|
||||
|
||||
@@ -640,6 +640,38 @@ html.is-bm-route .b06-section__notice-bar {
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
/* 선 공용 칸(370-3) — 고른 선 제목 · 버튼 · 값 한 줄(넘치면 말줄임) · 빈 칸도 자리(띠 높이) 그대로. */
|
||||
.b06-cross-card__linecell {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell.is-empty {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell .b06-design__seg-buttons {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell .b06-design__rockb-readout {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell .b06-design__rockb-btn {
|
||||
flex: 0 0 auto;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.b06-cross-card__linecell
|
||||
.b06-design__rockb-btn:not([hidden])
|
||||
~ .b06-design__rockb-btn:not([hidden]) {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 측점별 암 절토 경사(2026-09-07) — 행의 **오른쪽 끝**(암 경계 = 왼쪽). */
|
||||
.b06-cross-card__cutslope {
|
||||
margin-left: auto;
|
||||
|
||||
@@ -193,6 +193,24 @@
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
/* 교차점 손잡이(370-2) — 사면 ∩ 원지반 작은 원 · 늘 보임 · 고르면 채움. */
|
||||
.b06-chart__toe-hit {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.b06-chart__toe-hit.is-active {
|
||||
fill: var(--color-royal-amethyst);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 암 경계선 고름(370-3) — 점선 위 투명 판정 선 · 고르면 경고색 띠 · 세로 끌기. */
|
||||
.b06-chart__design-hit.b06-chart__rock-hit.is-active {
|
||||
stroke: color-mix(in srgb, var(--color-warning) 40%, transparent);
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
/* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */
|
||||
.b06-chart__carriageway-tick {
|
||||
stroke: var(--color-royal-amethyst);
|
||||
|
||||
@@ -223,7 +223,7 @@
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
|
||||
}
|
||||
|
||||
/* 고른 보라 선 치수(137-2) — 카드 폭 무관 늘 [+ − ⤢] 왼쪽(179-1) · 두 줄(151-1 · 길이 · 경사비율 / 수평 · 높이). */
|
||||
/* 고른 선 치수(137-2) — 상세 모달 줌 막대 왼쪽(179-1) · 두 줄(151-1) · 카드는 아래 띠 공용 칸(370-3). */
|
||||
.b06-cross-card__line-dims {
|
||||
align-self: center;
|
||||
padding: 1px var(--spacing-4);
|
||||
|
||||
@@ -63,8 +63,9 @@ export function facilityRanges(
|
||||
const depth = num(spec.overflow_depth_m) ?? 0;
|
||||
if (spec.type === "ford" && depth > 0) drops.push([at, depth]);
|
||||
}
|
||||
// 노면 = 표준 경사 · 암거 경사는 맹암거 하단 경계만(371-3 · 파이썬 `tilt_ranges` 짝)
|
||||
for (const drain of underdrainSpecsFrom(structures))
|
||||
tilt.push([drain.chainage_m, drain.chainage_m, drain.slope_pct]);
|
||||
tilt.push([drain.chainage_m, drain.chainage_m, null]);
|
||||
return { paved, tilt, drops };
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ export const REVET_LEAN_RATIO = 0.3;
|
||||
/** 사다리꼴(배면부) 상단 폭(m) — 띠 두께의 절반(2026-08-20 사용자 확정). */
|
||||
export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2;
|
||||
|
||||
/** 성토 위 이음 = 벽 윗면 사면 쪽 끝에서 이만큼 안(m · 371-1). */
|
||||
export const WALL_FILL_TOP_INSET_M = 0.1;
|
||||
|
||||
/** 배면에서 성토 위 이음까지 수평거리(m) — 윗면 1.5t − 0.1(371-1). */
|
||||
export const REVET_FILL_TOP_M = REVET_THICKNESS_M * 1.5 - WALL_FILL_TOP_INSET_M;
|
||||
|
||||
/**
|
||||
* 집수정 상부 여유(m) — 관 상단에서 집수정 벽 상단까지의 간격. 관 벽(유입·유출)은
|
||||
* 여유 없이 벽 윗면 = 관 윗면(139-1 · `revetTargetHeight`).
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
REVET_LEAN_RATIO,
|
||||
REVET_THICKNESS_M,
|
||||
REVET_TRAP_TOP_M,
|
||||
REVET_FILL_TOP_M,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type {
|
||||
EndFace,
|
||||
@@ -144,10 +144,10 @@ export interface FillSlopeSegment {
|
||||
* 접점이 다시 어긋난다.
|
||||
*/
|
||||
export function fillSlopeOf(
|
||||
wall: { role: "inlet" | "outlet" | "extra"; topJoint: OffsetPoint; outward: number },
|
||||
wall: { role: "inlet" | "outlet" | "extra"; fillTop: OffsetPoint; outward: number },
|
||||
edge: { offset_m: number; elevation_m: number },
|
||||
): FillSlopeSegment {
|
||||
const to = wall.topJoint;
|
||||
const to = wall.fillTop;
|
||||
const rise = edge.elevation_m - to.elevation;
|
||||
const edgePoint: OffsetPoint = { offset: edge.offset_m, elevation: edge.elevation_m };
|
||||
if (!(rise > 1e-6)) {
|
||||
@@ -244,7 +244,7 @@ export function minShoulderWallOffset(
|
||||
const offset = edge.offset_m + outward * ((span * i) / steps);
|
||||
const rise = edge.elevation_m - (baseAt(offset) + height);
|
||||
if (rise < FILL_MIN_RISE_M) continue;
|
||||
const joint = offset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M);
|
||||
const joint = offset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_FILL_TOP_M);
|
||||
if (((joint - edge.offset_m) * outward) / rise < FILL_SLOPE_RATIO_MIN) continue;
|
||||
if ((offset - toeOffset) * outward > 1e-6) {
|
||||
const toeRise = edge.elevation_m - (baseAt(toeOffset) + height);
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
*
|
||||
* 설계선이 지반과 만나기 전에 끝나면(계산 반폭 밖) **추세로 외삽**한다 — 끝단의 사면
|
||||
* 기울기와 지반 기울기 차이로 만나는 자리를 추정한다.
|
||||
*
|
||||
* 교차점 손잡이 자리(`toePoints`) · 사면 이동 멈춤(`extendStop`)도 여기(370-2).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign, CrossSection } from "../B06_Section_Api_Fetch";
|
||||
@@ -56,6 +58,60 @@ export function toeOffsets(section: CrossSection): { left: number | null; right:
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 교차점 손잡이 자리(370-2) — `toeOffsets` 자리 · 표고 = 원지반. 못 만난 쪽은 null. */
|
||||
export function toePoints(
|
||||
section: Pick<CrossSection, "design" | "samples">,
|
||||
): Record<"left" | "right", { offset_m: number; elevation_m: number } | null> {
|
||||
if (!section.samples?.length) return { left: null, right: null };
|
||||
const toes = toeOffsets(section as CrossSection);
|
||||
const groundAt = groundInterpolator(section.samples);
|
||||
const at = (offset: number | null) =>
|
||||
offset === null || !groundAt ? null : { offset_m: offset, elevation_m: groundAt(offset) };
|
||||
return { left: at(toes.left), right: at(toes.right) };
|
||||
}
|
||||
|
||||
/** 멈춤 탐색 간격(m) — 사면 이동 걸음(0.1 m)보다 촘촘하게. */
|
||||
const STOP_SCAN_M = 0.01;
|
||||
|
||||
/**
|
||||
* 사면 이동 멈춤(370-2) — 그 쪽 노견 확장 `now` → `next` 사이에서 옮긴 노견 끝이 원지반과 딱 만나는
|
||||
* (절토도 성토도 아닌) 확장값. 원지반 − 노면 평면(`road_edges` 두 점을 이은 선) 부호가 바뀌는 자리를
|
||||
* 보간 · 못 찾거나 지금 그 자리면 `next` 그대로. 0.001 m 반올림.
|
||||
*/
|
||||
export function extendStop(
|
||||
samples: CrossSection["samples"],
|
||||
edges: CrossDesign["road_edges"] | undefined,
|
||||
side: "left" | "right",
|
||||
now: number,
|
||||
next: number,
|
||||
): number {
|
||||
const groundAt = groundInterpolator(samples ?? []);
|
||||
if (!groundAt || !edges || edges.left.offset_m === edges.right.offset_m) return next;
|
||||
const { left, right } = edges;
|
||||
const plane = (o: number) =>
|
||||
right.elevation_m +
|
||||
((left.elevation_m - right.elevation_m) * (o - right.offset_m)) /
|
||||
(left.offset_m - right.offset_m);
|
||||
const sign = side === "left" ? 1 : -1;
|
||||
const base = Math.abs(edges[side].offset_m) - now; // 확장 0 노견 끝 거리
|
||||
const gapAt = (extend: number) => {
|
||||
const o = sign * (base + extend);
|
||||
return groundAt(o) - plane(o);
|
||||
};
|
||||
let previous = gapAt(now);
|
||||
if (Math.abs(previous) < 1e-4) return next;
|
||||
const steps = Math.max(1, Math.ceil(Math.abs(next - now) / STOP_SCAN_M));
|
||||
for (let i = 1; i <= steps; i += 1) {
|
||||
const a = now + ((next - now) * (i - 1)) / steps;
|
||||
const b = now + ((next - now) * i) / steps;
|
||||
const gap = gapAt(b);
|
||||
if (gap === 0 || Math.sign(gap) !== Math.sign(previous))
|
||||
return Math.round((a + ((b - a) * previous) / (previous - gap)) * 1000) / 1000;
|
||||
previous = gap;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시).
|
||||
*
|
||||
|
||||
@@ -7,7 +7,7 @@ B06 [상세] 모달을 한 번도 안 연 측점(칸 = 없음)에 B07 이 같은
|
||||
(빈 엔티티)은 덮지 않음 · 옛 글(`structure_note`)이 있으면 옛 글이 먼저
|
||||
(모달이 처음 열 때 옮기는 차례와 같음).
|
||||
그림 = 관 주인 측점 원형 마크(배수관 · Φ · L 정수) + 측점 · 그 측점 구조물마다 지정된
|
||||
구조물 조합 이름 줄(미지정 = 「이름 조합 미지정」) + 그 구조물 좌측 패널 옵션 줄(166-1).
|
||||
구조물 조합 이름 줄(미지정 = 종류 이름만) + 그 구조물 좌측 패널 옵션 줄(166-1).
|
||||
옵션 줄은 파이썬으로 다시 짜지 않음 — 화면과 같은 TS(`structureOptionLines`)를 Node
|
||||
진입점(`B07_DesignDetail_Note_Options_Node.ts` · CLAUDE.md 5장 ②)으로 돌려 받음 ·
|
||||
실패는 비치명(조합 줄만).
|
||||
@@ -109,10 +109,11 @@ def things_at(things: list[Thing], chainage_m: float) -> list[Thing]:
|
||||
def structure_note_lines(things: list[Thing], chainage_m: float) -> list[str]:
|
||||
"""구조물 조합 줄 — TS `structureNoteLines` 짝.
|
||||
|
||||
본체 → 그룹 차례 · 같은 이름 한 줄 · 미지정 = 「이름 조합 미지정」 · 배관 본체 뺌 ·
|
||||
본체 → 그룹 차례 · 같은 이름 한 줄 · 미지정 = 종류 이름만(같은 측점 같은 종류 한 줄) · 배관 본체 뺌 ·
|
||||
이어서 그 구조물 좌측 패널 옵션 줄(`option_lines` · 166-1).
|
||||
"""
|
||||
lines: list[str] = []
|
||||
kinds: set[str] = set()
|
||||
for thing in things_at(things, chainage_m):
|
||||
own = _summary_item(thing.get("summary_item"))
|
||||
slots = ([("", own)] if own else []) + _group_items(thing.get("group_items"))
|
||||
@@ -123,8 +124,9 @@ def structure_note_lines(things: list[Thing], chainage_m: float) -> list[str]:
|
||||
]
|
||||
if slots:
|
||||
lines += list(dict.fromkeys(names))
|
||||
elif not thing["pipe"]:
|
||||
lines.append(f"{thing['name']} 조합 미지정")
|
||||
elif not thing["pipe"] and thing["name"] not in kinds:
|
||||
kinds.add(thing["name"])
|
||||
lines.append(thing["name"])
|
||||
lines += [str(line) for line in thing.get("option_lines") or []]
|
||||
return lines
|
||||
|
||||
|
||||
@@ -125,8 +125,10 @@ export interface Leg {
|
||||
road: Road | null;
|
||||
/** 사용자 값 — 있으면 우선 */
|
||||
km: number | null;
|
||||
/** [길찾기] 자동 값(km 만 저장 · 경로 좌표 · 응답은 저장 안 함) */
|
||||
/** [길찾기] 자동 값 */
|
||||
auto_km?: number | null;
|
||||
/** 마지막 [길찾기] 길 선 [위도, 경도](373 · 소수 5자리 · 점 줄임 · 구간 첫 줄만) — 수기 · 다시 이음 = 없음 */
|
||||
path?: Array<[number, number]> | null;
|
||||
}
|
||||
|
||||
export interface MilestoneRow {
|
||||
|
||||
@@ -284,6 +284,15 @@ export function chainOf(data: MilestoneData, to: string): MilestoneBase[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 시점까지 이어짐(373) — from(거래처 · 기준점 id)이 이어졌고 사슬 끝 기준점이 시점에 닿음 */
|
||||
export function reachesSite(data: MilestoneData, from: string): boolean {
|
||||
const owner = ownerOf(data, from);
|
||||
if (!owner || owner.linked === false) return false;
|
||||
if (owner.to === TO_SITE) return true;
|
||||
const chain = chainOf(data, owner.to);
|
||||
return chain.length > 0 && chain[chain.length - 1].to === TO_SITE;
|
||||
}
|
||||
|
||||
/** 돌기(254) — 기준점 from 을 to 에 이으면 to 사슬이 from 으로 돌아옴(A → B → A · 이음 여부 무관) */
|
||||
export function makesLoop(data: MilestoneData, from: string, to: string): boolean {
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
지금 중점 측점(244-12 · 상자 작은 글씨)
|
||||
POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오
|
||||
길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함)
|
||||
+ 경로 좌표 [위도, 경도] 목록(244-9 · 카카오 지도 길 선 · 그때그때 · 저장 안 함)
|
||||
+ 경로 좌표 [위도, 경도] 목록(244-9 · 카카오 지도 길 선 · 소수 5자리 · 점 줄임 ·
|
||||
373 화면이 구간 `legs[].path` 로 캐시 → [저장])
|
||||
GET /api/projects/{id}/quantity/milestone/reverse?lon=&lat= 지도에서 고른 소재지(244-13) →
|
||||
VWorld 주소 한 줄(서버 단독 · 저장 안 함 · 못 찾음 = null)
|
||||
GET /api/projects/{id}/quantity/milestone/map-key 카카오 지도 JavaScript 키(244-9 ·
|
||||
@@ -60,7 +61,7 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field, ValidationError, model_validator
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
|
||||
|
||||
from B01_Dashboard.B01_Dashboard_Map import reverse_geocode
|
||||
from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
|
||||
@@ -97,6 +98,7 @@ IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
|
||||
ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$"
|
||||
BOX_IDS = {SITE, "start", "mid"} # 다이어그램 공사지 상자 id — 기준점 · 경로 id 로 못 씀
|
||||
|
||||
PATH_MAX = 500 # 구간 길 선 점 수 상한(점 줄인 뒤)
|
||||
Road = Literal["2차포장", "1차포장", "포장", "비포장"] # 포장 = 기준점 안 거친 구간(244-13)
|
||||
Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
|
||||
|
||||
@@ -104,7 +106,18 @@ Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
|
||||
class Leg(BaseModel):
|
||||
road: Road | None = "2차포장" # None = 사용자가 안 고름(244-7 · 표에 「도로 구분 고르기」)
|
||||
km: float | None = Field(default=None, ge=0, le=10000) # 사용자 값 — 있으면 우선
|
||||
auto_km: float | None = Field(default=None, ge=0, le=10000) # [길찾기] 자동 값(km 만 저장)
|
||||
auto_km: float | None = Field(default=None, ge=0, le=10000) # [길찾기] 자동 값
|
||||
# 373 마지막 [길찾기] 길 선 [위도, 경도](소수 5자리 · 점 줄임) — 구간 첫 줄만 · 수기 = 비움
|
||||
path: list[tuple[float, float]] | None = Field(default=None, max_length=PATH_MAX)
|
||||
|
||||
@field_validator("path")
|
||||
@classmethod
|
||||
def _path(cls, value: list[tuple[float, float]] | None) -> list[tuple[float, float]] | None:
|
||||
if not value:
|
||||
return None
|
||||
if any(not (-90 <= lat <= 90 and -180 <= lon <= 180) for lat, lon in value):
|
||||
raise ValueError("길 선 좌표 범위")
|
||||
return [(round(lat, 5), round(lon, 5)) for lat, lon in value]
|
||||
|
||||
|
||||
class Row(BaseModel):
|
||||
@@ -616,10 +629,51 @@ def directions_km(origin: LonLat, destination: LonLat) -> dict[str, Any]:
|
||||
return {"km": math.floor(distance / 100 + 0.5) / 10, "reason": None, "path": path}
|
||||
|
||||
|
||||
def _rdp(points: list[tuple[float, float]], eps: float) -> list[tuple[float, float]]:
|
||||
"""Douglas-Peucker — 두 끝은 남기고 허용 오차(도) 안 점을 뺌."""
|
||||
keep = [False] * len(points)
|
||||
keep[0] = keep[-1] = True
|
||||
stack = [(0, len(points) - 1)]
|
||||
while stack:
|
||||
a, b = stack.pop()
|
||||
(ay, ax), (by, bx) = points[a], points[b]
|
||||
dx, dy = bx - ax, by - ay
|
||||
norm = math.hypot(dx, dy)
|
||||
far, at = -1.0, -1
|
||||
for i in range(a + 1, b):
|
||||
py, px = points[i]
|
||||
d = (
|
||||
abs(dy * (px - ax) - dx * (py - ay)) / norm
|
||||
if norm
|
||||
else math.hypot(px - ax, py - ay)
|
||||
)
|
||||
if d > far:
|
||||
far, at = d, i
|
||||
if at >= 0 and far > eps:
|
||||
keep[at] = True
|
||||
stack += [(a, at), (at, b)]
|
||||
return [p for p, k in zip(points, keep) if k]
|
||||
|
||||
|
||||
def thin_path(path: list[list[float]], limit: int = PATH_MAX) -> list[list[float]]:
|
||||
"""길 선 점 줄임 — 소수 5자리 · 같은 점 이어짐 뺌 · 상한 넘으면 허용 오차를 2배씩 키워 줄임."""
|
||||
points: list[tuple[float, float]] = []
|
||||
for lat, lon in path:
|
||||
p = (round(lat, 5), round(lon, 5))
|
||||
if not points or points[-1] != p:
|
||||
points.append(p)
|
||||
eps = 1e-5
|
||||
while len(points) > limit:
|
||||
points = _rdp(points, eps)
|
||||
eps *= 2
|
||||
return [[lat, lon] for lat, lon in points]
|
||||
|
||||
|
||||
@router.post(PATH + "/directions")
|
||||
async def milestone_directions(project_id: UUID, body: Directions) -> dict[str, Any]:
|
||||
"""[길찾기] 한 구간 — 단추 누를 때만(조작 중 자동 호출 없음)."""
|
||||
return await asyncio.to_thread(directions_km, body.origin, body.destination)
|
||||
"""[길찾기] 한 구간 — 단추 누를 때만(조작 중 자동 호출 없음) · 길 선은 점 줄여 돌려줌(373)."""
|
||||
got = await asyncio.to_thread(directions_km, body.origin, body.destination)
|
||||
return {**got, "path": thin_path(got["path"])} if got.get("path") else got
|
||||
|
||||
|
||||
KAKAO_JS = "KAKAO_JS_KEY"
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
stroke: var(--color-surface);
|
||||
stroke-width: 2;
|
||||
cursor: crosshair;
|
||||
opacity: 0.55;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.b08-milestone__box:hover .b08-milestone__connect {
|
||||
|
||||
@@ -212,9 +212,9 @@ export function createDiagram(
|
||||
* (to = 기준점 id) · 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 자기 · 같은 연결 = 취소 · 기준점 돌기
|
||||
* (A → B → A) = 취소 + 알림 · 바뀌면 그 구간 새로(km 비움) + 길찾기 한 번(km · 거래처 = 경로 그림) */
|
||||
const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => {
|
||||
const cx = box.x + 10;
|
||||
const cy = box.y + 10;
|
||||
const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 6 });
|
||||
const cx = box.x + 12;
|
||||
const cy = box.y + 12;
|
||||
const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 9 });
|
||||
dot.append(svg("title", {}, "끌어서 기준점 · 공사지에 놓으면 연결"));
|
||||
dot.addEventListener("click", (e) => e.stopPropagation()); // 상자 모달 안 뜸
|
||||
dot.addEventListener("pointerdown", (down) => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7)
|
||||
* 도로 구분(152 · 254) = 포장 종류는 선 자리로 정함 — → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 ·
|
||||
* 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`)
|
||||
* 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음)
|
||||
* 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 저장된 길 선 `legs[0].path` · 없으면 열 때 한 번 받음 · 자동 그림 없음)
|
||||
* 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정)
|
||||
* 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음)
|
||||
* 자동 그림(244-11) = 거래처 쪽 구간 [길찾기] · 옮김 때만(`findSegmentKm`)
|
||||
@@ -51,6 +51,7 @@ export function legsEditor(
|
||||
road: HTMLSelectElement;
|
||||
km: HTMLInputElement;
|
||||
auto: number | null;
|
||||
path: Leg["path"]; // 길 선은 칸에 안 보임 · 그대로 가져감
|
||||
orig: number | null; // 안 고친 칸 = 원래 값 그대로(반올림 값으로 안 덮음)
|
||||
shown: string;
|
||||
}> = [];
|
||||
@@ -68,7 +69,14 @@ export function legsEditor(
|
||||
dist.input.placeholder = fmtKm(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선
|
||||
dist.input.title = `자동 값 ${fmtKm(auto)} km`;
|
||||
}
|
||||
const ref = { road: road.select, km: dist.input, auto, orig: leg.km, shown: dist.input.value };
|
||||
const ref = {
|
||||
road: road.select,
|
||||
km: dist.input,
|
||||
auto,
|
||||
path: leg.path,
|
||||
orig: leg.km,
|
||||
shown: dist.input.value,
|
||||
};
|
||||
rows.push(ref);
|
||||
const drop = createButton({
|
||||
label: "×",
|
||||
@@ -96,10 +104,11 @@ export function legsEditor(
|
||||
return {
|
||||
root: el("div", { children: [list, more] }),
|
||||
value: () =>
|
||||
rows.map(({ road, km: dist, auto, orig, shown }) => ({
|
||||
rows.map(({ road, km: dist, auto, path, orig, shown }) => ({
|
||||
road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null,
|
||||
km: dist.value === shown ? orig : km(dist.value),
|
||||
auto_km: auto,
|
||||
...(path ? { path } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -199,7 +208,7 @@ export function openLegInfo(projectId: string, from: string): void {
|
||||
return out;
|
||||
};
|
||||
const drawMap = (): void => {
|
||||
const path = segmentPath(from);
|
||||
const path = segmentPath(projectId, from);
|
||||
map.set(pins(), path.length >= 2 ? [path] : []);
|
||||
};
|
||||
const run = async (image = true): Promise<void> => {
|
||||
@@ -268,7 +277,9 @@ export function openLegInfo(projectId: string, from: string): void {
|
||||
const fresh = liveMilestone(projectId);
|
||||
const target = owner(fresh);
|
||||
if (!target) return;
|
||||
target.legs = legs.value().map((l) => (manual.input.checked ? { ...l, auto_km: null } : l));
|
||||
target.legs = legs
|
||||
.value()
|
||||
.map((l) => (manual.input.checked ? { ...l, auto_km: null, path: null } : l));
|
||||
target.manual = manual.input.checked;
|
||||
putMilestone(projectId, fresh);
|
||||
redrawLegs();
|
||||
@@ -279,7 +290,8 @@ export function openLegInfo(projectId: string, from: string): void {
|
||||
showMode();
|
||||
drawMap();
|
||||
// 길 선이 아직 없으면 열 때 한 번(km 같으면 안 씀 · 자동 그림 없음 · 수기 입력이면 안 함)
|
||||
if (!manual.input.checked && !segmentPath(from).length && seg.from && seg.to) void run(false);
|
||||
if (!manual.input.checked && !segmentPath(projectId, from).length && seg.from && seg.to)
|
||||
void run(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*
|
||||
* [그림 변경] = 사용자 그림 올림(이 탭 = 그 그림) · [초기화] = 사용자 그림만 지움(길찾기 지도로 · 즉시 서버)
|
||||
* 서버 자동 그림(244-11 · 선 이을 때 · 서류용)은 그대로 만듦 · 이 탭에는 안 보임(157)
|
||||
* 373 연결 없어도 소재지 넣기 — 지도 누름 = 그 자리 소재지 · 머리 줄 [소재지] = 등록 창(`openPlaceModal`) ·
|
||||
* 운반 자동 상자(주소 · 선 · 좌표 없음)도 · 길 선 = 저장된 구간 `path` · 안 이은 기준점에 이음 = 「시점 미연결」
|
||||
* 255-2 시점도 끌기 = 길찾기용 덮개(`site.start_pin`) · 표지 옮기면 그 자리에 닿는 구간 모두 다시 길찾기 ·
|
||||
* 열 때 선 없는 사슬 구간 모두 길찾기 · 실패 = 안내 한 줄 · 끌기는 그림 캡처 안 함
|
||||
* ========================================================================== */
|
||||
@@ -16,9 +18,10 @@ import {
|
||||
} from "@ui/ui_template_context_menu";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import type { MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
||||
import { chainOf, ownerOf } from "./B08_Quantity_Milestone_Layout";
|
||||
import { chainOf, ownerOf, reachesSite } from "./B08_Quantity_Milestone_Layout";
|
||||
import { DEST_TEXT } from "./B08_Quantity_Milestone_Table";
|
||||
import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap";
|
||||
import { openPlaceModal } from "./B08_Quantity_UI_Milestone_Modal";
|
||||
import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place";
|
||||
import {
|
||||
capturePlace,
|
||||
@@ -122,6 +125,13 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||
),
|
||||
});
|
||||
reset.dataset.b08MilestoneRevert = "";
|
||||
// 373 [소재지] — 연결 없이도 주소 넣기(거래처 등록 창 그대로)
|
||||
const place = createButton({
|
||||
label: "소재지",
|
||||
variant: "ghost",
|
||||
onClick: () => openPlaceModal(projectId, { add: false, id: routeId }),
|
||||
});
|
||||
place.dataset.b08MilestonePlaceOpen = "";
|
||||
|
||||
const pins = (): MapPin[] => {
|
||||
const d = liveMilestone(projectId);
|
||||
@@ -142,12 +152,16 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||
const d = liveMilestone(projectId);
|
||||
const r = d.routes.find((x) => x.id === routeId);
|
||||
const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : [];
|
||||
return [routeId, ...chain].map(segmentPath).filter((p) => p.length >= 2);
|
||||
return [routeId, ...chain].map((id) => segmentPath(projectId, id)).filter((p) => p.length >= 2);
|
||||
};
|
||||
// 255-2 길찾기 실패 = 안내 한 줄(까닭 줄 = 「 — 」)
|
||||
const status = el("p", { className: "b08-options__hint" });
|
||||
status.dataset.b08MilestoneRouteStatus = "";
|
||||
status.hidden = true;
|
||||
// 373 안 이은 기준점에 이음 = 시점까지 안 닿음 안내(이은 거래처만)
|
||||
const open = el("p", { className: "b08-options__hint" });
|
||||
open.dataset.b08MilestoneRouteOpen = "";
|
||||
open.hidden = true;
|
||||
/** 길 선 여럿 다시 길찾기(그림 캡처 없음 · 캡처는 [저장] 때) → 선 다시 · 실패 안내 */
|
||||
const refind = async (ids: string[]): Promise<void> => {
|
||||
const texts = await Promise.all(ids.map((id) => findSegmentKm(projectId, id, false)));
|
||||
@@ -172,8 +186,10 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||
await refind(touchingSegments(fresh, key));
|
||||
};
|
||||
const map = editMap(projectId, {
|
||||
hint: "",
|
||||
hint: "지도 누름 = 그 자리를 소재지로 · 표지 끌어 옮김",
|
||||
onClick: (lon, lat) => void moveTo(routeId, lon, lat),
|
||||
onMove: (key, lon, lat) => void moveTo(key, lon, lat),
|
||||
center: () => startPoint(liveMilestone(projectId)),
|
||||
});
|
||||
const drawMap = (): void => map.set(pins(), lines());
|
||||
let asked = false; // 길 선이 없으면 열 때 한 번 길찾기(사슬 선 모두 · 그림 · 알림 없음)
|
||||
@@ -183,7 +199,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||
const d = liveMilestone(projectId);
|
||||
const r = d.routes.find((x) => x.id === routeId);
|
||||
const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : [];
|
||||
const missing = [routeId, ...chain].filter((id) => !segmentPath(id).length);
|
||||
const missing = [routeId, ...chain].filter((id) => !segmentPath(projectId, id).length);
|
||||
if (missing.length) void refind(missing);
|
||||
};
|
||||
|
||||
@@ -195,10 +211,11 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||
className: "b08-milestone__route-head",
|
||||
children: [
|
||||
el("div", { className: "b08-milestone__route-title", children: [name, address] }),
|
||||
el("div", { className: "b08-milestone__bar", children: [change, reset, file] }),
|
||||
el("div", { className: "b08-milestone__bar", children: [place, change, reset, file] }),
|
||||
],
|
||||
}),
|
||||
status,
|
||||
open,
|
||||
img,
|
||||
map.root,
|
||||
],
|
||||
@@ -210,6 +227,8 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||
if (!r) return;
|
||||
name.textContent = `${r.name || "거래처"} → ${DEST_TEXT[r.rows[0]?.dest ?? "start"]}`;
|
||||
address.textContent = r.address || "";
|
||||
open.hidden = r.linked === false || reachesSite(d, routeId);
|
||||
open.textContent = open.hidden ? "" : "시점 미연결 — 이은 기준점이 시점에 이어지지 않았습니다.";
|
||||
loadImage();
|
||||
const user = hasUserImage(routeId);
|
||||
reset.hidden = !user;
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
* 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴).
|
||||
* [길찾기](244-7) = 구간마다 서버 POST …/directions(카카오 키는 서버만) → 자동 km 를 캐시에 ·
|
||||
* 단추 누를 때만(조작 중 자동 호출 없음) · km 만 남김(경로 좌표 · 응답 원문 없음)
|
||||
* 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐)
|
||||
* 경로 좌표(244-9 · 373) = 마지막 [길찾기] 길 선을 구간 첫 줄 `legs[0].path` 에(캐시 → [저장] 정본 ·
|
||||
* 다시 이으면 옛 거리 · 선 지우고 새 값 · 실패 = 빈칸 + 까닭 · 수기 = 지움)
|
||||
* 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림
|
||||
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
|
||||
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭)
|
||||
* 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 · 기준점 → to(기준점 · 시점) · 254) ·
|
||||
* 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점)
|
||||
* 장소 그림(256-1) = 상자 좌표가 정해지면 `capturePlace` → 서버 캡처 `place/<상자 id>.png` ·
|
||||
@@ -29,7 +30,13 @@ import {
|
||||
type MilestoneData,
|
||||
type SegKind,
|
||||
} from "./B08_Quantity_Milestone_Calc";
|
||||
import { mapMarks, ownerOf, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout";
|
||||
import {
|
||||
mapMarks,
|
||||
ownerOf,
|
||||
putAutoKm,
|
||||
reachesSite,
|
||||
segmentEnds,
|
||||
} from "./B08_Quantity_Milestone_Layout";
|
||||
import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal";
|
||||
|
||||
export interface SiteInfo {
|
||||
@@ -278,12 +285,30 @@ export function roadOptions(kind: SegKind): Array<{ value: string; text: string
|
||||
return [PAVED[kind], "비포장"].map((r) => ({ value: r, text: r }));
|
||||
}
|
||||
|
||||
/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 · 기준점 id) → [위도, 경도] 목록(메모리만 · 저장 안 함) */
|
||||
const paths = new Map<string, Array<[number, number]>>();
|
||||
export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? [];
|
||||
/** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */
|
||||
export const routePaths = (routeId: string): Array<Array<[number, number]>> =>
|
||||
paths.has(routeId) ? [paths.get(routeId)!] : [];
|
||||
/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 · 기준점 id) → [위도, 경도] 목록(캐시 자료의 구간 첫 줄) */
|
||||
export const segmentPath = (projectId: string, from: string): Array<[number, number]> =>
|
||||
ownerOf(liveMilestone(projectId), from)?.legs[0]?.path ?? [];
|
||||
|
||||
/** 찾은 값을 구간 첫 줄에 — km · path 가 null 이면 옛 값 지움(옛 거리 · 선 안 남김) · 바뀔 때만 캐시에 */
|
||||
function putFound(
|
||||
projectId: string,
|
||||
from: string,
|
||||
km: number | null,
|
||||
path: Array<[number, number]> | null,
|
||||
): void {
|
||||
const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에
|
||||
const owner = ownerOf(fresh, from);
|
||||
const before = JSON.stringify(owner?.legs[0] ?? null);
|
||||
if (km != null) putAutoKm(fresh, from, km);
|
||||
const leg = owner?.legs[0];
|
||||
if (leg) {
|
||||
if (km == null && leg.auto_km != null) leg.auto_km = null;
|
||||
if (path) leg.path = path;
|
||||
else delete leg.path;
|
||||
}
|
||||
if (JSON.stringify(leg ?? null) !== before) putMilestone(projectId, fresh);
|
||||
else changes.dispatchEvent(new Event("change"));
|
||||
}
|
||||
|
||||
/** [길찾기] 한 구간(연결선 · 244-13) — 서버가 카카오로 km · 자동 값을 캐시에(sameKm = 같으면 안 씀) ·
|
||||
* 거래처 쪽 구간 = 자동 그림(뒤에서) · 돌려줌 = 결과 한 줄(까닭 줄엔 「 — 」) */
|
||||
@@ -299,6 +324,7 @@ export async function findSegmentKm(
|
||||
if (!seg) return "";
|
||||
if (!seg.from || !seg.to) {
|
||||
const missing = seg.text.split(" → ")[seg.from ? 1 : 0];
|
||||
putFound(projectId, from, null, null);
|
||||
return `${seg.text} — ${missing} 좌표가 없습니다(소재지 · 노선) · km 를 손으로 넣으세요.`;
|
||||
}
|
||||
try {
|
||||
@@ -313,18 +339,17 @@ export async function findSegmentKm(
|
||||
destination: { lon: seg.to[0], lat: seg.to[1] },
|
||||
}),
|
||||
});
|
||||
if (res.km == null) return `${seg.text} — ${res.reason ?? "길찾기 실패"}`;
|
||||
if ((res.path?.length ?? 0) >= 2) paths.set(from, res.path!);
|
||||
else paths.delete(from);
|
||||
const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에
|
||||
if (ownerOf(fresh, from)?.legs[0]?.auto_km !== res.km) {
|
||||
putAutoKm(fresh, from, res.km);
|
||||
putMilestone(projectId, fresh);
|
||||
} else changes.dispatchEvent(new Event("change"));
|
||||
if (image && seg.key === "route" && paths.has(from))
|
||||
void autoRouteImage(projectId, from, routePaths(from)); // 뒤에서 · km 알림 안 기다림
|
||||
return `${seg.text} ${res.km} km`;
|
||||
if (res.km == null) {
|
||||
putFound(projectId, from, null, null);
|
||||
return `${seg.text} — ${res.reason ?? "길찾기 실패"}`;
|
||||
}
|
||||
const path = (res.path?.length ?? 0) >= 2 ? res.path! : null;
|
||||
putFound(projectId, from, res.km, path);
|
||||
if (image && seg.key === "route" && path) void autoRouteImage(projectId, from, [path]); // 뒤에서 · km 알림 안 기다림
|
||||
const open = reachesSite(liveMilestone(projectId), from) ? "" : " — 시점 미연결";
|
||||
return `${seg.text} ${res.km} km${open}`;
|
||||
} catch (error) {
|
||||
putFound(projectId, from, null, null);
|
||||
return `${seg.text} — 길찾기 요청 실패(${String(error)}) · km 를 손으로 넣으세요.`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +326,14 @@ def refuse_used(folder: Path, kind: str, key: str) -> None:
|
||||
|
||||
|
||||
# ── 층 사이 복사 ──────────────────────────────────────────────────────
|
||||
def origin_mark(doc: dict[str, Any], ref: Ref) -> dict[str, Any]:
|
||||
"""복사 문서의 `본뜬` — 실무자료 본뜬(키 · 전국자재 …)은 지키고 원본 참조는 `복사원본` 칸에 둠."""
|
||||
own = doc.get("본뜬")
|
||||
if isinstance(own, dict) and own.get("층") == "실무자료":
|
||||
return {**doc, "본뜬": {**own, "복사원본": ref}}
|
||||
return {**doc, "본뜬": ref}
|
||||
|
||||
|
||||
def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]:
|
||||
"""복사(원본 층 `source` → 지금 층) — 지금 층이 못 읽는 참조는 그 문서를 지금 층에 새 키로
|
||||
같이 복사(`본뜬` = 원래 참조) · 참조를 새 키로 바꿈. 원본 층 읽기 권한은 부르는 쪽 몫."""
|
||||
@@ -341,7 +349,7 @@ def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]:
|
||||
got = read_ref(ref)
|
||||
if got is None:
|
||||
raise _bad(f"없는 참조 「{ref['키']}」")
|
||||
body = {**got, "담은로직": _moved(got.get("담은로직"), move, depth + 1), "본뜬": ref}
|
||||
body = origin_mark({**got, "담은로직": _moved(got.get("담은로직"), move, depth + 1)}, ref)
|
||||
made = create(kind_of(ref["키"]), body)
|
||||
done[ref_id(ref)] = {"층": target[0], "주인": target[1], "키": made["이름"]}
|
||||
return done[ref_id(ref)]
|
||||
|
||||
@@ -97,7 +97,7 @@ class LibraryBody(BaseModel):
|
||||
|
||||
|
||||
def _copy(kind: str, doc: dict[str, Any], source: refs.Here, ref: dict) -> dict[str, Any]:
|
||||
return refs.create(kind, refs.carry({**doc, "본뜬": ref}, source))
|
||||
return refs.create(kind, refs.carry(refs.origin_mark(doc, ref), source))
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/library/{kind}")
|
||||
|
||||
@@ -127,9 +127,9 @@ export function buildOrder(ones: { 번: number; 아래: number[] }[]): number[]
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 단가산출 행 → 행 전체 범위 A1(`4:9`) */
|
||||
/** 단가산출 행 → B ~ F 열 범위 A1(`B4:F9`) */
|
||||
export const rowsText = (행: number | [number, number]): string =>
|
||||
typeof 행 === "number" ? `${행}:${행}` : `${행[0]}:${행[1]}`;
|
||||
typeof 행 === "number" ? `B${행}:F${행}` : `B${행[0]}:F${행[1]}`;
|
||||
|
||||
export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
let sheet: SpreadsheetHandle | null = null;
|
||||
|
||||
@@ -111,12 +111,15 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
scope,
|
||||
key: file.키,
|
||||
파일: file.파일,
|
||||
canWrite: opt.canWrite,
|
||||
// 사전조합은 어느 화면 층에서든 로그인한 사람 개인 층에 만듦 — 회사 · 마스터는 구조물 카드 [가져오기]
|
||||
target: { layer: "personal", owner: null },
|
||||
canWrite: true,
|
||||
onClose: () => {
|
||||
if (sheetKey === file.키) sheetKey = null;
|
||||
paintList();
|
||||
},
|
||||
onDeleted: () => void refresh(),
|
||||
// [삭제]는 실무자료 파일이 있는 화면 층 쓰기 권한 때만
|
||||
onDeleted: opt.canWrite ? () => void refresh() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,16 @@ export class SectionGeometry {
|
||||
this.slopePerOffset = lowLeft ? -slope : slope;
|
||||
}
|
||||
|
||||
// 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 노견(폭 고정) · 측구 · 사면이 같이
|
||||
// 바깥으로 · 늘어난 폭은 차도 · 단면유형 · 측구 판정은 **옮긴 뒤** 노견에서(아래 · 370-1 — 마루를
|
||||
// 넘으면 그 쪽 사면 기울기로 원지반까지). ⚠ 파이썬 짝 `_SectionGeometry`.
|
||||
this.shoulderExtendLeft = Math.max(params.shoulderExtendLeftM ?? 0, 0);
|
||||
this.shoulderExtendRight = Math.max(params.shoulderExtendRightM ?? 0, 0);
|
||||
this.halfRoadLeft += this.shoulderExtendLeft;
|
||||
this.halfRoadRight += this.shoulderExtendRight;
|
||||
this.leftExtent += this.shoulderExtendLeft;
|
||||
this.rightExtent += this.shoulderExtendRight;
|
||||
|
||||
// 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토.
|
||||
const groundAt = params.groundAt;
|
||||
if (groundAt !== null) {
|
||||
@@ -226,15 +236,6 @@ export class SectionGeometry {
|
||||
// 물넘이포장 · 맹암거 자리(286-2 · 288-1) — 측구가 안 섬 · 선택은 그대로(파이썬 짝)
|
||||
if (params.tilted) this.hasDitch = false;
|
||||
|
||||
// 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전**
|
||||
// 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. ⚠ 파이썬 짝 `_SectionGeometry`.
|
||||
this.shoulderExtendLeft = Math.max(params.shoulderExtendLeftM ?? 0, 0);
|
||||
this.shoulderExtendRight = Math.max(params.shoulderExtendRightM ?? 0, 0);
|
||||
this.halfRoadLeft += this.shoulderExtendLeft;
|
||||
this.halfRoadRight += this.shoulderExtendRight;
|
||||
this.leftExtent += this.shoulderExtendLeft;
|
||||
this.rightExtent += this.shoulderExtendRight;
|
||||
|
||||
// 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
|
||||
const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent;
|
||||
const outward = params.ditchSide === "left" ? 1 : -1;
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
**격자 해석은 하지 않는다.** B04 배수유역 분석이 미리 돌려 저장한 결과를 읽어, 사용자가
|
||||
실제로 손대는 두 가지만 처리한다(2026-07-31 사용자 지시).
|
||||
|
||||
⑨ 관 간격이 최대치를 넘는 구간에 **최소 개수**로 관을 보충
|
||||
⑨ 관 간격이 최대치를 넘는 구간에 **최소 개수**로 관을 보충([자동 제안] 때만 · 기본 = 세류선
|
||||
교차만)
|
||||
⑩ 측구 흐름으로 도로 셀 → 담당 관을 정하고, 셀이 도달한 도로 셀의 담당 관을 그대로
|
||||
그 셀의 유역 번호로 삼아 세부유역을 나눈다
|
||||
⑪ 사용자가 관을 옮기거나 추가하면 ⑩만 다시 돈다 — 격자 해석은 재사용한다
|
||||
@@ -25,7 +26,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -65,6 +66,10 @@ _SCORE_WEIGHT_STRENGTH = 0.7
|
||||
_SCORE_WEIGHT_SAG = 0.3
|
||||
# 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다.
|
||||
_SCORE_FILL_PENALTY = 0.5
|
||||
# [자동 제안] 전체 자동(369) — 새 자리에서 이만큼(m) 안의 지금 관 = 같은 관(그 자리 그대로 ·
|
||||
# 속성 이어받음). 화면 `SAME_PIPE_M`(제안 · 같은 유역)과 같은 값 — 노선 따라감으로 cm 어긋난
|
||||
# 세월교도 같은 관.
|
||||
SAME_PIPE_M = 0.5
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -167,21 +172,25 @@ def build_detail(
|
||||
confirmed_chainages: list[float] | None = None,
|
||||
routing: RoadRouting | None = None,
|
||||
upstream_lonlat: list[list[list[float]]] | None = None,
|
||||
auto: str | None = None,
|
||||
) -> DrainageDetail | None:
|
||||
"""B04 분석 결과를 읽어 관을 보충하고 세부유역을 나눈다.
|
||||
"""B04 분석 결과를 읽어 관 자리를 정하고 세부유역을 나눈다.
|
||||
|
||||
`confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집 · 빈 목록 = 관 0개), None 이면
|
||||
B04의 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다.
|
||||
B04의 기본 관(세류선 교차)만. 어느 쪽이든 격자 해석은 하지 않는다.
|
||||
`auto`([자동 제안] · 369) — None = 위 그대로 · "all" = 기본 관 + 최대 간격 보충으로 다시
|
||||
(확정 목록 무시) · "add" = 확정 목록 + 빈 구간 보충. 관 자리 후보는 어느 길이든 셈.
|
||||
`routing` · `upstream_lonlat` 을 주면 파일 대신 그것(「지금 노선으로 셈」 메모리 결과 · 347-2).
|
||||
"""
|
||||
routing = routing or read_road_routing(directory)
|
||||
if routing is None or len(vertices) < 2:
|
||||
return None
|
||||
|
||||
candidates: list[StructureCandidate] = []
|
||||
if confirmed_chainages is not None:
|
||||
if confirmed_chainages is not None and auto != "all":
|
||||
pipes = pipes_from_chainages(vertices, confirmed_chainages)
|
||||
candidates = suggest_pipe_positions(vertices, pipes, routing.strength_curve)
|
||||
if auto == "add":
|
||||
pipes = [*pipes, *suggest_pipe_positions(vertices, pipes, routing.strength_curve)]
|
||||
pipes.sort(key=lambda pipe: pipe.chainage_m)
|
||||
else:
|
||||
# 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다.
|
||||
base = [
|
||||
@@ -194,7 +203,22 @@ def build_detail(
|
||||
for pipe in routing.base_pipes
|
||||
]
|
||||
# 자동 관 자리 = 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 확정본(위)은 단추로만
|
||||
pipes = snap_candidates(place_pipes(vertices, base, routing.strength_curve), vertices)
|
||||
placed = place_pipes(vertices, base, routing.strength_curve, fill=auto is not None)
|
||||
pipes = snap_candidates(placed, vertices)
|
||||
if confirmed_chainages:
|
||||
kept = pipes_from_chainages(vertices, confirmed_chainages)
|
||||
pipes = [
|
||||
replace(
|
||||
min(
|
||||
(k for k in kept if abs(k.chainage_m - pipe.chainage_m) <= SAME_PIPE_M),
|
||||
key=lambda k: abs(k.chainage_m - pipe.chainage_m),
|
||||
default=pipe,
|
||||
),
|
||||
reason=pipe.reason,
|
||||
)
|
||||
for pipe in pipes
|
||||
]
|
||||
candidates = suggest_pipe_positions(vertices, pipes, routing.strength_curve)
|
||||
|
||||
detail = DrainageDetail(
|
||||
route_lonlat=routing.route_lonlat,
|
||||
@@ -386,10 +410,12 @@ def place_pipes(
|
||||
vertices: list[RouteVertex],
|
||||
base_pipes: list[StructureCandidate],
|
||||
strength_curve: np.ndarray,
|
||||
fill: bool = True,
|
||||
) -> list[StructureCandidate]:
|
||||
"""B04가 정한 기본 관(세류 교차점)에, 최대 간격을 넘는 구간만 최소 개수로 보충한다.
|
||||
|
||||
기본 관은 여기서 다시 찾지 않는다 — B04 산출물에 이미 들어 있다.
|
||||
기본 관은 여기서 다시 찾지 않는다 — B04 산출물에 이미 들어 있다. `fill=False` = 보충 없이
|
||||
기본 관만(최소 간격 겹침만 걸러 냄 · 369 기본).
|
||||
"""
|
||||
total_length = vertices[-1].chainage_m
|
||||
base: list[StructureCandidate] = []
|
||||
@@ -397,6 +423,8 @@ def place_pipes(
|
||||
if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M:
|
||||
continue
|
||||
base.append(candidate)
|
||||
if not fill:
|
||||
return base
|
||||
|
||||
filled: list[StructureCandidate] = []
|
||||
previous = 0.0
|
||||
|
||||
@@ -786,7 +786,8 @@ export function createMassHaulLegend(
|
||||
reset.textContent = L("B06_MassHaul_Balloon_Reset");
|
||||
reset.title = L("B06_MassHaul_Balloon_Reset_Tip");
|
||||
reset.addEventListener("click", onResetBalloons);
|
||||
legend.append(reset);
|
||||
// [횡단 기준] 왼쪽(367-1)
|
||||
legend.prepend(reset);
|
||||
}
|
||||
return legend;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { catalogRow } from "./common_util_structure_catalog";
|
||||
|
||||
const EDGE_TOLERANCE_M = 0.02;
|
||||
|
||||
/** 맹암거 제원 — 높이 · 경사가 비면 null(높이 없으면 형상 없음 · 경사 없으면 표준 횡단경사). */
|
||||
/** 맹암거 제원 — 높이 · 경사가 비면 null(높이 없으면 형상 없음 · 경사 = 형상 하단 경계 · 없으면 노면과 평행 · 371-3). */
|
||||
export interface UnderdrainSpec {
|
||||
structure_id: string | null;
|
||||
chainage_m: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 종단설계 설명서
|
||||
# 노선설계 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
|
||||
@@ -288,7 +288,12 @@ const designLines = kids.filter((k) => k.classList.contains("b06-chart__design-c
|
||||
const stopped = hits[0].click();
|
||||
handle.setActive(1);
|
||||
const activeOn = hits.map((h) => h.classList.contains("is-active"));
|
||||
const labelOn = { hidden: handle.label.hidden, text: handle.label.textContent };
|
||||
// 아래 띠 공용 칸(370-3) — 빈 칸 = is-empty · 값 = 버튼 줄 끝 글
|
||||
const cellNow = () => ({
|
||||
hidden: handle.cell.classList.contains("is-empty"),
|
||||
text: handle.cell.children[1].children.at(-1).textContent,
|
||||
});
|
||||
const labelOn = cellNow();
|
||||
handle.setActive(null);
|
||||
out.real = {
|
||||
designLines,
|
||||
@@ -303,7 +308,7 @@ out.real = {
|
||||
activeCount: activeOn.filter(Boolean).length,
|
||||
activeIndex: activeOn.indexOf(true),
|
||||
labelOn,
|
||||
labelOff: { hidden: handle.label.hidden, text: handle.label.textContent },
|
||||
labelOff: cellNow(),
|
||||
zones: [...new Set(handle.pieces.map((p) => pick.pieceZone(pipeSection.design, p).zone))].sort(),
|
||||
};
|
||||
|
||||
|
||||
@@ -131,7 +131,8 @@ function reshaped(rise, slope) {
|
||||
const s = clone(pipe);
|
||||
for (const p of s.samples) {
|
||||
const t = ((p.offset_m ?? 0) - start.offset) * outward;
|
||||
if (t >= 0 && typeof p.elevation_m === "number")
|
||||
// 시작점 바로 앞 표본까지(−1 m) — 보간이 시작점을 옛 지반에 두지 않게(371-1 벽 자리 이동)
|
||||
if (t >= -1 && typeof p.elevation_m === "number")
|
||||
p.elevation_m = start.elevation + rise + slope * t;
|
||||
}
|
||||
return study(
|
||||
@@ -141,4 +142,5 @@ function reshaped(rise, slope) {
|
||||
}
|
||||
out.pipeDown = reshaped(0.3, -0.15); // 3도보다 가파르게 내림 → 3도 선이 만남
|
||||
out.pipeUp = reshaped(0.3, 0.1); // 오르막 → 3도 선이 못 만남 → 복합경사
|
||||
out.pipeOpen = reshaped(-0.3, -0.5); // 끝 바깥이 지반 아래로 열림 → 성토부선(371-1)
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
|
||||
@@ -147,21 +147,21 @@ const handle = pick.attachDesignLinePick({
|
||||
set: (side, v) => (values[side] = v),
|
||||
},
|
||||
});
|
||||
const panel = handle.panel;
|
||||
const slot = (name) => panel.querySelector(`.b06-structure-panel__btn--${name}`);
|
||||
const close = panel.querySelector(".b06-structure-panel__close");
|
||||
// 아래 띠 공용 칸(370-3) — 제목 · 버튼 · 값 한 줄
|
||||
const cell = handle.cell;
|
||||
const slot = (name) => cell.querySelector(`.b06-cross-card__cell-btn--${name}`);
|
||||
const cellTitle = () => cell.children[0].textContent;
|
||||
const cellValue = () => cell.children[1].children.at(-1).textContent;
|
||||
const pieceAt = (offset) =>
|
||||
handle.pieces.findIndex(
|
||||
(p) =>
|
||||
Math.min(p.from.offset, p.to.offset) <= offset &&
|
||||
offset <= Math.max(p.from.offset, p.to.offset),
|
||||
);
|
||||
const hiddenAtStart = panel.classList.contains("is-hidden");
|
||||
const hiddenAtStart = cell.classList.contains("is-empty");
|
||||
const log = [];
|
||||
handle.setActive(pieceAt(4)); // 좌 사면
|
||||
const titleLeft = panel.descendants.find((n) =>
|
||||
n._cls.has("b06-structure-panel__title"),
|
||||
).textContent;
|
||||
const titleLeft = cellTitle();
|
||||
slot("left").click(); // 화면 왼쪽 = 좌측 바깥 → +0.1
|
||||
key("ArrowLeft"); // 방향키 = 십자
|
||||
log.push({ ...values });
|
||||
@@ -174,26 +174,25 @@ key("ArrowLeft"); // 화면 왼쪽 = 우측 안쪽 → 0 에서 멈춤
|
||||
slot("right").click(); // 우측 바깥 → +0.1
|
||||
log.push({ ...values });
|
||||
handle.setActive(pieceAt(-4));
|
||||
const text = handle.label.textContent;
|
||||
const readout = panel.descendants.find((n) => n._cls.has("b06-structure-panel__value")).textContent;
|
||||
const text = cellValue();
|
||||
const slots = ["left", "up", "reset", "down", "right"]
|
||||
.map((n) => slot(n))
|
||||
.filter((b) => !b.hidden)
|
||||
.map((b) => b.textContent);
|
||||
slot("reset").click(); // ↺ = 그 쪽 0
|
||||
const afterReset = { ...values };
|
||||
close.click(); // ✕ = 같은 조각 다시 고름(카드가 풂)
|
||||
handle.setActive(null);
|
||||
key("ArrowRight"); // 숨은 패널은 방향키 안 먹음
|
||||
key("ArrowRight"); // 빈 칸은 방향키 안 먹음
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
log,
|
||||
text,
|
||||
readout,
|
||||
titleLeft,
|
||||
afterReset,
|
||||
afterHidden: { ...values },
|
||||
picks,
|
||||
pieceRight: pieceAt(-4),
|
||||
hiddenAtStart,
|
||||
hiddenAfter: panel.classList.contains("is-hidden"),
|
||||
labelButtons: handle.label.children.length,
|
||||
slots: ["left", "reset", "right"].map((n) => slot(n).textContent),
|
||||
hiddenAfter: cell.classList.contains("is-empty"),
|
||||
slots,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -173,13 +173,10 @@ function card(sectionMode, ditch) {
|
||||
Math.min(p.from.offset, p.to.offset) <= offset &&
|
||||
offset <= Math.max(p.from.offset, p.to.offset),
|
||||
);
|
||||
const slot = (name) => handle.panel.querySelector(`.b06-structure-panel__btn--${name}`);
|
||||
const title = () =>
|
||||
handle.panel.descendants.find((n) => n._cls.has("b06-structure-panel__title")).textContent;
|
||||
const rowHidden = () =>
|
||||
handle.panel.descendants
|
||||
.find((n) => n._cls.has("b06-structure-panel__struct"))
|
||||
._cls.has("is-hidden");
|
||||
// 아래 띠 공용 칸(370-3) — 제목 · 버튼 줄(◀▲↺▼▶ 보임만 바뀜) · 값 한 줄
|
||||
const slot = (name) => handle.cell.querySelector(`.b06-cross-card__cell-btn--${name}`);
|
||||
const title = () => handle.cell.children[0].textContent;
|
||||
const rowHidden = () => ["left", "reset", "right"].every((name) => slot(name).hidden);
|
||||
return { design, handle, values, at, slot, title, rowHidden };
|
||||
}
|
||||
|
||||
@@ -236,7 +233,7 @@ const out = {};
|
||||
same: JSON.stringify(before) === JSON.stringify(c.values),
|
||||
title: c.title(),
|
||||
rowHidden: c.rowHidden(),
|
||||
label: c.handle.label.textContent,
|
||||
label: c.handle.cell.children[1].children.at(-1).textContent,
|
||||
};
|
||||
// 고름 풀면 아무 키도 안 막음
|
||||
c.handle.setActive(null);
|
||||
|
||||
@@ -176,15 +176,17 @@ function card({ dl = 1, stored = undefined, order = ["left", "road", "right"], s
|
||||
leftUpper: find((p) => !pick.isBermPiece(design, p) && mid(p) > 2 && mid(p) < 2 + 1.5 * dl),
|
||||
road: find((p) => mid(p) > 0 && mid(p) < 2),
|
||||
};
|
||||
// 아래 띠 공용 칸(370-3) — 버튼 다섯 칸 한 벌(보임만 바뀜) · 제목 · 값 한 줄
|
||||
const slot = (name) =>
|
||||
handle.panel.descendants.find(
|
||||
(n) => n._cls.has(`b06-structure-panel__btn--${name}`) && !n.parentHidden,
|
||||
);
|
||||
const rows = () =>
|
||||
handle.panel.descendants
|
||||
.filter((n) => n._cls.has("b06-structure-panel__struct"))
|
||||
.map((n) => n._cls.has("is-hidden"));
|
||||
const text = (cls) => handle.panel.descendants.find((n) => n._cls.has(cls)).textContent;
|
||||
handle.cell.descendants.find((n) => n._cls.has(`b06-cross-card__cell-btn--${name}`));
|
||||
const rows = () => [
|
||||
slot("left").hidden && slot("right").hidden,
|
||||
slot("up").hidden && slot("down").hidden,
|
||||
];
|
||||
const text = (what) =>
|
||||
what === "title"
|
||||
? handle.cell.children[0].textContent
|
||||
: handle.cell.children[1].children.at(-1).textContent;
|
||||
return { design, handle, values, sets, focus, at, slot, rows, text };
|
||||
}
|
||||
|
||||
@@ -212,13 +214,10 @@ const out = {};
|
||||
const afterSide = { values: { ...c.values }, sets: c.sets.length };
|
||||
const upTaken = press("ArrowUp");
|
||||
press("ArrowDown");
|
||||
c.handle.panel.descendants
|
||||
.filter((n) => n._cls.has("b06-structure-panel__btn--reset"))
|
||||
.at(-1)
|
||||
.click();
|
||||
c.slot("reset").click();
|
||||
out.berm = {
|
||||
title: c.text("b06-structure-panel__title"),
|
||||
readout: c.text("b06-structure-panel__value"),
|
||||
title: c.text("title"),
|
||||
readout: c.text("value"),
|
||||
moveHidden,
|
||||
liftHidden,
|
||||
leftBlocked,
|
||||
@@ -232,7 +231,7 @@ const out = {};
|
||||
const before = c.sets.length;
|
||||
out.berm.slopeUpBlocked = press("ArrowUp");
|
||||
out.berm.slopeSets = c.sets.length - before;
|
||||
out.berm.slopeTitle = c.text("b06-structure-panel__title");
|
||||
out.berm.slopeTitle = c.text("title");
|
||||
out.berm.focusAfter = c.focus.at(-1);
|
||||
// 노면 = ▲▼ 는 종단도로 넘김(안 막음)
|
||||
c.handle.setActive(c.at.road);
|
||||
|
||||
@@ -26,7 +26,9 @@ function loadTs(file) {
|
||||
}
|
||||
|
||||
const free = loadTs(path.join(ROOT, "A00_Common", "design", "logic", "design_profile_free.ts"));
|
||||
const align = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"));
|
||||
const align = loadTs(
|
||||
path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"),
|
||||
);
|
||||
|
||||
// 전체 측점 폴리라인(20m 간격 · 0~200m) — 계획고 = 지반고, 마디마다 기울기가 다르다.
|
||||
const stations = Array.from({ length: 11 }, (_, i) => i * 20);
|
||||
@@ -124,14 +126,23 @@ out.snapOutside = free.snapStation(stations, 190, [40, 160]);
|
||||
out.radiiKeys = Object.keys(next.curve_radii).sort();
|
||||
const built = align.buildAlignment(base, next);
|
||||
const pvi = (s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m;
|
||||
out.pvi = { 40: pvi(40), 60: pvi(60), 80: pvi(80), 100: pvi(100), 120: pvi(120), 140: pvi(140), 160: pvi(160) };
|
||||
out.pvi = {
|
||||
40: pvi(40),
|
||||
60: pvi(60),
|
||||
80: pvi(80),
|
||||
100: pvi(100),
|
||||
120: pvi(120),
|
||||
140: pvi(140),
|
||||
160: pvi(160),
|
||||
};
|
||||
out.ground = { 40: z0(40), 160: z0(160) };
|
||||
const g1 = (103.46 - z0(40)) / 40;
|
||||
const g2 = (101.2 - 103.46) / 40;
|
||||
out.expectR80 = Number((20 / Math.abs(g2 - g1)).toFixed(6));
|
||||
out.r80 = next.curve_radii["80.000"];
|
||||
out.curve80L = built.curves.find((c) => Math.abs(c.chainage_m - 80) < 1e-6)?.l_m ?? null;
|
||||
out.outsideUntouched = !("20.000" in next.station_offsets) && !("180.000" in next.station_offsets);
|
||||
out.outsideUntouched =
|
||||
!("20.000" in next.station_offsets) && !("180.000" in next.station_offsets);
|
||||
}
|
||||
|
||||
// ④ Enter = 끝(마지막 점 → 끝 세로선) · 끝 세로선에서 거꾸로 시작.
|
||||
@@ -172,4 +183,62 @@ out.snapOutside = free.snapStation(stations, 190, [40, 160]);
|
||||
out.enterOutsideDrawing = draw.key("Enter");
|
||||
}
|
||||
|
||||
// ⑥ 되돌리기 · 다시하기(367-3) — 점 둘 → ↶ → ↷ → 새 클릭이 ↷ 지움 · apply 안 불림 · 범위까지 거꾸로.
|
||||
{
|
||||
const { draw, log } = harness();
|
||||
out.undoEmpty = { can: draw.canUndo(), backspace: draw.key("Backspace") };
|
||||
draw.click(40, 0, stations);
|
||||
draw.click(40, 0, stations); // 같은 세로선 다시 — 상태 그대로 · 안 쌓임
|
||||
draw.click(160, 0, stations);
|
||||
draw.click(40, 0, stations);
|
||||
draw.click(80, 102, stations);
|
||||
draw.click(120, 103, stations);
|
||||
const dots = () => draw.marks().dots.map((d) => d.chainage_m);
|
||||
draw.undo();
|
||||
out.undo1 = { dots: dots(), canRedo: draw.canRedo() };
|
||||
draw.redo();
|
||||
out.redo1 = { dots: dots(), canRedo: draw.canRedo() };
|
||||
draw.undo();
|
||||
draw.click(100, 104, stations); // 새 클릭 = future 비움
|
||||
out.newClick = { dots: dots(), canRedo: draw.canRedo() };
|
||||
const phases = [];
|
||||
while (draw.canUndo()) {
|
||||
draw.undo();
|
||||
phases.push(`${draw.phase()}:${draw.marks().edges.length}`);
|
||||
}
|
||||
out.undoAll = phases;
|
||||
out.undoApplied = log.applied.length;
|
||||
out.undoClosed = log.closed;
|
||||
draw.redo();
|
||||
draw.redo();
|
||||
out.redoTwice = { phase: draw.phase(), band: draw.marks().band };
|
||||
draw.reset();
|
||||
out.resetClears = { undo: draw.canUndo(), redo: draw.canRedo() };
|
||||
}
|
||||
|
||||
// ⑦ 적용이 막히면(apply = false) 자유지정 열린 채 · 점 그대로.
|
||||
{
|
||||
let tries = 0;
|
||||
const draw = free.createFreeDraw({
|
||||
base: () => base,
|
||||
alignment: () => align.buildAlignment(base, align.emptyEdits()),
|
||||
edits: () => align.emptyEdits(),
|
||||
apply: () => ((tries += 1), false),
|
||||
close: () => (tries += 100),
|
||||
changed: () => undefined,
|
||||
});
|
||||
draw.click(40, 0, stations);
|
||||
draw.click(160, 0, stations);
|
||||
draw.click(40, 0, stations);
|
||||
draw.click(80, 102, stations);
|
||||
draw.key("Enter");
|
||||
draw.click(160, 0, stations);
|
||||
out.blocked = {
|
||||
tries,
|
||||
phase: draw.phase(),
|
||||
dots: draw.marks().dots.length,
|
||||
canUndo: draw.canUndo(),
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
|
||||
@@ -489,7 +489,8 @@ function makePage() {
|
||||
return app;
|
||||
}
|
||||
function fakeDocument(app) {
|
||||
const doc = { body: new El("BODY") };
|
||||
// 374-1 뜨는 자리 = body(모달 포함) · 원 · 배율 글은 html 바로 아래
|
||||
const doc = { body: app, documentElement: new El("HTML") };
|
||||
doc.createElement = (tag) => new El(tag.toUpperCase());
|
||||
doc.getElementById = (id) => (id === "app" ? app : null);
|
||||
return doc;
|
||||
@@ -515,10 +516,12 @@ function fakeDocument(app) {
|
||||
};
|
||||
const view = m.createLoupeView(win);
|
||||
view.show(500, 400);
|
||||
const root = doc.body.children[0];
|
||||
const label = doc.body.children[1];
|
||||
const root = doc.documentElement.children[0];
|
||||
const label = doc.documentElement.children[1];
|
||||
const stage = root.children[0];
|
||||
const page = stage.children[0];
|
||||
const scaler = stage.children[0]; // 이동 = stage · 배율 = scaler
|
||||
const page = scaler.children[0];
|
||||
const lens = () => `${stage.style.transform} ${scaler.style.transform}`;
|
||||
const copy = page.children[0];
|
||||
const [scroller, offscreen, table, svg, frame, cadBox] = copy.children;
|
||||
const [canvas, blank, input, check] = scroller.children;
|
||||
@@ -529,7 +532,8 @@ function fakeDocument(app) {
|
||||
ariaHidden: root.attrs["aria-hidden"],
|
||||
hidden: root.hidden,
|
||||
rootTransform: root.style.transform,
|
||||
stageTransform: stage.style.transform,
|
||||
stageTransform: lens(),
|
||||
stageMoveOnly: stage.style.transform,
|
||||
pageBox: [page.style.left, page.style.top, page.style.width, page.style.height],
|
||||
isClone: copy.cloned === true && copy !== app,
|
||||
canvasDrawn: canvas.drawn.map((d) => d[0]),
|
||||
@@ -560,19 +564,31 @@ function fakeDocument(app) {
|
||||
hidden: label.hidden,
|
||||
};
|
||||
view.setZoom(3);
|
||||
r.label.zoom3 = [label.textContent, stage.style.transform, root.style.transform];
|
||||
r.label.zoom3 = [label.textContent, lens(), root.style.transform];
|
||||
view.move(500, 790);
|
||||
r.label.bottom = label.style.transform;
|
||||
view.setZoom(2);
|
||||
view.move(500, 400);
|
||||
// 화면이 바뀜 — 0.5 초 뒤 한 번만 다시 뜸
|
||||
// 화면이 바뀜 — 멈춘 뒤 0.5 초에 한 번만 다시 뜸
|
||||
const firstCopy = copy;
|
||||
observers[0].fn();
|
||||
observers[0].fn();
|
||||
win.tick(499);
|
||||
const sameBefore = stage.children[0].children[0] === firstCopy;
|
||||
const sameBefore = scaler.children[0].children[0] === firstCopy;
|
||||
win.tick(1);
|
||||
const recaptured = stage.children[0].children[0] !== firstCopy && stage.children.length === 1;
|
||||
const recaptured = scaler.children[0].children[0] !== firstCopy && scaler.children.length === 1;
|
||||
// 374-1 움직이는 동안 안 뜸 — 바뀜 뒤 움직이면 처음부터 · 멈춘 뒤 0.5 초에 한 번
|
||||
const moving = scaler.children[0].children[0];
|
||||
observers[0].fn();
|
||||
win.tick(300);
|
||||
view.move(520, 400);
|
||||
win.tick(300);
|
||||
view.move(540, 400);
|
||||
win.tick(499);
|
||||
const heldWhileMoving = scaler.children[0].children[0] === moving;
|
||||
win.tick(1);
|
||||
r.debounce = { heldWhileMoving, afterStop: scaler.children[0].children[0] !== moving };
|
||||
view.move(500, 400);
|
||||
view.move(10, 10);
|
||||
const nearEdge = root.style.transform;
|
||||
view.hide();
|
||||
@@ -580,20 +596,21 @@ function fakeDocument(app) {
|
||||
sameBefore,
|
||||
recaptured,
|
||||
nearEdge,
|
||||
stageChildren: stage.children.length,
|
||||
stageChildren: scaler.children.length,
|
||||
hidden: root.hidden,
|
||||
disconnected: observers[0].watching === null,
|
||||
scrollListen: win.calls.some((c) => c.type === "scroll"),
|
||||
};
|
||||
observers[0].fn();
|
||||
win.tick(1000);
|
||||
r.hide.noRecaptureAfterHide = stage.children.length;
|
||||
r.hide.noRecaptureAfterHide = scaler.children.length;
|
||||
r.hide.labelHidden = label.hidden;
|
||||
view.setZoom(4);
|
||||
view.hide();
|
||||
view.show(500, 400);
|
||||
r.hide.reopenLabel = [label.textContent, label.hidden, stage.style.transform];
|
||||
r.hide.reuseRoot = doc.body.children.length;
|
||||
r.hide.reopenLabel = [label.textContent, label.hidden, lens()];
|
||||
r.hide.reuseRoot = doc.documentElement.children.length;
|
||||
r.hide.bodyUntouched = app.children.length; // 원이 body 에 안 붙음
|
||||
}
|
||||
{
|
||||
// 221-2 스크롤 — 스크롤한 뒤 켬 · 켠 채 스크롤(칸 · 문서)
|
||||
@@ -606,8 +623,9 @@ function fakeDocument(app) {
|
||||
disconnect() {}
|
||||
};
|
||||
const view = m.createLoupeView(win);
|
||||
const cloneScroller = () => doc.body.children[0].children[0].children[0].children[0].children[0];
|
||||
const page = () => doc.body.children[0].children[0].children[0];
|
||||
const scaler = () => doc.documentElement.children[0].children[0].children[0];
|
||||
const cloneScroller = () => scaler().children[0].children[0].children[0];
|
||||
const page = () => scaler().children[0];
|
||||
view.show(500, 400);
|
||||
view.hide();
|
||||
scroller.scrollTop = 0; // 0 에서 켬 → 스크롤 칸으로 잡힘
|
||||
|
||||
@@ -99,7 +99,7 @@ out.viewOf = {
|
||||
buildAlignment: () => null,
|
||||
store: { edits: () => ({}) },
|
||||
projectId: "p",
|
||||
pagePipeline: { pipelineGrade: () => undefined }, // 계산 묶음(346-1) — 묶음 없으면 아무 일 없음
|
||||
pagePipeline: { pipelineGrade: () => undefined, pipelineOwnsCross: () => false }, // 계산 묶음(346-1 · 365-4) — 묶음 없으면 아무 일 없음
|
||||
scheduleCrossPreview: () => s.previews++,
|
||||
draw: () => s.draws++,
|
||||
root: inPopup(),
|
||||
|
||||
@@ -107,6 +107,8 @@ process.stdout.write(
|
||||
attached,
|
||||
sections: sections.map((s) => s.underdrain ?? null),
|
||||
outline: draw.underdrainOutline(design, 1),
|
||||
// 371-3 암거 경사 3 % — 하단 = 높은 쪽(좌) 노면 끝 − 1 m 에서 3 % 로 낮은 쪽(우)으로 내림
|
||||
sloped: draw.underdrainOutline(design, 1, 3),
|
||||
bothCut: draw.underdrainOutline(bothCut, 1),
|
||||
noHeight: draw.underdrainOutline(design, null),
|
||||
drew: !!group,
|
||||
|
||||
@@ -321,6 +321,12 @@ const out = {};
|
||||
soil_exists: soil !== null,
|
||||
soil_children: soil?.childElementCount ?? null,
|
||||
};
|
||||
// 370-3 — 설계선 고름 공용 칸이 오면 그 칸이 왼쪽(암 제어를 따로 안 만듦)
|
||||
const cell = new FakeNode("div");
|
||||
cell.className = "linecell";
|
||||
const cellCard = new FakeNode("article");
|
||||
appendCardFooter(cellCard, { design: { geometry_preset: "rock" } }, {}, {}, cell);
|
||||
out.footer.cell_classes = cellCard.querySelector("footer").children.map((kid) => kid.className);
|
||||
}
|
||||
|
||||
// ── 3. 단추 줄(318-1) — 빈자리 누름 = 카드 고름 · 조작 누름 = 안 넘김 ───────────
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user