Merge remote-tracking branch 'origin/dev' into sub_laptop_6

This commit is contained in:
2026-09-29 20:28:28 +09:00
32 changed files with 1938 additions and 395 deletions
+6 -3
View File
@@ -7,9 +7,12 @@
* 하나뿐이었다). 다른 조작값과 같은 규칙으로 세션에 쌓고 저장 앞단에서 함께 내보낸다.
*
* 자동저장은 하지 않는다(CLAUDE.md 5장).
*
* 관 목록의 주인은 설계 가게(`design_store` 의 `pipes`) 하나다(계획서 24-2a) — B05 배수유역도 ·
* B06 구조물 목록 모두 이 창구로 읽고 쓴다.
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import { useDesignStore } from "../A00_Common/design/design_store";
import { saveDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
@@ -17,11 +20,11 @@ import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch
export type PendingPipes = DetailPipeInput[];
export function readPendingPipes(projectId: string): PendingPipes | null {
return readState<PendingPipes>("pipes", projectId);
return useDesignStore(projectId, null).get("pipes");
}
export function writePendingPipes(projectId: string, next: PendingPipes | null): void {
writeState("pipes", next, projectId);
useDesignStore(projectId, null).set("pipes", next);
}
/** 초안이 있으면 정본에 쓰고 비운다. 없으면 아무 일도 하지 않는다. */
+12 -6
View File
@@ -8,8 +8,9 @@
제약 탐색은 하지 않고, 넘은 곳은 `metrics.violations` 에 뜬다.
· earthwork — 후보 중 절 · 성토 합(`cut_m3 + fill_m3`) 최소.
· gentle — 후보 중 최대 종단기울기 최소(같으면 평균 최소).
· weighted — 후보 중 예상노선 이격 · 절성토 · 기울기(최대 + 평균) 세 값을 후보 범위로
0~1 정규화해 비중(합 100)으로 더한 값 최소.
· weighted — 후보 중 예상노선 이격 · 절성토 · 기울기(최대, 같으면 평균) 세 값을 후보
범위로 0~1 정규화해 비중(합 100)으로 더한 값 최소. 끝값은 그 갈래와 같은 선 —
토공 100 = 토공 최소 · 기울기 100 = 기울기 순한 · 예상 100 = 추적 선 그대로(23-3e).
후보 = 목표 비중(토공 · 기울기 · 이격 · 기본 비중)을 바꿔 가며 격자 탐색(`..._Search`)한 노선들.
갈래는 **비교값(화면에 보이는 값)으로 고른다** — 탐색 목표(4 m 링크 기울기 · 옆경사)는 비교값
@@ -230,12 +231,16 @@ def generate_initial_route(
follow=follow,
)
bound = None
if mode == "follow":
def follow_line() -> tuple[Any, dict[str, Any]]:
# 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 입력 →
# 같은 선(`_write_planned_polyline` 기본값). 제약은 따지지 않고 넘은 곳만 목록에 뜸.
outline = build_planned_polyline(expected, min_radius_m=min_radius_m)
metrics = measure(outline, None, follow=True)
return outline, measure(outline, None, follow=True)
bound = None
# 비중 예상 100 = 예상노선 추적 그 선(PLAN 23-3e) — 사이 비중은 제약 지킨 후보에서만 고름
if mode == "follow" or (mode == "weighted" and used["earthwork"] == used["grade"] == 0):
outline, metrics = follow_line()
else:
key = _pool_key(
expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth
@@ -354,7 +359,8 @@ def _pick(
return metrics["cut_m3"] + metrics["fill_m3"]
def grade(metrics: dict[str, Any]) -> float:
return metrics["max_grade_pct"] + metrics["avg_grade_pct"]
# 최대 먼저 · 평균은 같을 때만(기울기 순한과 같은 순서 — 값은 0.01 단위라 평균 몫 < 0.01)
return metrics["max_grade_pct"] + 1e-4 * metrics["avg_grade_pct"]
def offset(metrics: dict[str, Any]) -> float:
return metrics["offset_avg_m"]
+15 -55
View File
@@ -32,6 +32,7 @@ import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
@@ -355,31 +356,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
summaryText(summary, pipeEditor.pipes().length, basins.length, zSource);
// 목록 통지가 선택 통지보다 **먼저** 가야 한다 — 사이드 폼은 이 목록에서 관을
// 찾아 편집 화면을 채우므로, 순서가 뒤집히면 새로 넣은 관의 폼이 열리지 않는다.
callbacks.onPipesChanged?.(
// 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null.
// 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고,
// 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조).
// 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이
// 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합).
pipeEditor.pipes().map((pipe) => {
const chainage = Math.round(pipe.chainage_m * 100) / 100;
const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51);
const attributes = facilityStore.get(chainage);
return {
chainage_m: chainage,
effective_diameter_mm:
basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null,
// 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다.
design_flow_m3s: basin?.design_flow_m3s ?? null,
facility: attributes?.facility ?? "pipe",
start_m: attributes?.start_m,
end_m: attributes?.end_m,
source: (pipe.reason || "user") as PipeSource,
options: attributes?.options,
summary_item: attributes?.summary_item,
};
}),
);
callbacks.onPipesChanged?.(buildPipeReports(pipeEditor.pipes(), basins, facilityStore));
syncPipeSelection();
}
@@ -405,18 +382,19 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
}
}
/** 지금 관 목록 + 시설 정보 — 저장 · 재계산 · 세션 초안 요청 몸통이 모두 이것이다. */
const pipeRequest = () =>
facilityStore.attach(
pipeEditor.pipes().map((pipe) => ({
chainage_m: pipe.chainage_m,
source: (pipe.reason || "user") as PipeSource,
})),
);
/** 지금 화면의 관 목록을 세션 초안에 담는다 — 정본 쓰기는 [저장]·[확정] 몫이다. */
function rememberPipes(): void {
if (!projectId) return;
writePendingPipes(
projectId,
facilityStore.attach(
pipeEditor.pipes().map((pipe) => ({
chainage_m: pipe.chainage_m,
source: (pipe.reason || "user") as PipeSource,
})),
),
);
writePendingPipes(projectId, pipeRequest());
}
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이
@@ -436,17 +414,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
/** 세부유역을 다시 나눈다. 격자 해석은 하지 않으므로 즉시 끝난다.
* 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */
const analyze = (): Promise<void> =>
run(() =>
computeDetailBasins(
projectId as string,
facilityStore.attach(
pipeEditor.pipes().map((pipe) => ({
chainage_m: pipe.chainage_m,
source: (pipe.reason || "user") as PipeSource,
})),
),
),
);
run(() => computeDetailBasins(projectId as string, pipeRequest()));
analyzeButton.addEventListener("click", () => void analyze());
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
@@ -664,16 +632,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
},
async savePipes() {
if (!projectId) return 0;
const response = await saveDetailPipePoints(
projectId,
// 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임).
facilityStore.attach(
pipeEditor.pipes().map((pipe) => ({
chainage_m: pipe.chainage_m,
source: (pipe.reason || "user") as PipeSource,
})),
),
);
// 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임).
const response = await saveDetailPipePoints(projectId, pipeRequest());
apply(response);
scheduleDraw();
return response.pipe_count;
@@ -52,24 +52,25 @@ export interface DrainagePanel {
dispose: () => void;
}
/** 관 하나의 보고 — 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류 · 구간 · 옵션 · 집계 항목. */
export interface DrainagePipeReport {
chainage_m: number;
effective_diameter_mm: number | null;
/** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */
design_flow_m3s?: number | null;
facility: PipeFacility;
start_m?: number;
end_m?: number;
source?: PipeSource;
options?: Record<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
summary_item?: SummaryItem | null;
}
export interface DrainagePanelCallbacks {
/** 관 목록이 바뀔 때마다 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류를
* 넘긴다 — 종단 구조물 라인·통합 목록 동기화 및 관경 자동 지정(D800 기본)용. */
onPipesChanged?: (
pipes: Array<{
chainage_m: number;
effective_diameter_mm: number | null;
/** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */
design_flow_m3s?: number | null;
facility: PipeFacility;
start_m?: number;
end_m?: number;
source?: PipeSource;
options?: Record<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
summary_item?: SummaryItem | null;
}>,
) => void;
onPipesChanged?: (pipes: DrainagePipeReport[]) => void;
/** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */
onBasinSelected?: (chainageM: number | null) => void;
/** 관 마커 선택이 바뀔 때(클릭·해제) 누가거리를 알린다 — 유역이 없는 관도 전 화면
@@ -0,0 +1,39 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Report.ts
* 배수유역 패널이 관 목록이 바뀔 때 밖에 알리는 **관 보고**(`DrainagePipeReport`)를 만든다.
* `B05_Profile_UI_Drainage_Panel` 에서 떼어냄(700줄 제한) — 셈은 그대로, 상태를 안 든다.
* ========================================================================== */
import type { DetailBasin, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import type { DrainagePipeReport } from "./B05_Profile_UI_Drainage_Panel_Types";
/** 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null.
* 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고,
* 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조).
* 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이
* 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합). */
export function buildPipeReports(
pipes: ReadonlyArray<{ chainage_m: number; reason?: string }>,
basins: ReadonlyArray<DetailBasin>,
facilityStore: ReturnType<typeof createFacilityStore>,
): DrainagePipeReport[] {
return pipes.map((pipe) => {
const chainage = Math.round(pipe.chainage_m * 100) / 100;
const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51);
const attributes = facilityStore.get(chainage);
return {
chainage_m: chainage,
effective_diameter_mm:
basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null,
// 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다.
design_flow_m3s: basin?.design_flow_m3s ?? null,
facility: attributes?.facility ?? "pipe",
start_m: attributes?.start_m,
end_m: attributes?.end_m,
source: (pipe.reason || "user") as PipeSource,
options: attributes?.options,
summary_item: attributes?.summary_item,
};
});
}
+87 -5
View File
@@ -1,11 +1,93 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Side.ts
* 배수유역도를 종단도 오른쪽 판(`side`)으로 끼우는 함수(계획서 24-2c · 24-2e).
* 판 자리 약속은 `A00_Common/design/design_profile.ts` 의 `DesignSideFactory` 다.
* 지금은 옛 종단 패널이 직접 만들던 것을 그대로 옮긴 첫 판이다.
* 배수유역도를 종단도 오른쪽 판(`side`)으로 끼우는 홀로 선 함수(계획서 24-2c · 24-2e).
* 판 자리 약속은 `A00_Common/design/design_profile.ts` 의 `DesignSideFactory` 다 —
* `createDrainageSide` 가 그 모양 그대로다(`(host) => 판`).
*
* 판(`createDrainagePanel`)은 종단 패널을 모른다 — 종단쪽 일은 전부 `host`
* (`DrainagePanelCallbacks` + projectId · view, `_Panel_Types` 약속)로 받고, 종단쪽에서 들어오는 일은
* 판의 창구(`setRoute` · `setIntervalSpans` · `pipeChainages` · `setPipeChainages` ·
* `selectPipeAtChainage` …)로만 한다.
* · 관 목록이 바뀜(최소 흙덮기 경고 · 구조물 라인 · 통합 목록) = `host.onPipesChanged`
* · 관 자리 읽기 · 쓰기(되돌리기) = `pipeChainages()` · `setPipeChainages()`
* · 우클릭 메뉴의 구조물 종류 = `host.structureMenuItems`
* · 구간 띠 · 노선 = `setIntervalSpans()` · `setRoute()`
* 두 보기(종단 · 횡단)에서 같은 판을 끼우고 **켜고 끈다** — 끄면 자리를 비우되 관 · 유역
* 상태는 그대로 살아 있다(다시 켜면 이어서 보임). 켬 · 끔을 어디에 적을지는 끼우는 쪽 몫이다.
* ========================================================================== */
import type { DesignSideFactory } from "../A00_Common/design/design_profile";
import "./B05_Profile_UI_Style_Drainage.css"; // 홀로 서려면 판이 자기 모양을 들고 온다.
import type { DesignSideBoard, DesignSideHost } from "../A00_Common/design/design_profile";
import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
export const createDrainageSide: DesignSideFactory = (host) => createDrainagePanel(host);
/** 종단쪽이 넘기는 이음 — 판 바깥 약속(`DrainagePanelCallbacks`)에 projectId · view 를 더한 것. */
export type DrainageSideHost = DesignSideHost;
/** 종단도 side 판 = 배수유역 판 창구 그대로 + 켜고 끔. */
export type DrainageSideBoard = DesignSideBoard & {
/** 켜고 끈다. 켜면 숨겼던 사이 바뀐 크기 · 그림을 판이 스스로 다시 맞춘다. */
setVisible: (visible: boolean) => void;
isVisible: () => boolean;
/** 켬 · 끔이 바뀔 때마다 부른다(단추 표시 · 저장). 해제 함수를 돌려준다. */
onVisibleChange: (listener: (visible: boolean) => void) => () => void;
};
export interface DrainageSideOptions {
/** 처음 켤지(기본 켬). */
visible?: boolean;
}
/** `DesignSideFactory` — 종단도를 만들 때 한 번 부른다. */
export function createDrainageSide(
host: DrainageSideHost,
options: DrainageSideOptions = {},
): DrainageSideBoard {
const panel = createDrainagePanel(host);
const listeners = new Set<(visible: boolean) => void>();
let visible = true;
function setVisible(next: boolean): void {
if (next === visible) return;
visible = next;
panel.root.hidden = !visible;
listeners.forEach((listener) => listener(visible));
}
// 처음 상태는 알림 없이 맞춘다.
if (options.visible === false) {
visible = false;
panel.root.hidden = true;
}
return {
...panel,
// 보기가 바뀌어도 판은 다시 안 만든다 — 켬 · 끔은 끼우는 쪽이 `setVisible` 로 정한다.
setView: () => {},
setVisible,
isVisible: () => visible,
onVisibleChange(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
dispose() {
listeners.clear();
panel.dispose();
},
};
}
/** 켜고 끄는 단추 — 어느 보기에서든 같은 모양 · 같은 글. 누르면 판을 뒤집는다. */
export function createDrainageToggleButton(side: DrainageSideBoard): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "ui-btn ui-btn--ghost b05-drainage__toggle";
button.textContent = "배수유역도";
button.title = "배수유역도 판을 켜고 끕니다";
const sync = (visible: boolean): void => {
button.setAttribute("aria-pressed", String(visible));
button.classList.toggle("is-active", visible);
};
sync(side.isVisible());
side.onVisibleChange(sync);
button.addEventListener("click", () => side.setVisible(!side.isVisible()));
return button;
}
+55 -63
View File
@@ -23,9 +23,7 @@ import {
solveRoute,
type RouteLatestResponse,
} from "./B05_Profile_Api_Fetch";
import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options";
import { flushPendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { stateKey } from "../A00_Common/b_page_state";
import { saveDesign } from "../A00_Common/design/design_save";
import {
invalidateSectionDetail,
saveCachedCrossPatches,
@@ -127,74 +125,68 @@ export async function solveRouteAction(ctx: PageActionContext): Promise<void> {
}
/** [임시저장] — 현재 편집을 영구저장소에 남기되 워크플로 단계·페이지는 그대로 둔다.
* 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). */
* 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의).
* 차례는 저장 한 줄기(`saveDesign`)가 정한다 — B06 [저장]과 같다(계획서 24-2a). */
export async function tempSaveAction(ctx: PageActionContext): Promise<void> {
if (!ctx.routeReady()) return;
const latest = ctx.latest();
const projectId = ctx.projectId;
// 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 임시저장을 막지는 않는다.
// 세션 초안(B06 저장도 같은 창구를 쓴다)을 먼저 내보내고, 화면 목록으로 한 번 더 맞춘다.
await flushPendingPipes(projectId).catch(() => undefined);
await ctx
.profilePanel()
.drainage.savePipes()
.catch(() => 0);
showLoadingOverlay();
try {
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다.
await ctx.profilePanel().save();
// 구조물도 조작분이 세션에만 있다(2026-08-29 — CLAUDE.md 5장). 실패는 자체 토스트로
// 알리고 세션에 남겨 두므로, 여기서 저장 전체를 멈추지 않는다.
await ctx.bridge().saveStructuresIfDirty();
// 비정규 측점·상단측 변경분까지 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다.
await confirmRoute(
projectId,
{
filter_key: latest?.surface_params.source_filter,
method: latest?.surface_params.method,
smooth: latest?.surface_params.smooth,
surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined,
irregular_stations: ctx
.bridge()
.irregularStations()
.map((station) => ({
chainage_m: station.chainage_m,
structure: station.structure,
})),
// 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다.
uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({
chainage_m: Number(chainage),
side,
})),
await saveDesign({
projectId,
routeId: () => latest?.route?.id ?? null,
steps: {
// 세션 초안이 나간 뒤 화면 목록으로 한 번 더 맞춘다(시설 보관소 되붙이기 · 응답으로 화면 갱신).
pipes: async () => {
await ctx.profilePanel().drainage.savePipes();
},
false,
);
// B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다.
// 관 위치는 두 페이지가 같은 스냅샷(`pipes`)으로만 옮김 — 옛 이동 예약은 안 보냄.
if (latest?.route?.id != null) {
await flushCulvertOptions(
projectId,
stateKey("culvertopt", projectId, latest.route.id),
).catch(() => undefined);
}
// 구조물 — 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다. 실패는 자체 토스트로 알리고
// 세션에 남겨 두므로 저장 전체를 멈추지 않는다(2026-08-29 — CLAUDE.md 5장).
structures: () => ctx.bridge().saveStructuresIfDirty(),
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다(패널도 다시 그림).
alignment: () => ctx.profilePanel().save(),
// 상단측 + 비정규 측점 — 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다.
uphill: async () => {
await confirmRoute(
projectId,
{
filter_key: latest?.surface_params.source_filter,
method: latest?.surface_params.method,
smooth: latest?.surface_params.smooth,
surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined,
irregular_stations: ctx
.bridge()
.irregularStations()
.map((station) => ({
chainage_m: station.chainage_m,
structure: station.structure,
})),
// 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다.
uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({
chainage_m: Number(chainage),
side,
})),
},
false,
);
},
},
// B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래
// 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가
// 최종본으로 얹힌다(2026-08-24 사용자 지적).
if (latest?.route?.id != null) {
await saveCachedCrossPatches(projectId, latest.route.id);
}
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다.
invalidateSectionDetail(projectId);
ctx.renderLatest(await ctx.loadLatest(true));
// 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리.
if (latest?.route?.id) void saveCorridorIfDirty(projectId, latest.route.id);
showToast(L("B05_Route_TempSave_Success"), "success");
} catch (error) {
showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error");
} finally {
hideLoadingOverlay();
}
cross: async () => {
if (latest?.route?.id != null) await saveCachedCrossPatches(projectId, latest.route.id);
},
done: async () => {
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다.
invalidateSectionDetail(projectId);
ctx.renderLatest(await ctx.loadLatest(true));
// 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리.
if (latest?.route?.id) void saveCorridorIfDirty(projectId, latest.route.id);
showToast(L("B05_Route_TempSave_Success"), "success");
},
failed: (error) =>
showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error"),
});
}
/** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입.
+92 -49
View File
@@ -17,8 +17,8 @@
import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
import { chainageKey, emptyEdits, hasEdits } from "./B05_Profile_UI_Profile_Alignment";
import { ALIGNMENT_DRAFT_KEY_PREFIX, useDesignStore } from "../A00_Common/design/design_store";
const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
/** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */
const BUTTON_CLEARANCE_PX = 23;
const BUTTON_HALF_PX = 10;
@@ -79,6 +79,9 @@ export interface ProfileEditStore {
edited(): boolean;
}
/** 초안 읽기 · 쓰기는 설계 가게(`design_store` 의 `alignmentDraft`) 한 창구로만. */
const draftOf = (routeId: number) => useDesignStore(null, routeId);
/**
* 노선의 미저장 편집 초안을 읽는다(B06 공용).
*
@@ -87,7 +90,7 @@ export interface ProfileEditStore {
* 쓰면 초안 편집분이 B06에서 되돌아간 것처럼 보인다.
*/
export function readAlignmentDraft(routeId: number | null): AlignmentEdits | null {
return routeId === null ? null : readDraft(`${DRAFT_KEY_PREFIX}:${routeId}`);
return routeId === null ? null : draftOf(routeId).get("alignmentDraft");
}
/**
@@ -96,13 +99,15 @@ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | nul
* 초기화는 서버 값을 초기 스냅샷으로 되돌리는데, 이 초안이 남아 있으면 화면이 그 위에
* 옛 편집 델타를 다시 얹어 계획선이 측점에서 원지반선과 만나지 않는다(2026-09-04 실측:
* 초기화 직후에는 멀쩡하다가 새로고침 한 번에 최대 6.0m 어긋남).
* 노선 번호를 모르는 옛 초안까지 쓸어야 해 여기서만 키를 훑는다.
*/
export function clearAlignmentDrafts(): void {
shared.clear();
try {
const keys: string[] = [];
for (let index = 0; index < sessionStorage.length; index += 1) {
const key = sessionStorage.key(index);
if (key?.startsWith(`${DRAFT_KEY_PREFIX}:`)) keys.push(key);
if (key?.startsWith(`${ALIGNMENT_DRAFT_KEY_PREFIX}:`)) keys.push(key);
}
keys.forEach((key) => sessionStorage.removeItem(key));
} catch {
@@ -110,18 +115,47 @@ export function clearAlignmentDrafts(): void {
}
}
function readDraft(storageKey: string): AlignmentEdits | null {
try {
const raw = sessionStorage.getItem(storageKey);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<AlignmentEdits>;
return {
station_offsets: parsed.station_offsets ?? {},
curve_radii: parsed.curve_radii ?? {},
};
} catch {
return null;
/**
* 노선마다 **한 벌**인 편집 상태 — B05 종단 패널이 주인이고 B06 계획선 편집은 같은 벌을
* 쓴다(계획서 24-2a). 예전에는 두 화면이 가게를 따로 만들어 같은 초안 키를 각자 고쳤다 —
* 한쪽이 메모리에 쥔 옛 값으로 다시 쓰면 다른 쪽 편집이 덮였다.
*/
interface EditState {
current: AlignmentEdits;
unsaved: boolean;
/**
* [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시).
* 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동
* 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다.
*/
savedBaseline: AlignmentEdits;
/** 서버 저장분 — 측점 원복 · 전체 원복이 자동 곡선반경을 되살리는 기준. */
saved: AlignmentEdits;
}
const shared = new Map<number, EditState>();
/** 서버 저장분과 남은 초안으로 상태를 세운다(초안이 있으면 그것이 이기고 미저장). */
function freshState(routeId: number | null, saved: AlignmentEdits): EditState {
const draft = readAlignmentDraft(routeId);
return { current: draft ?? saved, unsaved: draft !== null, savedBaseline: saved, saved };
}
/** [저장]이 보낼 계획고 초안 — 미저장 편집이 없으면 null. */
export function pendingAlignment(routeId: number): AlignmentEdits | null {
const state = shared.get(routeId);
if (state) return state.unsaved ? state.current : null;
return readAlignmentDraft(routeId);
}
/** 계획고가 정본에 나갔다 — 그 노선 상태를 저장됨으로 두고 초안을 지운다. */
export function markAlignmentSaved(routeId: number): void {
const state = shared.get(routeId);
if (state) {
state.unsaved = false;
state.savedBaseline = state.current;
}
draftOf(routeId).set("alignmentDraft", null);
}
/**
@@ -131,6 +165,9 @@ function readDraft(storageKey: string): AlignmentEdits | null {
* 새로고침한 경우) 초안을 우선 채택하고 미저장 상태로 표시한다. routeId가 바뀌면
* 초안 키도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다.
*
* 부를 때마다 그 노선의 한 벌을 **초안 · 저장분으로 다시 세운다**(되돌리기가 초안을 바꾼 뒤
* 다시 읽는 길). 이미 선 한 벌에 붙기만 하려면 `useProfileEditStore`.
*
* routeId가 아직 없으면 **초안을 두지 않는다**(2026-09-04). 노선 없는 키(`:none`)에
* 쌓인 초안은 어느 노선에나 되붙어 초기화로도 떨어지지 않았다.
*/
@@ -139,56 +176,62 @@ export function createProfileEditStore(
saved: AlignmentEdits,
onChange: () => void,
): ProfileEditStore {
const storageKey = routeId === null ? null : `${DRAFT_KEY_PREFIX}:${routeId}`;
const draft = storageKey ? readDraft(storageKey) : null;
let current = draft ?? saved;
let unsaved = draft !== null;
/**
* [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시).
* 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동
* 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다.
*/
let savedBaseline = saved;
const fresh = freshState(routeId, saved);
// 이미 선 한 벌은 **그 자리에서** 다시 세운다 — 바꿔 끼우면 붙어 있던 쪽이 옛 벌을 쥔다.
const state = routeId === null ? fresh : Object.assign(shared.get(routeId) ?? fresh, fresh);
if (routeId !== null) shared.set(routeId, state);
return handleOf(routeId, state, onChange);
}
/** 그 노선에 이미 선 한 벌에 붙는다 — 없으면 `createProfileEditStore` 와 같다(B06 계획선 편집). */
export function useProfileEditStore(
routeId: number,
saved: AlignmentEdits,
onChange: () => void,
): ProfileEditStore {
const state = shared.get(routeId);
return state
? handleOf(routeId, state, onChange)
: createProfileEditStore(routeId, saved, onChange);
}
function handleOf(
routeId: number | null,
state: EditState,
onChange: () => void,
): ProfileEditStore {
const draft = routeId === null ? null : draftOf(routeId);
function dropDraft(): void {
if (!storageKey) return;
try {
sessionStorage.removeItem(storageKey);
} catch {
// 세션 저장소를 못 쓰는 환경에서도 편집 자체는 계속 동작해야 한다.
}
draft?.set("alignmentDraft", null);
}
function commit(next: AlignmentEdits): void {
current = next;
unsaved = true;
try {
if (storageKey) sessionStorage.setItem(storageKey, JSON.stringify(current));
} catch {
// 초안 보관 실패는 편집을 막지 않는다.
}
state.current = next;
state.unsaved = true;
draft?.set("alignmentDraft", state.current);
onChange();
}
return {
edits: () => current,
edits: () => state.current,
replace: commit,
markSaved() {
unsaved = false;
savedBaseline = current;
state.unsaved = false;
state.savedBaseline = state.current;
dropDraft();
},
restoreSaved() {
current = savedBaseline;
unsaved = false;
state.current = state.savedBaseline;
state.unsaved = false;
dropDraft();
onChange();
},
canRestoreSaved: () => JSON.stringify(current) !== JSON.stringify(savedBaseline),
canRestoreSaved: () => JSON.stringify(state.current) !== JSON.stringify(state.savedBaseline),
resetStation(chainageM, moveTo) {
const key = chainageKey(chainageM);
const stationOffsets = { ...current.station_offsets };
const curveRadii = { ...current.curve_radii };
const stationOffsets = { ...state.current.station_offsets };
const curveRadii = { ...state.current.curve_radii };
if (moveTo !== undefined) {
const to = chainageKey(moveTo);
if (stationOffsets[key] !== undefined) stationOffsets[to] = stationOffsets[key];
@@ -200,7 +243,7 @@ export function createProfileEditStore(
delete stationOffsets[key];
// 그 측점의 곡선반경은 저장분(자동 계산이 역산해 둔 값)으로 되돌린다 — 지우면
// 프론트가 기본 R로 다시 그려 계획고가 서버 정본과 어긋난다(2026-08-23).
const savedRadius = saved.curve_radii?.[key];
const savedRadius = state.saved.curve_radii?.[key];
if (savedRadius === undefined) delete curveRadii[key];
else curveRadii[key] = savedRadius;
commit({ station_offsets: stationOffsets, curve_radii: curveRadii });
@@ -209,10 +252,10 @@ export function createProfileEditStore(
// 지우는 것은 **사용자 편집 측점**이다. 자동 계획선이 역산해 저장한 곡선반경
// (`curve_radii`)까지 비우면 호가 달라져 화면 계획고가 정본과 달라진다 —
// 세월교 지점에서 0.22m 낮게 나와 최소고 경고가 잘못 떴다(2026-08-23 진단).
commit({ ...emptyEdits(), curve_radii: { ...(saved.curve_radii ?? {}) } });
commit({ ...emptyEdits(), curve_radii: { ...(state.saved.curve_radii ?? {}) } });
},
dirty: () => unsaved,
edited: () => hasEdits(current),
dirty: () => state.unsaved,
edited: () => hasEdits(state.current),
};
}
@@ -12,7 +12,7 @@
/** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린
* 기호(`↺`·`↻`)는 「한 바퀴 돈다」로 읽혀 한 칸씩 도는 동작과 안 맞았다. */
const HALF_TURN_ICON = {
export const HALF_TURN_ICON = {
ccw: `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 8a5 5 0 0 0-10 0" /><path d="M3 8 1.2 5.6" /><path d="M3 8 5.4 6.6" /></svg>`,
@@ -42,9 +42,9 @@ export function createRouteEditChrome(): RouteEditChrome {
<strong>계획노선 편집</strong>
<span class="b05-routeedit__rec-now" data-rec-now></span>
<span class="b05-routeedit__actions">
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
title="예상노선 추적 · 토공 최소 · 기울기 순한 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다">노선 추천</button>
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--ghost" data-act="contour-minor"
aria-pressed="false" disabled
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
@@ -34,6 +34,8 @@ export interface RouteEditNavigationParams {
/** 돌린 지도 보정 — 화면에서 민 만큼(dx, dy)을 **그림 좌표의 만큼**으로 바꾼다.
* 안 주면 안 돌린 것으로 본다(배수유역도는 회전이 없다). */
unrotateDelta?: (dx: number, dy: number) => [number, number];
/** 마우스 왼쪽 버튼으로도 팬 — 집을 노드가 없는 미리 보기 지도용. 편집 창은 가운데 버튼만. */
leftPan?: boolean;
draw: () => void;
}
@@ -78,7 +80,12 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
// 가운데 버튼 기본 동작(페이지 자동 스크롤)이 팬과 겹치지 않게 막는다.
if (event.button === 1) event.preventDefault();
// 마우스는 가운데 버튼만 팬이다 — 왼쪽 버튼은 노드·손잡이 조작 몫.
if (event.pointerType === "mouse" && event.button !== 1) return;
if (
event.pointerType === "mouse" &&
event.button !== 1 &&
!(params.leftPan && event.button === 0)
)
return;
const view = getView();
dragStart = {
x: event.clientX,
@@ -23,7 +23,6 @@ import {
criteriaLine,
CRITERIA_KEYS,
LOGIC_LABEL,
mapPaths,
metricsLine,
rebalanceWeights,
relaxedLine,
@@ -36,6 +35,7 @@ import {
type MapLayer,
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
import "./B05_Profile_UI_Style_RouteRecommend.css";
type Vertex = [number, number];
@@ -61,34 +61,31 @@ const CRITERIA_FIELD: Record<
{ label: string; unit: string; title: string; of: (c: InitialCriteria) => number | null }
> = {
max_grade_pct: {
label: "기울기",
label: "종단기울기 상한",
unit: "%",
title: "종단기울기 상한(%) — 비우면 프로젝트 기본값(설계속도 · 지형 표값)",
of: (c) => c.maxGradePct,
},
min_radius_m: {
label: "최소R",
label: "최소 반지름",
unit: "m",
title: "최소 곡선반지름(m) — 비우면 프로젝트 기본값",
of: (c) => c.minRadiusM,
},
stream_offset_m: {
label: "계류",
label: "계류 이격",
unit: "m",
title: "계류 이격(m) — 비우면 기본값 100m",
of: (c) => c.streamOffsetM,
},
grade_smooth_m: {
label: "지반",
label: "지반 고름 폭",
unit: "m",
title: "기울기 잴 지반 고름 폭(m) — 비우면 기본값 5m",
of: (c) => c.gradeSmoothM,
},
};
const MAP_W = 460;
const MAP_H = 340;
export interface RecommendParams {
/** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */
criteria: RouteInitialCriteria | null;
@@ -120,13 +117,17 @@ export function openRecommendModal(params: RecommendParams): void {
let seq = 0;
let bodyEl: HTMLElement | null = null;
let map: ReturnType<typeof createRecommendMap> | null = null;
openModal({
title: "노선 추천",
closeLabel: "✕",
dialogClass: "b05-routerec",
// 곡선 라벨 패널(z-index 1001)이 떠 있어도 이 모달과 뒤 막이 그 위에 오게 한다.
backdropClass: "b05-routerec-backdrop",
// ✕ · Esc · 바깥 누르기 모두 [닫기]와 같다 — 미리 보기를 버린다.
beforeClose: () => {
closed = true;
map?.dispose();
return true;
},
mount: (body, close) => {
@@ -141,21 +142,28 @@ export function openRecommendModal(params: RecommendParams): void {
</div>
<div class="b05-routerec__main">
<div class="b05-routerec__mapbox">
<svg class="b05-routerec__map" viewBox="0 0 ${MAP_W} ${MAP_H}" role="img"
aria-label="추천 노선 미리 보기"></svg>
<div class="b05-routerec__mapwrap" role="img" aria-label="추천 노선 미리 보기">
${RECOMMEND_MAP_HTML}
</div>
<div class="b05-routerec__legend">
<span><i class="is-expected"></i>예상노선(원본)</span>
<span><i class="is-current"></i>창을 연 노선</span>
<span><i class="is-preview"></i>미리 보기</span>
</div>
<div class="b05-routerec__maphint">휠 = 확대 · 축소 &nbsp; 끌기 = 이동 &nbsp; ↺ ↻ = 회전</div>
</div>
<div class="b05-routerec__side">
<div class="b05-routerec__weights" hidden>
${WEIGHT_KEYS.map(
(key) => `<label><span>${WEIGHT_NAME[key]}</span>
<input type="range" min="0" max="100" step="1" data-weight="${key}" />
<output data-weight-out="${key}"></output></label>`,
).join("")}
<div class="b05-routerec__bars">
${WEIGHT_KEYS.map(
(
key,
) => `<label class="b05-routerec__bar"><output data-weight-out="${key}"></output>
<input type="range" min="0" max="100" step="1" data-weight="${key}"
aria-label="${WEIGHT_NAME[key]} 비중" />
<span>${WEIGHT_NAME[key]}</span></label>`,
).join("")}
</div>
<span class="b05-routerec__sum" data-weight-sum></span>
</div>
<div class="b05-routerec__criteria">
@@ -181,7 +189,7 @@ export function openRecommendModal(params: RecommendParams): void {
</div>`;
const q = <T extends HTMLElement>(sel: string): T => body.querySelector<T>(sel)!;
const svg = body.querySelector<SVGSVGElement>(".b05-routerec__map")!;
map = createRecommendMap(q<HTMLElement>(".b05-routerec__mapwrap"));
const pills = [...body.querySelectorAll<HTMLButtonElement>("[data-logic]")];
const weightsBox = q<HTMLElement>(".b05-routerec__weights");
const rerun = q<HTMLButtonElement>('[data-act="rerun"]');
@@ -213,9 +221,7 @@ export function openRecommendModal(params: RecommendParams): void {
{ kind: "current", points: params.current },
];
if (preview) layers.push({ kind: "preview", points: preview.snapshot.planned });
svg.innerHTML = mapPaths(layers, MAP_W, MAP_H)
.map((path) => `<path class="is-${path.kind}" d="${path.d}" />`)
.join("");
map?.setLayers(layers);
}
function render(): void {
@@ -0,0 +1,183 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Recommend_Map.ts
* [노선 추천] 모달의 미리 보기 지도 — 캔버스 하나에 선 세 겹(예상노선 · 창을 연 노선 · 미리 보기).
*
* 화면 셈 · 조작은 편집 창 것을 그대로 가져다 쓴다(베끼지 않음) — 좌표 옮기기 `metricToScreen` ·
* 처음 맞춤 `computeRouteView` · 캔버스 크기 `fitCanvas`(`_View`) · 휠 확대 · 끌기
* `bindRouteEditNavigation`(`_Input`) · 회전 `createMapRotation`(`_Rotate`). 배경 지도(VWorld)는
* 없어서, 선들의 범위를 감싼 가짜 도엽(`VWorldMeta`)을 만들어 같은 셈에 넣는다.
* ========================================================================== */
import { themeColor } from "@ui/ui_template_palette";
import {
computeMapRect,
computeRouteView,
metricToScreen,
ROUTE_VIEW_MARGIN_M,
routeLineColor,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { HALF_TURN_ICON } from "./B05_Profile_UI_RouteEdit_Chrome";
import { bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input";
import type { MapLayer } from "./B05_Profile_UI_RouteEdit_Initial_Model";
import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
import { fitCanvas } from "./B05_Profile_UI_RouteEdit_View";
/** 처음 맞출 때 선 범위 둘레로 두는 여유(m) — 편집 창(200 m)보다 좁게, 선이 지도를 채우게. */
const FIT_MARGIN_M = 30;
/** 지도 칸 마크업 — 캔버스 + 회전 단추 둘(편집 창과 같은 모양 · 같은 `data-act`). */
export const RECOMMEND_MAP_HTML = `
<canvas class="b05-routerec__map"></canvas>
<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>`;
export interface RecommendMap {
/** 겹을 바꿔 다시 그린다. 처음 점이 있는 겹이 들어올 때 한 번만 화면을 맞춘다. */
setLayers: (layers: MapLayer[]) => void;
dispose: () => void;
}
/** `box` = `RECOMMEND_MAP_HTML` 을 넣은 칸(높이가 정해져 있어야 함). */
export function createRecommendMap(box: HTMLElement): RecommendMap {
const canvas = box.querySelector<HTMLCanvasElement>("canvas")!;
const context = canvas.getContext("2d")!;
let layers: MapLayer[] = [];
let meta: VWorldMeta | null = null;
let fitted = false;
let view: ViewState = {
width: 0,
height: 0,
scale: 1,
offsetX: 0,
offsetY: 0,
mapRect: computeMapRect(null, 0, 0),
};
const rotation = createMapRotation({ overlay: box, size: () => view, onChange: () => draw() });
function bounds(): { x_min: number; x_max: number; y_min: number; y_max: number } | null {
const all = layers.flatMap((layer) => layer.points);
if (!all.length) return null;
const xs = all.map(([x]) => x);
const ys = all.map(([, y]) => y);
return {
x_min: Math.min(...xs),
x_max: Math.max(...xs),
y_min: Math.min(...ys),
y_max: Math.max(...ys),
};
}
/** 선 범위 + 여유를 도엽으로 본다 — 이후 확대 상한 · 맞춤이 편집 창과 같은 공식을 탄다. */
function makeMeta(range: NonNullable<ReturnType<typeof bounds>>): VWorldMeta {
const x_min = range.x_min - ROUTE_VIEW_MARGIN_M;
const y_min = range.y_min - ROUTE_VIEW_MARGIN_M;
const x_max = range.x_max + ROUTE_VIEW_MARGIN_M;
const y_max = range.y_max + ROUTE_VIEW_MARGIN_M;
return {
x_min,
x_max,
y_min,
y_max,
width_meters: x_max - x_min,
height_meters: y_max - y_min,
center_x: (x_min + x_max) / 2,
center_y: (y_min + y_max) / 2,
lon_min: 0,
lon_max: 1,
lat_min: 0,
lat_max: 1,
};
}
function refit(): void {
const b = bounds();
if (!b || !view.width) return;
meta = makeMeta(b);
view = {
...view,
mapRect: computeMapRect(meta, view.width, view.height),
...computeRouteView(meta, b, view.width, view.height, FIT_MARGIN_M),
};
fitted = true;
}
function draw(): void {
context.clearRect(0, 0, view.width, view.height);
if (!meta) return;
context.save();
context.translate(view.width / 2, view.height / 2);
context.rotate(rotation.radians());
context.translate(-view.width / 2, -view.height / 2);
context.lineCap = "round";
context.lineJoin = "round";
const styles: Record<MapLayer["kind"], () => void> = {
expected: () => {
context.strokeStyle = themeColor("--color-text-secondary", "#9ca3af");
context.lineWidth = 1.5;
context.setLineDash([5, 4]);
},
current: () => {
context.strokeStyle = routeLineColor();
context.globalAlpha = 0.55;
context.lineWidth = 2;
context.setLineDash([]);
},
preview: () => {
context.strokeStyle = themeColor("--color-primary", "#6d28d9");
context.lineWidth = 3;
context.setLineDash([]);
},
};
for (const layer of layers) {
if (layer.points.length < 2) continue;
context.save();
styles[layer.kind]();
context.beginPath();
layer.points.forEach(([x, y], index) => {
const [sx, sy] = metricToScreen(meta!, view, x, y);
if (index) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
context.stroke();
context.restore();
}
context.restore();
}
function resize(): void {
const { width, height } = fitCanvas(canvas, context);
if (!width || !height) return;
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
// 처음 한 번만 맞춘다 — 그 뒤 창 크기가 바뀌어도 사용자가 만진 배율 · 자리를 지킨다.
if (!fitted) refit();
draw();
}
bindRouteEditNavigation({
canvas,
getView: () => view,
setView: (next) => (view = next),
getMeta: () => meta,
unrotateDelta: rotation.unrotateDelta,
leftPan: true,
draw,
});
// 모달이 붙기 전에는 칸 크기가 0 이다 — 붙은 뒤 크기가 잡히면 맞추고 그린다.
const observer = new ResizeObserver(() => resize());
observer.observe(canvas.parentElement!);
return {
setLayers(next) {
layers = next;
if (!fitted) refit();
draw();
},
dispose: () => observer.disconnect(),
};
}
@@ -288,3 +288,8 @@
}
/* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */
/* 종단도 판 자리에서 끈 배수유역도(`_Drainage_Side` · 계획서 24-2e) — 자리를 비운다. */
.b05-drainage[hidden] {
display: none;
}
@@ -9,13 +9,20 @@
white-space: nowrap;
}
/* 곡선 라벨 패널(`.b05-routeedit__label` = 모달 + 1) 위로 — 모달과 뒤 막이 함께 올라간다. */
.ui-modal__backdrop.b05-routerec-backdrop {
z-index: calc(var(--z-modal, 1000) + 2);
}
.ui-modal.b05-routerec {
width: min(900px, 94vw);
width: min(960px, 94vw);
max-height: 92vh;
font-size: var(--text-body-sm);
}
.b05-routerec__body {
gap: var(--spacing-12);
font-size: var(--text-body-sm);
}
/* 알약 — 하나만 고른 상태(라디오 묶음). */
@@ -26,12 +33,12 @@
}
.b05-routerec__pill {
padding: 6px var(--spacing-16);
padding: 8px var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-body-sm);
font-size: var(--text-body);
cursor: pointer;
}
@@ -53,7 +60,7 @@
.b05-routerec__main {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: var(--spacing-12);
align-items: start;
}
@@ -64,36 +71,27 @@
gap: var(--spacing-4, 4px);
}
.b05-routerec__map {
display: block;
width: 100%;
height: auto;
/* 미리 보기 지도 — 캔버스 칸(높이 고정). 회전 단추는 편집 창 것(`.b05-routeedit__spin`)과 같은 모양. */
.b05-routerec__mapwrap {
position: relative;
height: 380px;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px);
background: var(--color-surface);
}
.b05-routerec__map path {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
.b05-routerec__map {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.b05-routerec__map path.is-expected {
stroke: var(--color-text-secondary, #9ca3af);
stroke-dasharray: 5 4;
stroke-width: 1.5;
}
.b05-routerec__map path.is-current {
stroke: var(--map-route, #f97316);
stroke-width: 2;
opacity: 0.55;
}
.b05-routerec__map path.is-preview {
stroke: var(--color-primary);
stroke-width: 2.5;
.b05-routerec__maphint {
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.b05-routerec__legend {
@@ -101,7 +99,7 @@
flex-wrap: wrap;
gap: var(--spacing-12);
color: var(--color-text-secondary);
font-size: var(--text-caption);
font-size: var(--text-body-sm);
}
.b05-routerec__legend i {
@@ -128,53 +126,81 @@
display: flex;
flex-direction: column;
gap: var(--spacing-12);
color: var(--color-text-secondary);
font-size: var(--text-caption);
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
/* 비중 — 세로 막대 셋이 나란히(값 위 · 이름 아래). 슬라이더가 세로로 선다. */
.b05-routerec__weights {
display: grid;
gap: 4px;
display: flex;
flex-direction: column;
gap: var(--spacing-4, 4px);
}
.b05-routerec__weights label {
display: grid;
grid-template-columns: 3.5em 1fr 2.5em;
align-items: center;
.b05-routerec__weights[hidden] {
display: none;
}
.b05-routerec__bars {
display: flex;
gap: var(--spacing-8);
}
.b05-routerec__weights output {
.b05-routerec__bar {
display: flex;
flex: 1 1 0;
flex-direction: column;
align-items: center;
gap: var(--spacing-4, 4px);
}
.b05-routerec__bar input[type="range"] {
width: 28px;
height: 130px;
margin: 0;
writing-mode: vertical-lr;
direction: rtl; /* 위로 올릴수록 큰 값 */
accent-color: var(--color-primary);
}
.b05-routerec__bar output {
color: var(--color-text-body);
text-align: right;
font-size: var(--text-body);
font-weight: 600;
}
.b05-routerec__bar span {
color: var(--color-text-secondary);
}
.b05-routerec__sum {
text-align: right;
text-align: center;
font-weight: 600;
}
/* 공통 제약 네 칸 — 비우면 placeholder 가 기본값. */
/* 공통 제약 네 칸 — 한 줄에 하나(이름 · 입력 · 단위). 비우면 placeholder 가 기본값. */
.b05-routerec__criteria {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8) var(--spacing-12);
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--spacing-8);
}
.b05-routerec__criteria label {
display: inline-flex;
display: grid;
grid-template-columns: minmax(0, 1fr) 5.5em 1.5em;
align-items: center;
gap: 4px;
white-space: nowrap;
gap: var(--spacing-8);
color: var(--color-text-body);
}
.b05-routerec__criteria input {
width: 4em;
padding: 2px 4px;
width: 100%;
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-caption);
font-size: var(--text-body);
text-align: right;
}
@@ -191,8 +217,8 @@
flex-direction: column;
gap: 2px;
color: var(--color-text-secondary);
font-size: var(--text-caption);
line-height: 1.45;
font-size: var(--text-body-sm);
line-height: 1.5;
}
/* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */
@@ -212,7 +238,7 @@
.b05-routerec__state {
flex: 1 1 12em;
color: var(--color-text-body);
font-size: var(--text-caption);
font-size: var(--text-body-sm);
}
/* 좁은 폭 — 지도 아래로 조작 칸. */
@@ -220,4 +246,8 @@
.b05-routerec__main {
grid-template-columns: minmax(0, 1fr);
}
.b05-routerec__mapwrap {
height: 300px;
}
}