Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_5
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_profile.ts
|
||||
* 종단도 한 벌(계획서 24-1 · 24-2c) — 종단 보기 · 횡단 보기가 **같은 종단도**를 가운데
|
||||
* 아래쪽에 둔다. 도구(▲▼ 고치기 · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 ·
|
||||
* 뱃지)와 막기 규칙(최소 흙덮기 · 기울기 상한)은 보기와 무관하게 하나.
|
||||
*
|
||||
* 판 자리 둘 — 종단도는 자리만 내주고 판은 밖에서 만들어 끼운다(나중에 떼기 쉽게).
|
||||
* · side 종단도 오른쪽 판(배수유역도 · sub7). `DesignSideFactory` 로 받는다.
|
||||
* · overlay 종단도 위 겹침 판(유토곡선 · 표). 종단도가 직접 만든다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
DrainagePanel,
|
||||
DrainagePanelCallbacks,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Drainage_Panel_Types";
|
||||
|
||||
/** 가운데 보기 — 종단 보기(3D + 종단도) · 횡단 보기(종단도 + 횡단 카드). */
|
||||
export type DesignView = "profile" | "cross";
|
||||
|
||||
/**
|
||||
* 오른쪽 판을 만들 때 종단도가 넘겨 주는 것 — 판이 알릴 일(관 목록 · 유역 · 관 고름 ·
|
||||
* 우클릭 메뉴 항목)과 처음 보기. 판은 `onPipesChanged` 를 **반드시** 불러야 종단도가
|
||||
* 최소 흙덮기를 판정한다.
|
||||
*/
|
||||
export interface DesignSideHost extends DrainagePanelCallbacks {
|
||||
projectId: string;
|
||||
view: DesignView;
|
||||
}
|
||||
|
||||
/**
|
||||
* 오른쪽 판(배수유역도). 종단도는 만든 직후 `load(projectId)` · 노선이 오면 `setRoute` ·
|
||||
* 구조물이 바뀌면 `setIntervalSpans` · 되돌리기에 `pipeChainages`/`setPipeChainages` ·
|
||||
* 보기가 바뀌면 `setView` · 걷을 때 `dispose` 를 부른다. 뿌리(`root`)는 종단 열 오른쪽에 붙는다.
|
||||
*/
|
||||
export type DesignSideBoard = DrainagePanel & {
|
||||
/** 보기가 바뀌었다 — 횡단 보기에서도 켜고 끈다(판이 정함). */
|
||||
setView?: (view: DesignView) => void;
|
||||
};
|
||||
|
||||
/** 오른쪽 판을 끼우는 함수 모양 — 종단도를 만들 때 한 번 부른다. */
|
||||
export type DesignSideFactory = (host: DesignSideHost) => DesignSideBoard;
|
||||
|
||||
/** 위 겹침 판(유토곡선 · 표) — 종단 스크롤러와 가로 스크롤을 맞추는 떼었다 붙이는 판. */
|
||||
export interface DesignOverlayBoard {
|
||||
overlay: HTMLElement;
|
||||
handle: HTMLElement;
|
||||
attachScrollSync: (scroller: HTMLElement) => void;
|
||||
}
|
||||
|
||||
/** 종단도를 만들 때 넘기는 것. */
|
||||
export interface DesignProfileContext {
|
||||
projectId: string;
|
||||
/** 처음 보기. */
|
||||
view: DesignView;
|
||||
/** 오른쪽 판 — 없으면 판 없이 종단 열만. */
|
||||
side?: DesignSideFactory;
|
||||
}
|
||||
|
||||
/** 종단도 한 벌. */
|
||||
export interface DesignProfile {
|
||||
root: HTMLElement;
|
||||
/** 보기가 바뀌었다 — 종단도는 그대로 두고 판에만 알린다(다시 만들지 않음). */
|
||||
setView: (view: DesignView) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Side.ts
|
||||
* 배수유역도를 종단도의 **side 판**으로 끼우는 홀로 선 창구(계획서 24-2e).
|
||||
*
|
||||
* 판(`createDrainagePanel`)은 종단 패널을 모른다 — 종단쪽 일은 전부 `DrainageSideHost`
|
||||
* (= `DrainagePanelCallbacks`, `_Panel_Types` 약속)로 받고, 종단쪽에서 들어오는 일은
|
||||
* `panel` 의 창구(`setRoute` · `setIntervalSpans` · `pipeChainages` · `setPipeChainages` ·
|
||||
* `selectPipeAtChainage` …)로만 한다.
|
||||
* · 관 목록이 바뀜(최소 흙덮기 경고 · 구조물 라인 · 통합 목록) = `host.onPipesChanged`
|
||||
* · 관 자리 읽기 · 쓰기(되돌리기) = `panel.pipeChainages()` · `panel.setPipeChainages()`
|
||||
* · 우클릭 메뉴의 구조물 종류 = `host.structureMenuItems`
|
||||
* · 구간 띠 · 노선 = `panel.setIntervalSpans()` · `panel.setRoute()`
|
||||
* 두 보기(종단 · 횡단)에서 같은 판을 끼우고 **켜고 끈다** — 끄면 자리를 비우되 관 · 유역
|
||||
* 상태는 그대로 살아 있다(다시 켜면 이어서 보임). 켬 · 끔을 어디에 적을지는 끼우는 쪽 몫이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
|
||||
import type { DrainagePanel, DrainagePanelCallbacks } from "./B05_Profile_UI_Drainage_Panel_Types";
|
||||
|
||||
/** 종단쪽이 넘기는 이음 — 판 바깥 약속(`DrainagePanelCallbacks`) 그대로. */
|
||||
export type DrainageSideHost = DrainagePanelCallbacks;
|
||||
|
||||
export interface DrainageSide {
|
||||
/** 종단도 side 자리에 붙일 뿌리. */
|
||||
root: HTMLElement;
|
||||
/** 판의 창구 — 종단쪽이 노선 · 구간 띠 · 관 자리 · 선택을 밀어 넣는다. */
|
||||
panel: DrainagePanel;
|
||||
/** 켜고 끈다. 켜면 숨겼던 사이 바뀐 크기 · 그림을 판이 스스로 다시 맞춘다. */
|
||||
setVisible: (visible: boolean) => void;
|
||||
isVisible: () => boolean;
|
||||
/** 켬 · 끔이 바뀔 때마다 부른다(단추 표시 · 저장). 해제 함수를 돌려준다. */
|
||||
onVisibleChange: (listener: (visible: boolean) => void) => () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export interface DrainageSideOptions {
|
||||
/** 처음 켤지(기본 켬). */
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export function createDrainageSide(
|
||||
host: DrainageSideHost = {},
|
||||
options: DrainageSideOptions = {},
|
||||
): DrainageSide {
|
||||
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 {
|
||||
root: panel.root,
|
||||
panel,
|
||||
setVisible,
|
||||
isVisible: () => visible,
|
||||
onVisibleChange(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
dispose() {
|
||||
listeners.clear();
|
||||
panel.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 켜고 끄는 단추 — 어느 보기에서든 같은 모양 · 같은 글. 누르면 `side` 를 뒤집는다. */
|
||||
export function createDrainageToggleButton(side: DrainageSide): 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;
|
||||
}
|
||||
@@ -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">휠 = 확대 · 축소 끌기 = 이동 ↺ ↻ = 회전</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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/* 배수유역 관 보고(`B05_Profile_UI_Drainage_Report.ts`) 셈 단위검증 헬퍼 — TS 를 트랜스파일해 Node 로 돈다. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(__dirname, "..", "..", "B05_Profile", "B05_Profile_UI_Drainage_Report.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const { buildPipeReports } = box.exports;
|
||||
|
||||
const store = {
|
||||
get: (chainage) =>
|
||||
chainage === 100.5 ? { facility: "box_culvert", start_m: 95, end_m: 106, options: { h: 2 } } : null,
|
||||
};
|
||||
const basins = [
|
||||
{ chainage_m: 40, pipe_diameter_mm: 800, design_flow_m3s: 1.2, bridge_required: false },
|
||||
{ chainage_m: 100.5, pipe_diameter_mm: 1000, design_flow_m3s: 3.4, bridge_required: true },
|
||||
];
|
||||
const out = buildPipeReports(
|
||||
[
|
||||
{ chainage_m: 40.004, reason: "auto" },
|
||||
{ chainage_m: 100.499 },
|
||||
{ chainage_m: 200 },
|
||||
],
|
||||
basins,
|
||||
store,
|
||||
);
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""배수유역도 판이 종단 패널을 모르고 홀로 선다(계획서 24-2e).
|
||||
|
||||
- 배수유역 파일(`B05_Profile_UI_Drainage_*`)은 종단 패널(`_Profile_*`)이나 B06 을 가져오지 않는다 —
|
||||
종단쪽 일은 `_Panel_Types` 약속(콜백 · 창구)으로만 오간다.
|
||||
- 파일마다 700줄 이하.
|
||||
- 관 보고 만들기(`_Report`)는 상태 없이 같은 결과: ±0.5m 유역 매칭 · 세월교 유역은 관경 null ·
|
||||
시설 정보 · 출처 기본값 user.
|
||||
- `createDrainageSide` 가 켜고 끄는 창구를 낸다(두 보기에서 같은 판).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _drainage_files() -> list[Path]:
|
||||
return sorted(B05.glob("B05_Profile_UI_Drainage_*.ts"))
|
||||
|
||||
|
||||
def test_배수유역_파일은_종단_패널과_B06을_안_가져옴():
|
||||
files = _drainage_files()
|
||||
assert len(files) >= 10
|
||||
for path in files:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
for line in source.splitlines():
|
||||
if not line.lstrip().startswith(("import ", "} from ", "export {", "export type {")):
|
||||
continue
|
||||
assert "UI_Profile_" not in line, (path.name, line)
|
||||
assert "B06_Section" not in line, (path.name, line)
|
||||
|
||||
|
||||
def test_배수유역_파일은_700줄_이하():
|
||||
for path in _drainage_files():
|
||||
lines = len(path.read_text(encoding="utf-8").splitlines())
|
||||
assert lines <= 700, (path.name, lines)
|
||||
|
||||
|
||||
def test_켜고_끄는_창구():
|
||||
side = (B05 / "B05_Profile_UI_Drainage_Side.ts").read_text(encoding="utf-8")
|
||||
for name in (
|
||||
"createDrainageSide",
|
||||
"createDrainageToggleButton",
|
||||
"setVisible",
|
||||
"onVisibleChange",
|
||||
):
|
||||
assert name in side
|
||||
assert "root.hidden" in side or "panel.root.hidden" in side
|
||||
types = (B05 / "B05_Profile_UI_Drainage_Panel_Types.ts").read_text(encoding="utf-8")
|
||||
assert "export interface DrainagePipeReport" in types
|
||||
assert "onPipesChanged?: (pipes: DrainagePipeReport[]) => void;" in types
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def reports():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_drainage_report.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_관_보고_유역_매칭과_기본값(reports):
|
||||
first, box, plain = reports
|
||||
# 누가거리는 소수 둘째 자리로 · 담당 유역의 관경 · 설계유량이 붙는다.
|
||||
assert first["chainage_m"] == 40 and first["effective_diameter_mm"] == 800
|
||||
assert first["design_flow_m3s"] == 1.2 and first["facility"] == "pipe"
|
||||
assert first["source"] == "auto"
|
||||
# 세월교 검토 유역은 관경만 null · 설계유량은 남김 · 시설 정보를 데려옴.
|
||||
assert box["chainage_m"] == 100.5 and box["effective_diameter_mm"] is None
|
||||
assert box["design_flow_m3s"] == 3.4
|
||||
assert box["facility"] == "box_culvert" and (box["start_m"], box["end_m"]) == (95, 106)
|
||||
assert box["options"] == {"h": 2} and box["source"] == "user"
|
||||
# 유역 없는 관 — 모두 null · 맨 배관.
|
||||
assert plain["effective_diameter_mm"] is None and plain["design_flow_m3s"] is None
|
||||
assert plain["facility"] == "pipe"
|
||||
@@ -192,3 +192,24 @@ def test_되돌리기_다시하기가_현재_추천을_함께_되살림(results)
|
||||
{"logic": "earthwork"},
|
||||
None,
|
||||
]
|
||||
|
||||
|
||||
def test_노선_추천_다듬기_23_3():
|
||||
"""구분선은 [노선 추천] 오른쪽 · 모달 지도는 편집 창 함수를 가져다 씀 · 모달이 라벨 패널 위."""
|
||||
page = os.path.join(HERE, "..", "..", "B05_Profile")
|
||||
|
||||
def read(name):
|
||||
return open(os.path.join(page, name), encoding="utf-8").read()
|
||||
|
||||
chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts")
|
||||
rec = chrome.index('data-act="recommend"')
|
||||
divider = chrome.index("b05-routeedit__divider", rec)
|
||||
assert rec < divider < chrome.index('data-act="contour-minor"')
|
||||
assert chrome.count("b05-routeedit__divider") == 2
|
||||
shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts")
|
||||
for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"):
|
||||
assert f"{name}(" in shared
|
||||
css = read("B05_Profile_UI_Style_RouteRecommend.css")
|
||||
assert "calc(var(--z-modal, 1000) + 2)" in css # 라벨 패널(+1) 위
|
||||
assert "writing-mode: vertical-lr" in css
|
||||
assert 'backdropClass: "b05-routerec-backdrop"' in read("B05_Profile_UI_RouteEdit_Recommend.ts")
|
||||
|
||||
Reference in New Issue
Block a user