Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8
This commit is contained in:
@@ -164,8 +164,8 @@ export const STATE_REGISTRY = {
|
||||
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
|
||||
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
|
||||
crossdesign: { bucket: "draft", scope: "route" },
|
||||
/** B08 홍수량 계산서 입력 `{측점: 입력}` — 지금은 캐시만 · 배수유역 · B05/B06 값과 안 이음(62-2). */
|
||||
"b08-hydro": { bucket: "draft", scope: "project" },
|
||||
/** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · 배수유역 · B05/B06 값과 안 이음(69-6). */
|
||||
"b08-hydro": { bucket: "draft", scope: "project", version: 2 },
|
||||
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
|
||||
* 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */
|
||||
"alignment-draft": {
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface RouteInitialRequest {
|
||||
mode: RouteInitialLogic;
|
||||
weights?: RouteInitialWeights;
|
||||
criteria?: RouteInitialCriteria;
|
||||
/** 추천 점수에 균형 종단(63-4)을 넣을지 — 끄면 원지반만. 안 보내면 켬(서버 기본). */
|
||||
with_profile?: boolean;
|
||||
}
|
||||
|
||||
/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */
|
||||
|
||||
@@ -64,7 +64,11 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import (
|
||||
similar,
|
||||
weighted,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import attach_profile, cover_shortfall
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
|
||||
attach_profile,
|
||||
cover_shortfall,
|
||||
plain_metrics,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
|
||||
CORRIDOR_M,
|
||||
ROAD_WIDTH_M,
|
||||
@@ -219,6 +223,7 @@ def generate_initial_route(
|
||||
target_grade: float | None = None,
|
||||
design_speed: int = 20,
|
||||
pipes: list[tuple[float, float, float]] | None = None,
|
||||
with_profile: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).
|
||||
|
||||
@@ -228,6 +233,7 @@ def generate_initial_route(
|
||||
|
||||
후보마다 균형 종단(`..._Profile` · PLAN 63-4)을 붙여 토공 점수를 그것으로 고른다 — 편집 창
|
||||
추천 점수 · 미리 보기에만 쓰고 확정 종단과 무관. `pipes`(x, y, 덮개 여유)는 덮개 부족 수만 센다.
|
||||
`with_profile` 끄면(종단 반영 끔) 종단 없이 63-4 전 점수(원지반 가늠)로 고른다.
|
||||
"""
|
||||
if mode not in MODES:
|
||||
raise ValueError(f"모르는 갈래: {mode}")
|
||||
@@ -303,12 +309,16 @@ def generate_initial_route(
|
||||
if target == "weighted":
|
||||
outline, metrics = weighted(pool, used, min_radius_m, measure)
|
||||
else:
|
||||
for route in [*pool.candidates, pool.follow]:
|
||||
profiled(route)
|
||||
outline, metrics = pick(target, pool.candidates)
|
||||
profiled((outline, metrics))
|
||||
if pipes and "error" not in metrics["profile"]:
|
||||
metrics["profile"].update(cover_shortfall(metrics["profile"], outline.vertices, pipes))
|
||||
if with_profile:
|
||||
for route in [*pool.candidates, pool.follow]:
|
||||
profiled(route)
|
||||
outline, metrics = pick(target, pool.candidates, with_profile)
|
||||
if with_profile:
|
||||
profiled((outline, metrics))
|
||||
if pipes and "error" not in metrics["profile"]:
|
||||
metrics["profile"].update(cover_shortfall(metrics["profile"], outline.vertices, pipes))
|
||||
else:
|
||||
metrics = plain_metrics(metrics)
|
||||
return {
|
||||
"mode": mode,
|
||||
"logic": mode,
|
||||
@@ -321,7 +331,8 @@ def generate_initial_route(
|
||||
"curves": [curve.as_dict() for curve in outline.curves],
|
||||
"metrics": metrics,
|
||||
# 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만
|
||||
"similar": None if pool is None else similar(pool, target, outline),
|
||||
"similar": None if pool is None else similar(pool, target, outline, with_profile),
|
||||
"with_profile": with_profile,
|
||||
"band_width_m": None if pool is None else round(pool.band_width_m, 1),
|
||||
"criteria": {
|
||||
"max_grade_pct": round(max_grade * 100.0, 2),
|
||||
|
||||
@@ -36,7 +36,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
|
||||
COST_EARTH_M3,
|
||||
COST_LENGTH_M,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import profile_earth
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
|
||||
CROSSING_BAND,
|
||||
OBJECTIVES,
|
||||
@@ -213,12 +213,15 @@ def build_pool(
|
||||
return pool
|
||||
|
||||
|
||||
def similar(pool: Pool, mode: str, outline: Any) -> list[dict[str, Any]]:
|
||||
def similar(pool: Pool, mode: str, outline: Any, with_profile: bool = True) -> list[dict[str, Any]]:
|
||||
"""이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m).
|
||||
|
||||
화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다."""
|
||||
here = LineString(outline.vertices)
|
||||
others = {"follow": pool.follow, **{name: pick(name, pool.candidates) for name in GOALS}}
|
||||
others = {
|
||||
"follow": pool.follow,
|
||||
**{name: pick(name, pool.candidates, with_profile) for name in GOALS},
|
||||
}
|
||||
found = []
|
||||
for name, (other, _) in others.items():
|
||||
if name == mode or other is None:
|
||||
@@ -284,7 +287,9 @@ GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = {
|
||||
}
|
||||
|
||||
|
||||
def pick(mode: str, candidates: list[Route]) -> Route:
|
||||
"""갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보."""
|
||||
def pick(mode: str, candidates: list[Route], with_profile: bool = True) -> Route:
|
||||
"""갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보. `with_profile` 끔 = 원지반 가늠 점수."""
|
||||
goal = GOALS[mode]
|
||||
return min(candidates, key=lambda item: goal(item[1]))
|
||||
return min(
|
||||
candidates, key=lambda item: goal(item[1] if with_profile else plain_metrics(item[1]))
|
||||
)
|
||||
|
||||
@@ -366,6 +366,7 @@ def attach_profile(
|
||||
metrics["profile"] = {"error": str(error)}
|
||||
return
|
||||
estimate = metrics["cut_m3"] + metrics["fill_m3"]
|
||||
metrics["cost_index_base"] = metrics["cost_index"] # 「종단 반영」 끔이 되돌아갈 값
|
||||
metrics["profile"] = profile
|
||||
metrics["earth_estimate_m3"] = round(estimate, 1)
|
||||
metrics["cost_index"] = round(
|
||||
@@ -376,3 +377,19 @@ def attach_profile(
|
||||
def profile_earth(profile: dict[str, Any]) -> float:
|
||||
"""균형 종단 토공 점수 — 절토(자연) + 성토 + 반입 + 사토 ㎥."""
|
||||
return profile["cut_m3"] + profile["fill_m3"] + profile["borrow_m3"] + profile["spoil_m3"]
|
||||
|
||||
|
||||
def plain_metrics(metrics: dict[str, Any]) -> dict[str, Any]:
|
||||
"""균형 종단을 붙이기 전 비교값(복사본) — 「종단 반영」 끔(원지반 가늠 점수).
|
||||
|
||||
묶음 캐시의 비교값은 종단을 붙인 채 남으므로, 끌 때는 붙인 것을 걷어 낸다."""
|
||||
if "profile" not in metrics and "cost_index_base" not in metrics:
|
||||
return metrics
|
||||
plain = {
|
||||
key: value
|
||||
for key, value in metrics.items()
|
||||
if key not in ("profile", "earth_estimate_m3", "cost_index_base")
|
||||
}
|
||||
if "cost_index_base" in metrics:
|
||||
plain["cost_index"] = metrics["cost_index_base"]
|
||||
return plain
|
||||
|
||||
@@ -118,6 +118,8 @@ class RouteInitialRequest(BaseModel):
|
||||
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
|
||||
weights: RouteInitialWeights | None = None
|
||||
criteria: RouteInitialCriteria | None = None
|
||||
# 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘.
|
||||
with_profile: bool = True
|
||||
|
||||
|
||||
class RouteReplanRequest(BaseModel):
|
||||
|
||||
@@ -80,7 +80,9 @@ async def initial_route(
|
||||
terrain, streams = await asyncio.to_thread(
|
||||
load_terrain_and_streams, project_root, selection
|
||||
)
|
||||
pipes = await asyncio.to_thread(project_pipes, project_root)
|
||||
pipes = (
|
||||
await asyncio.to_thread(project_pipes, project_root) if request.with_profile else None
|
||||
)
|
||||
result = await asyncio.to_thread(
|
||||
functools.partial(
|
||||
generate_initial_route,
|
||||
@@ -96,6 +98,7 @@ async def initial_route(
|
||||
target_grade=None if target_pct is None else target_pct / 100.0,
|
||||
design_speed=design_speed or 20,
|
||||
pipes=pipes,
|
||||
with_profile=request.with_profile,
|
||||
)
|
||||
)
|
||||
except (FileNotFoundError, ValueError) as error:
|
||||
|
||||
@@ -91,6 +91,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
weights: null as RouteInitialWeights | null,
|
||||
locks: { ...NO_WEIGHT_LOCKS },
|
||||
};
|
||||
/** 추천 「종단 반영」 — 이 편집 창이 사는 동안만 남는다(저장 · 캐시 · [취소] 되돌림 없이 창과 함께 사라짐). */
|
||||
const profileMemory = { withProfile: true };
|
||||
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */
|
||||
let planRead: Promise<RoutePlanResponse> | null = null;
|
||||
|
||||
@@ -114,11 +116,13 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
logic: RouteInitialLogic,
|
||||
weights: RouteInitialWeights | null,
|
||||
criteria: RouteInitialCriteria | null,
|
||||
withProfile: boolean,
|
||||
): Promise<InitialChoice> {
|
||||
const response = await fetchRouteInitial(projectId, {
|
||||
mode: logic,
|
||||
...(weights ? { weights } : {}),
|
||||
...(criteria ? { criteria } : {}),
|
||||
...(withProfile ? {} : { with_profile: false }),
|
||||
});
|
||||
return normalizeRouteInitial(response, logic, weights, criteria);
|
||||
}
|
||||
@@ -137,6 +141,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
defaults,
|
||||
weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) },
|
||||
weightMemory,
|
||||
profileMemory,
|
||||
planMinRadiusM: params.planMinRadiusM(),
|
||||
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
|
||||
current: opening!.planned.map(([x, y]) => [x, y]),
|
||||
|
||||
@@ -45,7 +45,11 @@ import {
|
||||
type WeightKey,
|
||||
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
|
||||
import { recommendHelpHtml, syncRecommendHelp } from "./B05_Profile_UI_RouteEdit_Recommend_Help";
|
||||
import {
|
||||
bindRecommendHelp,
|
||||
recommendHelpHtml,
|
||||
syncRecommendHelp,
|
||||
} from "./B05_Profile_UI_RouteEdit_Recommend_Help";
|
||||
import {
|
||||
balanceLine,
|
||||
candidatesTable,
|
||||
@@ -128,6 +132,8 @@ export interface RecommendParams {
|
||||
weights: RouteInitialWeights;
|
||||
/** 비중 · 고정 상태 — 편집 창이 사는 동안 모달을 닫았다 열어도 남는다. 여기서 고쳐 간다. */
|
||||
weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks };
|
||||
/** 「종단 반영」 — 켬이 기본(지금 동작). 편집 창이 사는 동안만 남고 저장하지 않는다. */
|
||||
profileMemory: { withProfile: boolean };
|
||||
planMinRadiusM: number;
|
||||
/** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */
|
||||
expected: Vertex[];
|
||||
@@ -137,6 +143,7 @@ export interface RecommendParams {
|
||||
logic: RouteInitialLogic,
|
||||
weights: RouteInitialWeights | null,
|
||||
criteria: RouteInitialCriteria | null,
|
||||
withProfile: boolean,
|
||||
) => Promise<InitialChoice>;
|
||||
/** [적용] — 미리 본 노선을 편집 창에 놓는다. */
|
||||
onApply: (choice: InitialChoice) => void;
|
||||
@@ -211,6 +218,11 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
</div>
|
||||
<span class="b05-routerec__sum" data-weight-sum></span>
|
||||
</div>
|
||||
<label class="b05-routerec__profile-opt" data-profile-opt
|
||||
title="켬 = 후보마다 균형 종단을 세워 토공 점수에 넣음 · 끔 = 원지반만 보고 점수를 냄(더 빠름)">
|
||||
<input type="checkbox" data-with-profile /> 종단 반영
|
||||
<small>끄면 원지반만 · 이 창 안에서만</small>
|
||||
</label>
|
||||
<div class="b05-routerec__criteria">
|
||||
${CRITERIA_KEYS.map(
|
||||
(key) => `<label title="${CRITERIA_FIELD[key].title}">${CRITERIA_FIELD[key].label}
|
||||
@@ -255,6 +267,9 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
const stateEl = q<HTMLElement>("[data-state]");
|
||||
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
|
||||
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
|
||||
const profileBox = q<HTMLInputElement>("[data-with-profile]");
|
||||
profileBox.checked = params.profileMemory.withProfile;
|
||||
bindRecommendHelp(body);
|
||||
|
||||
criteriaInputs.forEach((input) => {
|
||||
const value =
|
||||
@@ -332,6 +347,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
input.disabled = busy;
|
||||
});
|
||||
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
|
||||
profileBox.disabled = busy;
|
||||
rerun.hidden = !dirty;
|
||||
rerun.disabled = busy || invalid.size > 0;
|
||||
applyButton.disabled = busy || dirty || !preview || invalid.size > 0;
|
||||
@@ -380,6 +396,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
const mine = ++seq;
|
||||
const sentWeights = logic === "weighted" ? { ...weights } : null;
|
||||
const criteria = currentCriteria();
|
||||
const withProfile = params.profileMemory.withProfile;
|
||||
busy = true;
|
||||
dirty = false;
|
||||
preview = null;
|
||||
@@ -388,7 +405,9 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
const label = choiceLabel(logic, sentWeights);
|
||||
const tick = (): void => {
|
||||
const seconds = Math.round((Date.now() - started) / 1000);
|
||||
stateEl.textContent = `${label} 셈하는 중… ${seconds}초 (후보마다 균형 종단을 셈합니다)`;
|
||||
stateEl.textContent = `${label} 셈하는 중… ${seconds}초${
|
||||
withProfile ? " (후보마다 균형 종단을 셈합니다)" : " (종단 반영 끔 — 원지반만)"
|
||||
}`;
|
||||
};
|
||||
window.clearInterval(ticker);
|
||||
tick();
|
||||
@@ -399,6 +418,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
logic,
|
||||
sentWeights,
|
||||
Object.keys(criteria).length ? criteria : null,
|
||||
withProfile,
|
||||
);
|
||||
if (closed || mine !== seq) return;
|
||||
// 비운 칸을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
|
||||
@@ -409,6 +429,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
}
|
||||
preview = choice;
|
||||
if (choice.balance) seen.set(label, choice.balance);
|
||||
else seen.delete(label); // 종단 반영 끔 — 켜고 셈했던 같은 갈래 줄은 지움
|
||||
stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)} — [적용]을 눌러야 편집 창에 놓입니다.`;
|
||||
} catch (error) {
|
||||
if (closed || mine !== seq) return;
|
||||
@@ -492,6 +513,11 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
render();
|
||||
});
|
||||
});
|
||||
profileBox.addEventListener("change", () => {
|
||||
params.profileMemory.withProfile = profileBox.checked;
|
||||
dirty = preview !== null;
|
||||
render();
|
||||
});
|
||||
rerun.addEventListener("click", () => void run());
|
||||
applyButton.addEventListener("click", () => {
|
||||
if (!preview) return;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Recommend_Help.ts
|
||||
* [노선 추천] 모달 오른쪽 설명 칸(계획서 48-2) — 갈래 여덟 · 공통 제약 넷 · 결과 줄 읽는 법.
|
||||
* [노선 추천] 모달 오른쪽 설명 칸(계획서 48-2 · 64-2) — 갈래 여덟 · 공통 제약 넷 · 결과 줄 읽는 법.
|
||||
*
|
||||
* 글은 서버 엔진(`B05_Profile_Engine_RouteInitial*`)이 실제로 하는 일을 따라 적는다 — 엔진을
|
||||
* 바꾸면 여기 글도 같이 고친다. 고른 갈래는 강조하고 보이는 자리로 굴린다.
|
||||
* 바꾸면 여기 글도 같이 고친다. 갈래 설명은 **한 번에 한 갈래**(쪽) — 위 ◀ ▶ 단추와 「n / 전체」로
|
||||
* 넘기고, 고른 갈래(알약)를 바꾸면 그 갈래 쪽으로 간다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { RouteInitialLogic } from "./B05_Profile_Api_RouteInitial";
|
||||
@@ -18,6 +19,8 @@ interface HelpEntry {
|
||||
when: string;
|
||||
/** 주의할 점. */
|
||||
caution: string;
|
||||
/** 「종단 반영」 켬 · 끔이 점수에 미치는 영향(토공 점수를 쓰는 갈래만). */
|
||||
profile?: string;
|
||||
}
|
||||
|
||||
const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
|
||||
@@ -51,6 +54,8 @@ const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
|
||||
],
|
||||
when: "토공량 · 사토 처리를 줄여야 할 때",
|
||||
caution: "비탈면 흙은 빠진 어림값 · 연장이 길어지는지 비교값으로 함께 볼 것",
|
||||
profile:
|
||||
"켬 = 후보마다 균형 종단을 세워 절 + 성 + 반입 + 사토 ㎥ 로 비교 · 끔 = 종단 없이 원지반 절 + 성 어림으로 비교(더 빠름)",
|
||||
},
|
||||
gentle: {
|
||||
summary: "종단기울기가 가장 순한 노선",
|
||||
@@ -90,6 +95,8 @@ const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
|
||||
],
|
||||
when: "토공 · 연장 · 구조물 수를 한꺼번에 비교하고 싶을 때",
|
||||
caution: "실제 원가가 아닌 상대값(임시 비율) — 갈래끼리 비교용으로만 볼 것",
|
||||
profile:
|
||||
"켬 = 토공 몫을 균형 종단 토량으로 셈 · 끔 = 원지반 절 + 성 어림으로 셈(종단을 세우지 않음)",
|
||||
},
|
||||
weighted: {
|
||||
summary: "예상 · 토공 · 기울기 세 비중(합 100)을 섞어 찾은 노선",
|
||||
@@ -104,6 +111,10 @@ const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
|
||||
};
|
||||
|
||||
const CRITERIA_HELP: Array<{ name: string; text: string }> = [
|
||||
{
|
||||
name: "종단 반영",
|
||||
text: "켬(기본) = 후보마다 종단을 세워 토공 점수에 넣음 · 끔 = 원지반만 보고 점수를 냄(종단 그림 · 덮개 줄은 안 나옴). 이 창 안에서만 적용되고 [적용]한 노선 · 저장값에는 남지 않음",
|
||||
},
|
||||
{
|
||||
name: "종단기울기 상한(%)",
|
||||
text: "이보다 가파른 구간을 만들지 않음. 비우면 임도 종류 · 지형 · 설계속도 법정 값(20km/h 일반 9% · 특수 14%). 곡선을 넣은 뒤 넘으면 상한을 조금씩 낮춰 다시 찾음",
|
||||
@@ -152,24 +163,35 @@ const LOGIC_ORDER: RouteInitialLogic[] = [
|
||||
"weighted",
|
||||
];
|
||||
|
||||
/** 설명 칸 HTML — 모달 본문 끝에 붙는다(세로 구분선 오른쪽). */
|
||||
export function recommendHelpHtml(): string {
|
||||
const logics = LOGIC_ORDER.map((key) => {
|
||||
const entry = LOGIC_HELP[key];
|
||||
return `<article class="b05-routerec__help-item" data-help-logic="${key}">
|
||||
<h4>${LOGIC_LABEL[key]}</h4>
|
||||
/** 갈래 설명 쪽 하나 — 고른 갈래(`is-on`)는 테두리로 알린다. */
|
||||
function logicPage(key: RouteInitialLogic): string {
|
||||
const entry = LOGIC_HELP[key];
|
||||
const profile = entry.profile ? `<p><b>종단 반영</b> ${entry.profile}</p>` : "";
|
||||
return `<article class="b05-routerec__help-item" data-help-logic="${key}" hidden>
|
||||
<h4>${LOGIC_LABEL[key]} <span class="b05-routerec__help-picked">고른 갈래</span></h4>
|
||||
<p class="b05-routerec__help-summary">${entry.summary}</p>
|
||||
<ul>${entry.how.map((line) => `<li>${line}</li>`).join("")}</ul>
|
||||
<p><b>이럴 때</b> ${entry.when}</p>
|
||||
${profile}
|
||||
<p class="b05-routerec__help-caution"><b>주의</b> ${entry.caution}</p>
|
||||
</article>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/** 설명 칸 HTML — 모달 본문 끝에 붙는다(세로 구분선 오른쪽). */
|
||||
export function recommendHelpHtml(): string {
|
||||
const list = (items: Array<{ name: string; text: string }>): string =>
|
||||
`<dl>${items.map((item) => `<dt>${item.name}</dt><dd>${item.text}</dd>`).join("")}</dl>`;
|
||||
return `<aside class="b05-routerec__help" aria-label="노선 추천 설명">
|
||||
<h3>추천 갈래</h3>
|
||||
<p class="b05-routerec__help-lead">제약을 지키는 후보 노선 여러 벌을 찾은 뒤, 갈래마다 자기 기준이 가장 좋은 것을 고름.</p>
|
||||
${logics}
|
||||
<div class="b05-routerec__help-pager" role="group" aria-label="갈래 설명 쪽 넘기기">
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-help-nav="-1"
|
||||
aria-label="앞 갈래 설명">◀</button>
|
||||
<span data-help-page aria-live="polite"></span>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-help-nav="1"
|
||||
aria-label="다음 갈래 설명">▶</button>
|
||||
</div>
|
||||
${LOGIC_ORDER.map(logicPage).join("")}
|
||||
<h3>공통 제약</h3>
|
||||
<p class="b05-routerec__help-lead">예상노선 추적 · 비중 계산을 뺀 갈래는 넘지 않음 · 칸을 비우면 기본값.</p>
|
||||
${list(CRITERIA_HELP)}
|
||||
@@ -178,11 +200,39 @@ export function recommendHelpHtml(): string {
|
||||
</aside>`;
|
||||
}
|
||||
|
||||
/** 고른 갈래 설명을 강조하고 보이는 자리로 굴린다. */
|
||||
export function syncRecommendHelp(body: HTMLElement, logic: RouteInitialLogic): void {
|
||||
body.querySelectorAll<HTMLElement>("[data-help-logic]").forEach((item) => {
|
||||
const on = item.dataset.helpLogic === logic;
|
||||
if (on && !item.classList.contains("is-on")) item.scrollIntoView({ block: "nearest" });
|
||||
item.classList.toggle("is-on", on);
|
||||
/** 설명 쪽을 `index` 번째 갈래로 — 쪽 수는 갈래 수. */
|
||||
function showHelpPage(aside: HTMLElement, index: number): void {
|
||||
const page = Math.min(Math.max(index, 0), LOGIC_ORDER.length - 1);
|
||||
aside.dataset.page = String(page);
|
||||
aside.querySelectorAll<HTMLElement>("[data-help-logic]").forEach((item) => {
|
||||
item.hidden = item.dataset.helpLogic !== LOGIC_ORDER[page];
|
||||
});
|
||||
aside.querySelector<HTMLElement>("[data-help-page]")!.textContent =
|
||||
`${page + 1} / ${LOGIC_ORDER.length}`;
|
||||
aside.querySelector<HTMLButtonElement>('[data-help-nav="-1"]')!.disabled = page === 0;
|
||||
aside.querySelector<HTMLButtonElement>('[data-help-nav="1"]')!.disabled =
|
||||
page === LOGIC_ORDER.length - 1;
|
||||
aside.scrollTop = 0;
|
||||
}
|
||||
|
||||
/** ◀ ▶ 단추 — 모달 본문에 한 번만 건다. */
|
||||
export function bindRecommendHelp(body: HTMLElement): void {
|
||||
body.addEventListener("click", (event) => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLElement>("[data-help-nav]");
|
||||
const aside = body.querySelector<HTMLElement>(".b05-routerec__help");
|
||||
if (!button || !aside) return;
|
||||
showHelpPage(aside, Number(aside.dataset.page ?? 0) + Number(button.dataset.helpNav));
|
||||
});
|
||||
}
|
||||
|
||||
/** 고른 갈래를 표시하고, 고른 갈래가 바뀐 때만 그 갈래 쪽으로 간다(넘겨 보던 쪽은 그대로 둠). */
|
||||
export function syncRecommendHelp(body: HTMLElement, logic: RouteInitialLogic): void {
|
||||
const aside = body.querySelector<HTMLElement>(".b05-routerec__help");
|
||||
if (!aside) return;
|
||||
aside.querySelectorAll<HTMLElement>("[data-help-logic]").forEach((item) => {
|
||||
item.classList.toggle("is-on", item.dataset.helpLogic === logic);
|
||||
});
|
||||
if (aside.dataset.pickedLogic === logic) return;
|
||||
aside.dataset.pickedLogic = logic;
|
||||
showHelpPage(aside, LOGIC_ORDER.indexOf(logic));
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
|
||||
.b05-routerec__help-item h4 {
|
||||
margin: 0 0 2px;
|
||||
font-size: var(--text-body-sm);
|
||||
font-size: var(--text-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,49 @@
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* 「고른 갈래」 표시 — 지금 알약으로 고른 갈래의 쪽에만 보임. */
|
||||
.b05-routerec__help-picked {
|
||||
display: none;
|
||||
margin-left: var(--spacing-4, 4px);
|
||||
font-size: var(--text-caption, 11px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b05-routerec__help-item.is-on .b05-routerec__help-picked {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* 쪽 넘김 — ◀ n / 전체 ▶ */
|
||||
.b05-routerec__help-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-12);
|
||||
margin-bottom: var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.b05-routerec__help-item[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-routerec__profile-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
color: var(--color-text-body);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-routerec__profile-opt input {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.b05-routerec__profile-opt small {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b05-routerec__help-item p,
|
||||
.b05-routerec__help dd {
|
||||
margin: 2px 0;
|
||||
|
||||
@@ -188,6 +188,8 @@ export interface PlanKindsState {
|
||||
pieces: { id: string; name: string }[];
|
||||
kinds: PlanKind[];
|
||||
marks: PlanMark[];
|
||||
/** 종단 측점 간격(m) — 측점 NO + 거리 입력용(GET 만 · 종단이 없으면 null). */
|
||||
station_interval_m?: number | null;
|
||||
}
|
||||
|
||||
export function fetchPlanKinds(projectId: string): Promise<PlanKindsState> {
|
||||
@@ -205,6 +207,18 @@ export function addPlanKind(
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePlanKind(
|
||||
projectId: string,
|
||||
kindId: string,
|
||||
name: string,
|
||||
pieces: string[],
|
||||
): Promise<PlanKindsState> {
|
||||
return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ name, pieces }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deletePlanKind(projectId: string, kindId: string): Promise<PlanKindsState> {
|
||||
return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""B07 계획평면도 종류 — 정보 조각을 골라 한 장으로 얹는다(PLAN 60장).
|
||||
|
||||
계획평면도는 여러 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)가 **같은 양식**이다.
|
||||
정보 조각(등고선 · 중심선 · 비탈 외곽선 · 비탈 눈금 · IP · R · 측점 NO · 시종점 ·
|
||||
대피소/사토장 · 라이다 배경)마다 프로젝트 저장소에 파일이 하나씩 있고, 종류는 그중
|
||||
무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 한다(파일 입출력 없음).
|
||||
정보 조각(등고선 · 중심선 · 노견선 · 성토 · 절토 외곽선 · 비탈 눈금 · IP · R · 측점 NO ·
|
||||
시종점 · 대피소/사토장 · 배수시설 기호 · 라이다 음영)마다 프로젝트 저장소에 파일이 하나씩
|
||||
있고, 종류는 그중 무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만
|
||||
한다(파일 입출력 없음).
|
||||
|
||||
좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 도곽 최소점) x MM (1/1,200).
|
||||
"""
|
||||
@@ -25,10 +26,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_COMPASS_SIZE,
|
||||
_FONT_SIZE,
|
||||
_ROUTE_WIDTH,
|
||||
_STRUCTURE_FONT_SIZE,
|
||||
_STRUCTURE_SIZE_MM,
|
||||
_TITLE_FONT_SIZE,
|
||||
CONTOUR_COLOR,
|
||||
MM,
|
||||
STREAM_COLOR,
|
||||
STRUCTURE_COLOR,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
compass_entities,
|
||||
@@ -40,21 +44,29 @@ from config.config_system import DRAWING_SCALE_PLAN
|
||||
|
||||
# 조각 (id, 이름, 도면층 잠금) — 그리는 차례(배경이 먼저).
|
||||
PIECES: tuple[tuple[str, str, bool], ...] = (
|
||||
("lidar", "라이다 배경", True),
|
||||
("lidar", "라이다 음영", True),
|
||||
("contour", "등고선", True),
|
||||
("stream", "계류", True),
|
||||
("slope_ticks", "비탈 눈금", False),
|
||||
("slope_outline", "성토사면 외곽선", False),
|
||||
("road_edge", "노견선", False),
|
||||
("fill_outline", "성토 외곽선", False),
|
||||
("cut_outline", "절토 외곽선", False),
|
||||
("centerline", "중심선", False),
|
||||
("ip", "IP", False),
|
||||
("radius", "R", False),
|
||||
("station", "측점 NO", False),
|
||||
("endpoints", "시종점", False),
|
||||
("refuge", "대피소 · 사토장", False),
|
||||
("drainage", "배수시설 기호", False),
|
||||
)
|
||||
PIECE_IDS: tuple[str, ...] = tuple(piece for piece, _name, _locked in PIECES)
|
||||
# 옛 조각 id → 나눈 조각들 — 옛 종류 파일을 읽을 때 펼친다.
|
||||
LEGACY_PIECES: dict[str, tuple[str, ...]] = {
|
||||
"slope_outline": ("road_edge", "fill_outline", "cut_outline"),
|
||||
}
|
||||
_OUTLINE = LEGACY_PIECES["slope_outline"]
|
||||
|
||||
_TERRAIN = ("contour", "stream", "centerline", "slope_outline", "slope_ticks", "ip", "radius")
|
||||
_TERRAIN = ("contour", "stream", "centerline", *_OUTLINE, "slope_ticks", "ip", "radius")
|
||||
# 기본 종류 넷 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다.
|
||||
DEFAULT_KINDS: tuple[dict[str, Any], ...] = (
|
||||
{"id": "terrain", "name": "지형", "pieces": [*_TERRAIN, "station"]},
|
||||
@@ -80,13 +92,16 @@ _COLORS = {
|
||||
"contour": CONTOUR_COLOR,
|
||||
"stream": STREAM_COLOR,
|
||||
"centerline": "#ff4d4d",
|
||||
"slope_outline": "#4dd8e6",
|
||||
"road_edge": "#4dd8e6",
|
||||
"fill_outline": "#4dd8e6",
|
||||
"cut_outline": "#4dd8e6",
|
||||
"slope_ticks": "#ff4d4d",
|
||||
"ip": "#4d7dff",
|
||||
"radius": "#4d7dff",
|
||||
"station": "#ffe066",
|
||||
"endpoints": "#ff9d4d",
|
||||
"refuge": "#7ddc7d",
|
||||
"drainage": STRUCTURE_COLOR,
|
||||
}
|
||||
TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다
|
||||
_MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수)
|
||||
@@ -129,6 +144,27 @@ def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float
|
||||
return before[key] + (after[key] - before[key]) * ratio
|
||||
|
||||
|
||||
def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]:
|
||||
return sorted(
|
||||
(float(item["offset_m"]), float(item["elevation_m"]))
|
||||
for item in samples
|
||||
if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float))
|
||||
)
|
||||
|
||||
|
||||
def _ground_at(ground: list[tuple[float, float]], offset: float) -> float:
|
||||
offsets = [x for x, _z in ground]
|
||||
index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1)
|
||||
(x0, z0), (x1, z1) = ground[index - 1], ground[index]
|
||||
return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0)
|
||||
|
||||
|
||||
def side_is_cut(samples: list[dict[str, Any]], edge: float, elevation: float) -> bool:
|
||||
"""노견 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 절토다(낮으면 성토)."""
|
||||
ground = _ground(samples)
|
||||
return len(ground) >= 2 and _ground_at(ground, edge) > elevation
|
||||
|
||||
|
||||
def slope_end(
|
||||
design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str
|
||||
) -> float:
|
||||
@@ -137,11 +173,7 @@ def slope_end(
|
||||
설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다.
|
||||
좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다.
|
||||
"""
|
||||
ground = sorted(
|
||||
(float(item["offset_m"]), float(item["elevation_m"]))
|
||||
for item in samples
|
||||
if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float))
|
||||
)
|
||||
ground = _ground(samples)
|
||||
points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line)
|
||||
outward = (
|
||||
[p for p in points if p[0] > edge]
|
||||
@@ -150,36 +182,61 @@ def slope_end(
|
||||
)
|
||||
if not ground:
|
||||
return outward[-1][0] if outward else edge
|
||||
offsets = [offset for offset, _z in ground]
|
||||
|
||||
def ground_at(offset: float) -> float:
|
||||
index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1)
|
||||
(x0, z0), (x1, z1) = ground[index - 1], ground[index]
|
||||
return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0)
|
||||
|
||||
end, differs = edge, False
|
||||
for offset, elevation in outward:
|
||||
if abs(elevation - ground_at(offset)) <= 1e-3:
|
||||
if abs(elevation - _ground_at(ground, offset)) <= 1e-3:
|
||||
return offset if differs else end
|
||||
differs, end = True, offset
|
||||
return end
|
||||
|
||||
|
||||
def _split_runs(
|
||||
points: list[list[float]], flags: list[bool]
|
||||
) -> tuple[list[list[list[float]]], list[list[list[float]]]]:
|
||||
"""한 선을 참 · 거짓 구간으로 끊는다 — 바뀌는 자리 점은 앞뒤 구간이 나눠 가져 이어진다."""
|
||||
runs: dict[bool, list[list[list[float]]]] = {True: [], False: []}
|
||||
current: list[list[float]] = []
|
||||
previous: bool | None = None
|
||||
for point, flag in zip(points, flags):
|
||||
if flag != previous:
|
||||
if current:
|
||||
current.append(point)
|
||||
current = [point]
|
||||
runs[flag].append(current)
|
||||
previous = flag
|
||||
else:
|
||||
current.append(point)
|
||||
return (
|
||||
[run for run in runs[True] if len(run) >= 2],
|
||||
[run for run in runs[False] if len(run) >= 2],
|
||||
)
|
||||
|
||||
|
||||
def slope_pieces(
|
||||
route: list[tuple[float, float, float]],
|
||||
records: list[dict[str, Any]],
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""측점별 노견 끝 · 비탈 끝 오프셋에서 외곽선 넷과 비탈 눈금을 만든다.
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
"""측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다.
|
||||
|
||||
`records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m,
|
||||
(x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 중심 · 좌축을 그대로
|
||||
써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 따라 보간한다.
|
||||
left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의
|
||||
중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을
|
||||
따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다.
|
||||
"""
|
||||
records = sorted(records, key=lambda record: record["chainage_m"])
|
||||
records = sorted(
|
||||
(
|
||||
{
|
||||
**record,
|
||||
**{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")},
|
||||
}
|
||||
for record in records
|
||||
),
|
||||
key=lambda record: record["chainage_m"],
|
||||
)
|
||||
if len(route) < 2 or not records:
|
||||
return {"lines": [], "stations": []}, {"ticks": []}
|
||||
return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []}
|
||||
keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m")
|
||||
by_chainage = {round(record["chainage_m"], 3): record for record in records}
|
||||
samples = {
|
||||
@@ -192,6 +249,7 @@ def slope_pieces(
|
||||
samples.add(round(step, 3))
|
||||
step += TICK_STEP_M
|
||||
lines: dict[str, list[list[float]]] = {key: [] for key in keys}
|
||||
cut: dict[str, list[bool]] = {"left": [], "right": []}
|
||||
ticks: list[list[list[float]]] = []
|
||||
for index, chainage in enumerate(sorted(samples)):
|
||||
record = by_chainage.get(chainage)
|
||||
@@ -206,6 +264,8 @@ def slope_pieces(
|
||||
}
|
||||
for key in keys:
|
||||
lines[key].append(points[key])
|
||||
for side in cut:
|
||||
cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5)
|
||||
# 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례).
|
||||
for side in ("left", "right"):
|
||||
edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"]
|
||||
@@ -213,15 +273,18 @@ def slope_pieces(
|
||||
continue
|
||||
reach = end if index % 2 == 0 else edge + (end - edge) / 2.0
|
||||
ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]])
|
||||
outline = {
|
||||
"lines": [lines[key] for key in keys],
|
||||
"stations": [
|
||||
{key: record[key] for key in ("chainage_m", *keys)}
|
||||
for record in records
|
||||
if start_m <= record["chainage_m"] <= end_m
|
||||
],
|
||||
}
|
||||
return outline, {"ticks": ticks}
|
||||
cut_lines: list[list[list[float]]] = []
|
||||
fill_lines: list[list[list[float]]] = []
|
||||
for side in cut:
|
||||
cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side])
|
||||
cut_lines += cut_runs
|
||||
fill_lines += fill_runs
|
||||
return (
|
||||
{"lines": [lines["left_edge_m"], lines["right_edge_m"]]},
|
||||
{"lines": fill_lines},
|
||||
{"lines": cut_lines},
|
||||
{"ticks": ticks},
|
||||
)
|
||||
|
||||
|
||||
def curve_pieces(
|
||||
@@ -326,7 +389,7 @@ def _piece_entities(
|
||||
},
|
||||
}
|
||||
)
|
||||
elif piece in ("contour", "stream", "slope_outline"):
|
||||
elif piece in ("contour", "stream", *_OUTLINE):
|
||||
for index, points in enumerate(data.get("lines") or []):
|
||||
line(points, f":{index}")
|
||||
elif piece == "centerline":
|
||||
@@ -395,6 +458,23 @@ def _piece_entities(
|
||||
line([(x + sx * half, y + sy * half) for sx, sy in corners], f":{index}")
|
||||
cx, cy = paper((x, y))
|
||||
text(f"{index}", item["label"], (cx + size + 1.0, cy), _LABEL_FONT, align="left")
|
||||
elif piece == "drainage":
|
||||
size = _STRUCTURE_SIZE_MM # 마름모 기호 반크기(종이 mm)
|
||||
for index, item in enumerate(data.get("items") or []):
|
||||
cx, cy = paper((item["x"], item["y"]))
|
||||
corners = [(0, 1), (1, 0), (0, -1), (-1, 0), (0, 1)]
|
||||
diamond = polyline_entity(
|
||||
drawing_id,
|
||||
[(cx + sx * size, cy + sy * size) for sx, sy in corners],
|
||||
layer,
|
||||
color,
|
||||
suffix=f":{index}",
|
||||
)
|
||||
if diamond:
|
||||
out.append(diamond)
|
||||
text(
|
||||
f"{index}", item["label"], (cx + size + 1.0, cy), _STRUCTURE_FONT_SIZE, align="left"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""B07 계획평면도 종류 · 대피소 자리 라우터 (PLAN 60장).
|
||||
|
||||
종류 = 정보 조각 고르기 목록. 더하기 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다.
|
||||
종류 = 정보 조각 고르기 목록. 추가 · 업데이트 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다.
|
||||
대피소 · 사토장 자리는 구조물 정본 밖에서 사용자가 더 정한 자리(`plan_marks.json`)다.
|
||||
"""
|
||||
|
||||
@@ -13,13 +13,17 @@ from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import PIECES
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import _confirmed_source
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Frame import _project_root
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_json
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
add_kind,
|
||||
delete_kind,
|
||||
list_kinds,
|
||||
read_marks,
|
||||
update_kind,
|
||||
write_marks,
|
||||
)
|
||||
|
||||
@@ -55,16 +59,27 @@ def _error(status: int, exc: Exception) -> JSONResponse:
|
||||
return JSONResponse(status_code=status, content={"status": "error", "message": str(exc)})
|
||||
|
||||
|
||||
async def _station_interval(project_id: UUID) -> float | None:
|
||||
"""종단 측점 간격(m) — 측점 NO + 거리 입력을 누가거리로 바꾸는 기준. 종단이 없으면 None."""
|
||||
try:
|
||||
_route_id, _root, longitudinal_path, _bypassed = await _confirmed_source(project_id)
|
||||
longitudinal = await asyncio.to_thread(_read_json, longitudinal_path)
|
||||
except (FileNotFoundError, PermissionError, OSError, ValueError):
|
||||
return None
|
||||
return infer_station_interval(longitudinal.get("stations") or [])
|
||||
|
||||
|
||||
@router.get("/{project_id}/plan-kinds")
|
||||
async def get_plan_kinds(project_id: UUID) -> Any:
|
||||
"""고를 수 있는 조각 · 지금 종류 · 대피소 자리."""
|
||||
"""고를 수 있는 조각 · 지금 종류 · 대피소 자리 · 측점 간격."""
|
||||
root = await _project_root(project_id)
|
||||
return await asyncio.to_thread(_state, root)
|
||||
state = await asyncio.to_thread(_state, root)
|
||||
return {**state, "station_interval_m": await _station_interval(project_id)}
|
||||
|
||||
|
||||
@router.post("/{project_id}/plan-kinds")
|
||||
async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any:
|
||||
"""종류 더하기 — 조각을 골라 이름을 붙인다."""
|
||||
"""종류 추가 — 조각을 골라 이름을 붙인다."""
|
||||
try:
|
||||
root = await _project_root(project_id)
|
||||
await asyncio.to_thread(add_kind, root, request.name, request.pieces)
|
||||
@@ -73,6 +88,19 @@ async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any:
|
||||
return _error(400, exc)
|
||||
|
||||
|
||||
@router.put("/{project_id}/plan-kinds/{kind_id}")
|
||||
async def put_plan_kind(project_id: UUID, kind_id: str, request: PlanKindRequest) -> Any:
|
||||
"""종류 업데이트 — 이름 · 조각을 고친다(기본 넷도)."""
|
||||
try:
|
||||
root = await _project_root(project_id)
|
||||
await asyncio.to_thread(update_kind, root, kind_id, request.name, request.pieces)
|
||||
return await asyncio.to_thread(_state, root)
|
||||
except ValueError as exc:
|
||||
return _error(400, exc)
|
||||
except FileNotFoundError as exc:
|
||||
return _error(404, exc)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/plan-kinds/{kind_id}")
|
||||
async def delete_plan_kind(project_id: UUID, kind_id: str) -> Any:
|
||||
"""종류 지우기 — 기본 넷도 지울 수 있다."""
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
plan_marks.json 사용자가 정한 대피소 · 사토장 자리
|
||||
|
||||
`plan_kinds/` 가 없으면 기본 넷(지형 · 노선배치도 · 대피소 · 라이다)을 쓴다. 처음 고칠 때
|
||||
기본 넷을 파일로 내려 두고 그 위에서 더하고 지운다 — 기본 넷도 지울 수 있다.
|
||||
기본 넷을 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 넷도 고치고 지울 수 있다.
|
||||
조각은 서버가 도면을 열 때마다 원자료(B05 노선 · 곡선표 · 구조물, B06 횡단 설계, B04 배경)
|
||||
에서 다시 계산해 파일로 쓰고, 도면은 그 파일을 읽어 고른 조각만 얹는다.
|
||||
"""
|
||||
@@ -24,23 +24,35 @@ from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval, station_no_label
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_area_mm, plan_chunks
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_structure_label,
|
||||
plan_area_mm,
|
||||
plan_chunks,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
|
||||
DEFAULT_KINDS,
|
||||
LEGACY_PIECES,
|
||||
PIECE_IDS,
|
||||
curve_pieces,
|
||||
route_frame,
|
||||
side_is_cut,
|
||||
slope_end,
|
||||
slope_pieces,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
_chunk_route,
|
||||
_hillshade_for_box,
|
||||
_plan_structures,
|
||||
_sheet_box,
|
||||
map_background,
|
||||
plan_stations,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _design_root, _read_json
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
|
||||
_design_root,
|
||||
_read_json,
|
||||
_read_manifest,
|
||||
_write_manifest,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from config.config_system import DRAWING_SCALE_PLAN
|
||||
@@ -76,7 +88,10 @@ def list_kinds(project_root: Path) -> list[dict[str, Any]]:
|
||||
document = _read_json(path)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
pieces = [piece for piece in document.get("pieces") or [] if piece in PIECE_IDS]
|
||||
stored = set()
|
||||
for piece in document.get("pieces") or []:
|
||||
stored.update(LEGACY_PIECES.get(piece, (piece,)))
|
||||
pieces = [piece for piece in PIECE_IDS if piece in stored]
|
||||
rows.append(
|
||||
(
|
||||
int(document.get("order") or 0),
|
||||
@@ -100,13 +115,18 @@ def _materialize(project_root: Path) -> list[dict[str, Any]]:
|
||||
return kinds
|
||||
|
||||
|
||||
def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]:
|
||||
def _checked(name: str, pieces: list[str]) -> tuple[str, list[str]]:
|
||||
text = str(name or "").strip()
|
||||
if not text or len(text) > _NAME_MAX:
|
||||
raise ValueError(f"종류 이름은 1~{_NAME_MAX}자로 적어 주세요.")
|
||||
chosen = [piece for piece in PIECE_IDS if piece in set(pieces or [])]
|
||||
if not chosen:
|
||||
raise ValueError("얹을 정보를 하나 이상 고르세요.")
|
||||
return text, chosen
|
||||
|
||||
|
||||
def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]:
|
||||
text, chosen = _checked(name, pieces)
|
||||
kinds = _materialize(project_root)
|
||||
if any(kind["name"] == text for kind in kinds):
|
||||
raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}")
|
||||
@@ -118,6 +138,31 @@ def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]
|
||||
return {"id": kind_id, "name": text, "pieces": chosen}
|
||||
|
||||
|
||||
def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str]) -> dict[str, Any]:
|
||||
"""종류 이름 · 조각을 고친다(차례는 그대로). 그 종류의 확정본은 풀어 새 조각으로 다시 그린다."""
|
||||
if not KIND_ID.fullmatch(kind_id):
|
||||
raise ValueError("올바르지 않은 종류 id 입니다.")
|
||||
text, chosen = _checked(name, pieces)
|
||||
kinds = _materialize(project_root)
|
||||
if any(kind["name"] == text and kind["id"] != kind_id for kind in kinds):
|
||||
raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}")
|
||||
path = _kinds_dir(project_root) / f"{kind_id}.json"
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.")
|
||||
order = int(_read_json(path).get("order") or 0)
|
||||
atomic_write_json(path, {"name": text, "pieces": chosen, "order": order})
|
||||
manifest = _read_manifest(project_root)
|
||||
released = False
|
||||
for drawing_id, entry in manifest["drawings"].items():
|
||||
match = PLAN_KIND_ID.fullmatch(drawing_id)
|
||||
if match and match.group(1) == kind_id and entry.get("confirmed"):
|
||||
entry["confirmed"] = False
|
||||
released = True
|
||||
if released:
|
||||
_write_manifest(project_root, manifest)
|
||||
return {"id": kind_id, "name": text, "pieces": chosen}
|
||||
|
||||
|
||||
def delete_kind(project_root: Path, kind_id: str) -> None:
|
||||
if not KIND_ID.fullmatch(kind_id):
|
||||
raise ValueError("올바르지 않은 종류 id 입니다.")
|
||||
@@ -223,6 +268,10 @@ def slope_records(
|
||||
"right_edge_m": right_edge,
|
||||
"right_end_m": slope_end(line, samples, right_edge, "right"),
|
||||
}
|
||||
for side in ("left", "right"):
|
||||
elevation = edges[side].get("elevation_m")
|
||||
if isinstance(elevation, (int, float)):
|
||||
record[f"{side}_cut"] = side_is_cut(samples, record[f"{side}_edge_m"], elevation)
|
||||
frame = source.get("frame") or {}
|
||||
origin, axis = frame.get("origin") or {}, frame.get("left_xy")
|
||||
if isinstance(axis, list) and len(axis) == 2 and "x" in origin and "y" in origin:
|
||||
@@ -298,11 +347,14 @@ def build_pieces(
|
||||
pieces["stream"] = {"lines": background["streams"]}
|
||||
if "centerline" in wanted:
|
||||
pieces["centerline"] = {"lines": [route_xy]}
|
||||
if {"slope_outline", "slope_ticks"} & set(wanted):
|
||||
if {*LEGACY_PIECES["slope_outline"], "slope_ticks"} & set(wanted):
|
||||
records = slope_records(longitudinal_path, longitudinal, designs, start_m, end_m)
|
||||
pieces["slope_outline"], pieces["slope_ticks"] = slope_pieces(
|
||||
route, records, start_m, end_m
|
||||
)
|
||||
(
|
||||
pieces["road_edge"],
|
||||
pieces["fill_outline"],
|
||||
pieces["cut_outline"],
|
||||
pieces["slope_ticks"],
|
||||
) = slope_pieces(route, records, start_m, end_m)
|
||||
if {"ip", "radius"} & set(wanted):
|
||||
pieces["ip"], pieces["radius"] = curve_pieces(_load_design_curves(project_root), route, box)
|
||||
if "station" in wanted:
|
||||
@@ -341,6 +393,18 @@ def build_pieces(
|
||||
pieces["endpoints"] = {"items": items}
|
||||
if "refuge" in wanted:
|
||||
pieces["refuge"] = _refuge_items(project_root, route, start_m, end_m)
|
||||
if "drainage" in wanted:
|
||||
min_x, min_y, max_x, max_y = box
|
||||
pieces["drainage"] = {
|
||||
"items": [
|
||||
{"x": float(item["x"]), "y": float(item["y"]), "label": _structure_label(item)}
|
||||
for item in _plan_structures(context)
|
||||
if isinstance(item.get("x"), (int, float))
|
||||
and isinstance(item.get("y"), (int, float))
|
||||
and min_x <= item["x"] <= max_x
|
||||
and min_y <= item["y"] <= max_y
|
||||
]
|
||||
}
|
||||
if "lidar" in wanted:
|
||||
try:
|
||||
image, image_box = _hillshade_for_box(context, box)
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
/* =============================================================================
|
||||
* B07_DesignDetail_UI_PlanKinds.ts
|
||||
* 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 ·
|
||||
* 사용자 추가)별 도면 단추, 종류 지우기 · 더하기, 대피소 · 사토장 자리 (PLAN 60장).
|
||||
* 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장).
|
||||
*
|
||||
* 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다.
|
||||
* 체크박스는 공용(`ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields";
|
||||
import {
|
||||
addPlanKind,
|
||||
deletePlanKind,
|
||||
fetchPlanKinds,
|
||||
savePlanMarks,
|
||||
updatePlanKind,
|
||||
type DesignDrawingItem,
|
||||
type PlanKindsState,
|
||||
type PlanMark,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
|
||||
/** 종단이 없어 측점 간격을 못 받았을 때 — 서버 `infer_station_interval` 기본값과 같음. */
|
||||
const DEFAULT_STATION_INTERVAL_M = 20;
|
||||
|
||||
export interface PlanKindsControls {
|
||||
projectId: string;
|
||||
/** 종류 · 대피소 자리를 고친 뒤 — 목록과 도면을 다시 받는다. */
|
||||
@@ -44,30 +51,45 @@ async function run(action: () => Promise<unknown>, controls: PlanKindsControls):
|
||||
}
|
||||
}
|
||||
|
||||
function addKindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
|
||||
/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 [추가]. */
|
||||
function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
|
||||
const form = el("div", "b07-plan-tools__block");
|
||||
form.append(el("strong", "", "종류 더하기"));
|
||||
form.append(el("strong", "", "종류"));
|
||||
const pick = el("select", "b07-plan-tools__kind");
|
||||
pick.append(
|
||||
...state.kinds.map((kind) => new Option(kind.name, kind.id)),
|
||||
new Option("새 종류", ""),
|
||||
);
|
||||
const name = el("input", "b07-plan-tools__name");
|
||||
name.type = "text";
|
||||
name.placeholder = "종류 이름";
|
||||
name.maxLength = 40;
|
||||
const boxes = state.pieces.map((piece) => {
|
||||
const label = el("label", "b07-plan-tools__piece");
|
||||
const box = el("input", "");
|
||||
box.type = "checkbox";
|
||||
box.value = piece.id;
|
||||
label.append(box, document.createTextNode(piece.name));
|
||||
return { label, box };
|
||||
});
|
||||
const boxes = state.pieces.map((piece) => createCheckbox(piece.name, { value: piece.id }));
|
||||
const pieceList = el("div", "b07-plan-tools__pieces");
|
||||
pieceList.append(...boxes.map((item) => item.label));
|
||||
const add = el("button", "b07-plan-tools__button", "더하기");
|
||||
add.type = "button";
|
||||
add.addEventListener("click", () => {
|
||||
const pieces = boxes.filter((item) => item.box.checked).map((item) => item.box.value);
|
||||
void run(() => addPlanKind(controls.projectId, name.value.trim(), pieces), controls);
|
||||
pieceList.append(...boxes.map((box) => box.root));
|
||||
const action = el("button", "b07-plan-tools__button");
|
||||
action.type = "button";
|
||||
const show = (): void => {
|
||||
const kind = state.kinds.find((item) => item.id === pick.value);
|
||||
name.value = kind?.name ?? "";
|
||||
boxes.forEach(({ input }) => (input.checked = kind?.pieces.includes(input.value) ?? false));
|
||||
action.textContent = kind ? "업데이트" : "추가";
|
||||
};
|
||||
pick.addEventListener("change", show);
|
||||
show();
|
||||
action.addEventListener("click", () => {
|
||||
const pieces = boxes.filter((box) => box.input.checked).map((box) => box.input.value);
|
||||
const kindId = pick.value;
|
||||
const label = name.value.trim();
|
||||
void run(
|
||||
() =>
|
||||
kindId
|
||||
? updatePlanKind(controls.projectId, kindId, label, pieces)
|
||||
: addPlanKind(controls.projectId, label, pieces),
|
||||
controls,
|
||||
);
|
||||
});
|
||||
form.append(name, pieceList, add);
|
||||
form.append(pick, name, pieceList, action);
|
||||
return form;
|
||||
}
|
||||
|
||||
@@ -78,28 +100,18 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem
|
||||
el(
|
||||
"p",
|
||||
"b07-plan-tools__hint",
|
||||
"구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(m)만 적으세요.",
|
||||
"구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(NO + 거리)만 적으세요.",
|
||||
),
|
||||
);
|
||||
const interval = state.station_interval_m || DEFAULT_STATION_INTERVAL_M;
|
||||
const rows = el("div", "b07-plan-tools__marks");
|
||||
const readers = new Map<HTMLElement, () => PlanMark>();
|
||||
const readers = new Map<HTMLElement, () => PlanMark | null>();
|
||||
const addRow = (mark: PlanMark): void => {
|
||||
const row = el("div", "b07-plan-tools__mark");
|
||||
const chainage = el("input", "b07-plan-tools__chainage");
|
||||
chainage.type = "number";
|
||||
chainage.min = "0";
|
||||
chainage.step = "0.1";
|
||||
chainage.value = String(mark.chainage_m);
|
||||
const flag = (text: string, checked: boolean): [HTMLLabelElement, HTMLInputElement] => {
|
||||
const label = el("label", "b07-plan-tools__piece");
|
||||
const box = el("input", "");
|
||||
box.type = "checkbox";
|
||||
box.checked = checked;
|
||||
label.append(box, document.createTextNode(text));
|
||||
return [label, box];
|
||||
};
|
||||
const [refugeLabel, refuge] = flag("대피소", mark.refuge);
|
||||
const [spoilLabel, spoil] = flag("사토장", mark.spoil);
|
||||
const station = stationFields("측점", () => interval);
|
||||
station.write(mark.chainage_m, interval);
|
||||
const refuge = createCheckbox("대피소", { checked: mark.refuge });
|
||||
const spoil = createCheckbox("사토장", { checked: mark.spoil });
|
||||
const remove = el("button", "b07-plan-tools__remove", "×");
|
||||
remove.type = "button";
|
||||
remove.title = "이 자리 빼기";
|
||||
@@ -107,23 +119,28 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem
|
||||
readers.delete(row);
|
||||
row.remove();
|
||||
});
|
||||
row.append(chainage, refugeLabel, spoilLabel, remove);
|
||||
readers.set(row, () => ({
|
||||
chainage_m: Number(chainage.value),
|
||||
refuge: refuge.checked,
|
||||
spoil: spoil.checked,
|
||||
}));
|
||||
row.append(station.wrap, refuge.root, spoil.root, remove);
|
||||
readers.set(row, () => {
|
||||
const chainage = station.read(interval, true);
|
||||
return chainage === null
|
||||
? null
|
||||
: { chainage_m: chainage, refuge: refuge.input.checked, spoil: spoil.input.checked };
|
||||
});
|
||||
rows.append(row);
|
||||
};
|
||||
state.marks.forEach(addRow);
|
||||
const more = el("button", "b07-plan-tools__button", "자리 더하기");
|
||||
const more = el("button", "b07-plan-tools__button", "자리 추가");
|
||||
more.type = "button";
|
||||
more.addEventListener("click", () => addRow({ chainage_m: 0, refuge: true, spoil: false }));
|
||||
const save = el("button", "b07-plan-tools__button", "자리 저장");
|
||||
const save = el("button", "b07-plan-tools__button", "업데이트");
|
||||
save.type = "button";
|
||||
save.addEventListener("click", () => {
|
||||
const marks = [...readers.values()].map((read) => read());
|
||||
void run(() => savePlanMarks(controls.projectId, marks), controls);
|
||||
if (marks.some((mark) => mark === null)) {
|
||||
showToast("측점 NO + 거리를 바르게 적어 주세요.", "error");
|
||||
return;
|
||||
}
|
||||
void run(() => savePlanMarks(controls.projectId, marks as PlanMark[]), controls);
|
||||
});
|
||||
const actions = el("div", "b07-plan-tools__actions");
|
||||
actions.append(more, save);
|
||||
@@ -131,7 +148,7 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem
|
||||
return form;
|
||||
}
|
||||
|
||||
/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 더하기 · 대피소 자리. */
|
||||
/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */
|
||||
export function buildPlanSection(
|
||||
items: DesignDrawingItem[],
|
||||
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
|
||||
@@ -161,9 +178,7 @@ export function buildPlanSection(
|
||||
remove.type = "button";
|
||||
remove.title = `「${name}」 종류 지우기`;
|
||||
remove.addEventListener("click", () => {
|
||||
if (
|
||||
!window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 종류 더하기로 새로 만듭니다.`)
|
||||
)
|
||||
if (!window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`))
|
||||
return;
|
||||
void run(() => deletePlanKind(controls.projectId, kindId), controls);
|
||||
});
|
||||
@@ -174,14 +189,14 @@ export function buildPlanSection(
|
||||
}
|
||||
|
||||
if (controls) {
|
||||
// 더하기 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다.
|
||||
// 종류 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다.
|
||||
const tools = el("details", "b07-plan-tools");
|
||||
tools.append(el("summary", "", "종류 더하기 · 대피소 자리"));
|
||||
tools.append(el("summary", "", "종류 추가 · 업데이트 · 대피소 자리"));
|
||||
tools.addEventListener("toggle", () => {
|
||||
if (!tools.open || tools.dataset.loaded) return;
|
||||
tools.dataset.loaded = "true";
|
||||
void fetchPlanKinds(controls.projectId)
|
||||
.then((state) => tools.append(addKindForm(state, controls), marksForm(state, controls)))
|
||||
.then((state) => tools.append(kindForm(state, controls), marksForm(state, controls)))
|
||||
.catch((error: unknown) => {
|
||||
delete tools.dataset.loaded;
|
||||
showToast(
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
|
||||
/* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */
|
||||
|
||||
/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 더하기 · 대피소 자리(PLAN 60장) */
|
||||
/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 종류 추가 · 업데이트 · 대피소 자리(PLAN 60 · 67장) */
|
||||
.b07-plan-kind {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -278,11 +278,9 @@
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
}
|
||||
|
||||
.b07-plan-tools__piece {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
white-space: nowrap;
|
||||
.b07-plan-tools__kind,
|
||||
.b07-plan-tools__name {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b07-plan-tools__hint {
|
||||
@@ -291,9 +289,30 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b07-plan-tools__chainage {
|
||||
width: 72px;
|
||||
/* 측점 NO + 거리 두 칸(B05 stationFields) — 한 줄: [측점][NO][+m] */
|
||||
.b07-plan-tools__mark .b05-route__field {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b07-plan-tools__mark .b05-structure__station-pair {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b07-plan-tools__mark .b05-structure__station-pair input {
|
||||
width: 52px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b07-plan-tools__mark input.is-invalid {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.b07-plan-tools__button,
|
||||
|
||||
@@ -36,43 +36,55 @@ def section_props(section: str, diameter_m: float) -> dict[str, float]:
|
||||
return {"area": full_area, "perimeter": math.pi * d, "radius": d / 4.0}
|
||||
|
||||
|
||||
def compute(inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""입력 → 계산서 값 전부(반올림 없음 · 화면이 자릿수를 정함)."""
|
||||
length_m = float(inputs["length_m"])
|
||||
height_m = float(inputs["height_m"])
|
||||
slope_ground = height_m / length_m
|
||||
tc_raw_hr = 0.0663 * (length_m / 1000.0) ** 0.77 * slope_ground**-0.385
|
||||
tc_hr = max(tc_raw_hr, float(inputs["tc_min_minutes"]) / 60.0)
|
||||
r24 = float(inputs["r24_mm"])
|
||||
intensity = (r24 / 24.0) * (24.0 / tc_hr) ** MONONOBE_EXPONENT
|
||||
runoff = float(inputs["runoff"])
|
||||
area_km2 = float(inputs["area_km2"])
|
||||
flow = runoff * intensity * area_km2 / 3.6
|
||||
design_flow = flow * float(inputs["factor"])
|
||||
def _num(inputs: dict[str, Any], key: str) -> float | None:
|
||||
value = inputs.get(key)
|
||||
return None if value is None or value == "" else float(value)
|
||||
|
||||
n = inputs.get("n")
|
||||
n = float(n) if n else PIPE_KINDS[inputs["pipe_kind"]]
|
||||
diameter_m = float(inputs["diameter_mm"]) / 1000.0
|
||||
props = section_props(inputs["section"], diameter_m)
|
||||
slope_bed = 1.0 / float(inputs["slope_denominator"])
|
||||
velocity = (1.0 / n) * props["radius"] ** (2.0 / 3.0) * math.sqrt(slope_bed)
|
||||
capacity = props["area"] * velocity
|
||||
|
||||
def compute(inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""입력 → 계산서 값 전부(반올림 없음 · 화면이 자릿수를 정함).
|
||||
|
||||
빈칸(None)이 있으면 그 값에 기대는 칸만 None — 화면은 「—」 (69-6).
|
||||
"""
|
||||
length_m, height_m = _num(inputs, "length_m"), _num(inputs, "height_m")
|
||||
r24, area_km2 = _num(inputs, "r24_mm"), _num(inputs, "area_km2")
|
||||
slope_ground = tc_raw_hr = tc_hr = intensity = flow = design_flow = None
|
||||
if length_m and height_m:
|
||||
slope_ground = height_m / length_m
|
||||
tc_raw_hr = 0.0663 * (length_m / 1000.0) ** 0.77 * slope_ground**-0.385
|
||||
tc_hr = max(tc_raw_hr, float(inputs["tc_min_minutes"]) / 60.0)
|
||||
if tc_hr and r24:
|
||||
intensity = (r24 / 24.0) * (24.0 / tc_hr) ** MONONOBE_EXPONENT
|
||||
if intensity is not None and area_km2:
|
||||
flow = float(inputs["runoff"]) * intensity * area_km2 / 3.6
|
||||
design_flow = flow * float(inputs["factor"])
|
||||
|
||||
n = _num(inputs, "n") or PIPE_KINDS[inputs["pipe_kind"]]
|
||||
diameter_mm, slope_den = _num(inputs, "diameter_mm"), _num(inputs, "slope_denominator")
|
||||
props = section_props(inputs["section"], diameter_mm / 1000.0) if diameter_mm else {}
|
||||
slope_bed = 1.0 / slope_den if slope_den else None
|
||||
velocity = capacity = None
|
||||
if props and slope_bed:
|
||||
velocity = (1.0 / n) * props["radius"] ** (2.0 / 3.0) * math.sqrt(slope_bed)
|
||||
capacity = props["area"] * velocity
|
||||
judged = capacity is not None and design_flow is not None
|
||||
return {
|
||||
"area_ha": area_km2 * 100.0,
|
||||
"area_ha": area_km2 * 100.0 if area_km2 else None,
|
||||
"r24_mm": r24,
|
||||
"slope_ground": slope_ground,
|
||||
"tc_raw_hr": tc_raw_hr,
|
||||
"tc_hr": tc_hr,
|
||||
"tc_floored": tc_hr > tc_raw_hr,
|
||||
"tc_floored": tc_hr is not None and tc_hr > tc_raw_hr,
|
||||
"intensity": intensity,
|
||||
"flow": flow,
|
||||
"design_flow": design_flow,
|
||||
"n": n,
|
||||
"section_area": props["area"],
|
||||
"perimeter": props["perimeter"],
|
||||
"radius": props["radius"],
|
||||
"section_area": props.get("area"),
|
||||
"perimeter": props.get("perimeter"),
|
||||
"radius": props.get("radius"),
|
||||
"slope_bed": slope_bed,
|
||||
"velocity": velocity,
|
||||
"capacity": capacity,
|
||||
"ok": capacity >= design_flow,
|
||||
"ratio_pct": capacity / design_flow * 100.0 if design_flow > 0 else None,
|
||||
"ok": capacity >= design_flow if judged else None,
|
||||
"ratio_pct": capacity / design_flow * 100.0 if judged and design_flow > 0 else None,
|
||||
}
|
||||
|
||||
@@ -17,20 +17,22 @@ SECTIONS = {"full": "만관", "arc240": "240° 유효단면", "eff70": "유효 7
|
||||
#: 관 종류별 조도계수 n — 파형강관은 KDS 표에 없음(지금 배관 제안 0.024)
|
||||
PIPE_KINDS = {"파형강관": 0.024, "흄관": 0.013, "콘크리트관": 0.015}
|
||||
|
||||
#: 처음 값 = 표준 값만(69-6) · 측점 고유 값(유역면적 · L · H · D · S)은 None(빈칸)
|
||||
#: R24 · 지역은 공사지 자료(B08_Quantity_Hydro_Site) — 자료가 없을 때만 화면 입력
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"factor": 2.0,
|
||||
"runoff": 0.80, # 유출계수 f — KDS 가파른 산지
|
||||
"area_km2": 0.132,
|
||||
"length_m": 480.0, # 산정에서 하천시점까지 거리
|
||||
"height_m": 185.0, # 산정에서 하천시점까지 표고차
|
||||
"area_km2": None,
|
||||
"length_m": None, # 산정에서 하천시점까지 거리
|
||||
"height_m": None, # 산정에서 하천시점까지 표고차
|
||||
"tc_min_minutes": 5.0, # 최소 도달시간(KDS 2.4)
|
||||
"r24_mm": 305.9,
|
||||
"region": "울진",
|
||||
"r24_mm": None,
|
||||
"region": "",
|
||||
"return_period": 100,
|
||||
"pipe_kind": "파형강관",
|
||||
"n": None, # None = 관 종류 값
|
||||
"diameter_mm": 1200.0,
|
||||
"slope_denominator": 20.0, # 하상경사 S = 1 / 이 값
|
||||
"diameter_mm": None,
|
||||
"slope_denominator": None, # 하상경사 S = 1 / 이 값
|
||||
"section": "full",
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""B08 4탭 홍수량 계산서 — 공사지 지역 · 확률강우량 · 저장 파일 (PLAN 69-5 · 69-6).
|
||||
|
||||
읽기만: 전처리 강우량표(`B04_PreProcess/drainage/rainfall_table.json` · 관측소 확률강우량)
|
||||
· 행정구역(`B04_PreProcess/processed/행정구역_*_bounds.geojson` · WGS84) · 프로젝트 등록 지역(DB).
|
||||
배수유역 계산 코드는 부르지 않음 — 이미 저장된 파일만 읽음.
|
||||
|
||||
쓰기: B08 자기 파일 `B08_Quantity/quantities/hydro_inputs.json` 하나([저장] 때만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import Point, shape
|
||||
|
||||
RAINFALL_FILE = Path("B04_PreProcess") / "drainage" / "rainfall_table.json"
|
||||
PROCESSED_DIR = Path("B04_PreProcess") / "processed"
|
||||
EMD_FILE = "행정구역_읍면동_bounds.geojson"
|
||||
SGG_FILE = "행정구역_시군구_bounds.geojson"
|
||||
SAVED_FILE = Path("B08_Quantity") / "quantities" / "hydro_inputs.json"
|
||||
|
||||
#: 강우량표가 없을 때 재현기간 드롭다운 — 확률강우량 자료 표의 재현기간
|
||||
FALLBACK_PERIODS = (2, 3, 5, 10, 20, 30, 50, 70, 80, 100, 200, 300, 500)
|
||||
DAY_MINUTES = 1440
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _features(path: Path) -> list[dict[str, Any]]:
|
||||
data = _read_json(path) or {}
|
||||
return [f for f in data.get("features") or [] if isinstance(f, dict)]
|
||||
|
||||
|
||||
def _feature_at(path: Path, lon: float, lat: float) -> dict[str, Any] | None:
|
||||
"""점이 드는 행정구역 속성 — 경계 파일은 WGS84."""
|
||||
point = Point(lon, lat)
|
||||
for feature in _features(path):
|
||||
try:
|
||||
if shape(feature["geometry"]).covers(point):
|
||||
return feature.get("properties") or {}
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _region(root: Path, rain: dict[str, Any] | None, db_region: str | None) -> dict[str, str]:
|
||||
"""공사지 지역 — 전처리 행정구역(노선 대표점이 드는 곳) > 시군구 하나뿐 > 프로젝트 등록 지역."""
|
||||
lat, lon = (rain or {}).get("lat"), (rain or {}).get("lon")
|
||||
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
|
||||
emd = _feature_at(root / PROCESSED_DIR / EMD_FILE, lon, lat)
|
||||
sgg = _feature_at(root / PROCESSED_DIR / SGG_FILE, lon, lat)
|
||||
name = (sgg or {}).get("sig_kor_nm") or ""
|
||||
full = (emd or {}).get("full_nm") or (sgg or {}).get("full_nm") or ""
|
||||
if name or full:
|
||||
return {"name": name or full.split()[-1], "full": full, "source": "전처리 행정구역"}
|
||||
sggs = _features(root / PROCESSED_DIR / SGG_FILE)
|
||||
if len(sggs) == 1:
|
||||
props = sggs[0].get("properties") or {}
|
||||
return {
|
||||
"name": props.get("sig_kor_nm") or "",
|
||||
"full": props.get("full_nm") or "",
|
||||
"source": "전처리 행정구역",
|
||||
}
|
||||
if db_region:
|
||||
return {"name": db_region, "full": db_region, "source": "프로젝트 등록"}
|
||||
return {"name": "", "full": "", "source": ""}
|
||||
|
||||
|
||||
def _r24_table(rain: dict[str, Any] | None) -> dict[int, float]:
|
||||
"""재현기간(년) → 24시간 확률강우량(mm)."""
|
||||
table: dict[int, float] = {}
|
||||
for row in (rain or {}).get("values") or []:
|
||||
try:
|
||||
if int(row["duration_min"]) == DAY_MINUTES:
|
||||
table[int(row["return_period_yr"])] = float(row["depth_mm"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
return table
|
||||
|
||||
|
||||
def site_info(root: Path, db_region: str | None) -> dict[str, Any]:
|
||||
"""화면 머리 — 지역 · 관측소 · 재현기간 목록 · 재현기간별 R24(없으면 빈 표)."""
|
||||
rain = _read_json(root / RAINFALL_FILE)
|
||||
r24 = _r24_table(rain)
|
||||
station = (rain or {}).get("station") or {}
|
||||
return {
|
||||
"region": _region(root, rain, db_region),
|
||||
"station": station.get("name") or "",
|
||||
"periods": sorted(r24) or list(FALLBACK_PERIODS),
|
||||
"r24": {str(k): v for k, v in sorted(r24.items())},
|
||||
}
|
||||
|
||||
|
||||
def site_r24(root: Path, return_period: float) -> float | None:
|
||||
"""계산 때 서버가 정본으로 쓰는 R24 — 자료가 있으면 그 값 · 없으면 None(화면 입력)."""
|
||||
return _r24_table(_read_json(root / RAINFALL_FILE)).get(int(return_period))
|
||||
|
||||
|
||||
def read_saved(root: Path) -> dict[str, dict[str, Any]]:
|
||||
data = _read_json(root / SAVED_FILE) or {}
|
||||
stations = data.get("stations")
|
||||
return stations if isinstance(stations, dict) else {}
|
||||
|
||||
|
||||
def write_saved(root: Path, stations: dict[str, dict[str, Any]]) -> None:
|
||||
"""원자적 쓰기 — 임시 파일에 쓴 뒤 바꿔 끼움."""
|
||||
path = root / SAVED_FILE
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".part")
|
||||
payload = {"version": 1, "stations": stations}
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
@@ -1,9 +1,12 @@
|
||||
"""B08 4탭 홍수량 계산서 길 (PLAN 62-2 · 62-3) — 읽기 · 계산만, 아무 파일에도 쓰지 않음.
|
||||
"""B08 4탭 홍수량 계산서 길 (PLAN 62-2 · 62-3 · 69-5 · 69-6).
|
||||
|
||||
GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + 기본값 · 선택지
|
||||
+ 공사지(지역 · 재현기간별 R24) + 저장한 입력
|
||||
POST /api/projects/{id}/quantity/hydro/calc 화면 입력 → 계산서 값(B08_Quantity_Engine_Hydro)
|
||||
PUT /api/projects/{id}/quantity/hydro [저장] — 측점별 입력을 B08 자기 파일에만 씀
|
||||
|
||||
측점 목록만 관 지점 정본에서 읽음 — 관경 · 관 종류 등 B05/B06 값은 잇지 않음(자기 기본값).
|
||||
B05/B06 · 배수유역 파일에는 쓰지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,9 +18,11 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B08_Quantity.B08_Quantity_Engine_Hydro import compute
|
||||
from B08_Quantity.B08_Quantity_Hydro_Defaults import FACTORS, PIPE_KINDS, options
|
||||
from B08_Quantity.B08_Quantity_Hydro_Site import read_saved, site_info, site_r24, write_saved
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
@@ -26,22 +31,30 @@ router = APIRouter(prefix="/api/projects", tags=["B08 Hydro"])
|
||||
|
||||
|
||||
class HydroInputs(BaseModel):
|
||||
"""빈칸(None) = 측점 고유 값을 아직 안 넣음 — 그 값에 기대는 계산 칸만 비움."""
|
||||
|
||||
factor: float = Field(gt=0)
|
||||
runoff: float = Field(gt=0, le=1)
|
||||
area_km2: float = Field(gt=0)
|
||||
length_m: float = Field(gt=0)
|
||||
height_m: float = Field(gt=0)
|
||||
area_km2: float | None = Field(default=None, gt=0)
|
||||
length_m: float | None = Field(default=None, gt=0)
|
||||
height_m: float | None = Field(default=None, gt=0)
|
||||
tc_min_minutes: float = Field(ge=0)
|
||||
r24_mm: float = Field(gt=0)
|
||||
r24_mm: float | None = Field(default=None, gt=0)
|
||||
region: str = ""
|
||||
return_period: float = Field(gt=0)
|
||||
pipe_kind: Literal[tuple(PIPE_KINDS)] # type: ignore[valid-type]
|
||||
n: float | None = Field(default=None, gt=0)
|
||||
diameter_mm: float = Field(gt=0)
|
||||
slope_denominator: float = Field(gt=0)
|
||||
diameter_mm: float | None = Field(default=None, gt=0)
|
||||
slope_denominator: float | None = Field(default=None, gt=0)
|
||||
section: Literal["full", "arc240", "eff70"]
|
||||
|
||||
|
||||
class HydroSave(BaseModel):
|
||||
"""측점 키(chainage 소수 셋째 자리) → 그 측점 입력 전부."""
|
||||
|
||||
stations: dict[str, HydroInputs]
|
||||
|
||||
|
||||
async def _project_root(project_id: UUID) -> Path:
|
||||
try:
|
||||
async with get_db_pool().acquire() as connection:
|
||||
@@ -61,12 +74,34 @@ async def hydro_sheet(project_id: UUID) -> dict[str, Any]:
|
||||
({"chainage_m": p.chainage_m, "facility": p.facility} for p in points),
|
||||
key=lambda s: s["chainage_m"],
|
||||
)
|
||||
return {"stations": stations, **options()}
|
||||
project = await get_project_by_id(str(project_id)) or {}
|
||||
return {
|
||||
"stations": stations,
|
||||
**options(),
|
||||
"site": site_info(root, project.get("region")),
|
||||
"saved": read_saved(root),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{project_id}/quantity/hydro/calc")
|
||||
async def hydro_calc(project_id: UUID, body: HydroInputs) -> dict[str, Any]:
|
||||
await _project_root(project_id) # 없는 프로젝트는 404
|
||||
root = await _project_root(project_id) # 없는 프로젝트는 404
|
||||
if body.factor not in FACTORS:
|
||||
raise HTTPException(status_code=400, detail=f"할증은 {FACTORS} 중 하나")
|
||||
return compute(body.model_dump())
|
||||
inputs = body.model_dump()
|
||||
# 공사지 확률강우량이 있으면 그 값이 정본 — 화면 값을 받아 적지 않음
|
||||
r24 = site_r24(root, body.return_period)
|
||||
if r24 is not None:
|
||||
inputs["r24_mm"] = r24
|
||||
return compute(inputs)
|
||||
|
||||
|
||||
@router.put("/{project_id}/quantity/hydro")
|
||||
async def hydro_save(project_id: UUID, body: HydroSave) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
for key, inputs in body.stations.items():
|
||||
if inputs.factor not in FACTORS:
|
||||
raise HTTPException(status_code=400, detail=f"{key}: 할증은 {FACTORS} 중 하나")
|
||||
saved = {key: inputs.model_dump() for key, inputs in body.stations.items()}
|
||||
write_saved(root, saved)
|
||||
return {"saved": saved}
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
/* B08 4탭 홍수량 계산서(PLAN 62-2) — 인쇄 같은 흰 종이. 종이 안 색은 테마와 상관없이 고정
|
||||
(어두움 테마에서도 같은 종이로 읽힘): 입력 빨강 · 계산 파랑 · 주요 입력 노랑 바탕. */
|
||||
/* B08 4탭 홍수량 계산서(PLAN 62-2 · 69-4) — 공용 카드 · 입력 · 드롭다운 위에 줄 배치만.
|
||||
색은 테마 토큰만(밝음 · 어두움 같이) · 테두리는 입력 · 선택 칸(공용 ui-input · ui-select)만
|
||||
· 굵게 + 붉은 계열은 중요 결과(.is-key)만. */
|
||||
|
||||
/* 오른쪽 「진행단계」 떠 있는 판에 안 가리게 */
|
||||
.b08-hydro__toolbar,
|
||||
.b08-hydro__sheet {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.b08-hydro__toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-16);
|
||||
margin-bottom: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b08-hydro__tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4, 4px);
|
||||
margin-bottom: var(--spacing-12, 12px);
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b08-hydro__paper {
|
||||
--paper: #ffffff;
|
||||
--ink: #111111;
|
||||
--input: #d40000;
|
||||
--calc: #0033cc;
|
||||
--key: #ffff00;
|
||||
--ha: #f8cbad;
|
||||
--rule: #9aa0a6;
|
||||
/* 오른쪽 「진행단계」 떠 있는 판에 안 가리게 */
|
||||
max-width: 1000px;
|
||||
padding: 20px 24px 12px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--rule);
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 25%);
|
||||
font-family: "Malgun Gothic", "Noto Sans KR", sans-serif;
|
||||
font-size: 14px;
|
||||
.b08-hydro__toolbar > .ui-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.b08-hydro__sheet {
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b08-hydro__body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
@@ -39,95 +44,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
.b08-hydro__h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
.b08-hydro__card .ui-card__title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.b08-hydro__card .ui-card__body {
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b08-hydro__h2 {
|
||||
margin: 16px 0 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.b08-hydro__station mark {
|
||||
padding: 0 10px;
|
||||
background: var(--key);
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.b08-hydro__note {
|
||||
margin: 4px 0 4px 16px;
|
||||
color: var(--ink);
|
||||
margin: var(--spacing-8) 0 0;
|
||||
padding-top: var(--spacing-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.b08-hydro__formula {
|
||||
margin: 6px 0 6px 16px;
|
||||
margin: 0 0 0 var(--spacing-8);
|
||||
font-family: "Cambria Math", "Times New Roman", serif;
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b08-hydro__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 96px 110px;
|
||||
grid-template-columns: minmax(0, 1fr) 112px minmax(0, 0.8fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 26px;
|
||||
padding-left: 16px;
|
||||
gap: var(--spacing-8);
|
||||
min-height: var(--control-height-sm);
|
||||
}
|
||||
|
||||
.b08-hydro__row--f {
|
||||
grid-template-columns: max-content minmax(0, 1fr) 96px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.b08-hydro__hint {
|
||||
color: #666666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.b08-hydro__row.is-key .b08-hydro__label,
|
||||
.b08-hydro__row.is-key .b08-hydro__in {
|
||||
background: var(--key);
|
||||
grid-template-columns: max-content minmax(0, 1fr) 112px;
|
||||
}
|
||||
|
||||
.b08-hydro__label {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b08-hydro__out,
|
||||
.b08-hydro__in {
|
||||
box-sizing: border-box;
|
||||
width: 96px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #3355cc;
|
||||
background: var(--paper);
|
||||
text-align: right;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.b08-hydro__out {
|
||||
color: var(--calc);
|
||||
}
|
||||
|
||||
.b08-hydro__in {
|
||||
color: var(--input);
|
||||
}
|
||||
|
||||
.b08-hydro__in--text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b08-hydro__select {
|
||||
width: auto;
|
||||
text-align: left;
|
||||
color: var(--input);
|
||||
.b08-hydro__label--select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b08-hydro__row .b08-hydro__formula {
|
||||
@@ -135,67 +96,67 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.b08-hydro__ha {
|
||||
.b08-hydro__hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 계산 칸 — 테두리 없이 글자만 · 숫자 자리 맞춤 */
|
||||
.b08-hydro__out,
|
||||
.b08-hydro__value {
|
||||
padding: 0 var(--spacing-8);
|
||||
color: var(--color-text-body);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.b08-hydro__value {
|
||||
text-align: left;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.b08-hydro__in {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 줄에 바로 놓인 드롭다운은 값 칸 + 덧칸 — 선택지 글이 안 잘리게 */
|
||||
.b08-hydro__row > .b08-hydro__select {
|
||||
grid-column: 2 / 4;
|
||||
justify-self: start;
|
||||
width: auto;
|
||||
border: none;
|
||||
background: var(--ha);
|
||||
}
|
||||
|
||||
.b08-hydro__tcnote {
|
||||
width: auto;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
/* 식 칸에 놓인 입력(하상경사 1 / ☐)은 값 칸 폭 */
|
||||
.b08-hydro__row--f > .b08-hydro__in:nth-child(2) {
|
||||
justify-self: start;
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
.b08-hydro__place {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 96px;
|
||||
margin: 6px 0 0 16px;
|
||||
border: 1px solid var(--ink);
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.b08-hydro__place-head {
|
||||
padding: 2px 6px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.b08-hydro__place .b08-hydro__in {
|
||||
border: none;
|
||||
color: var(--calc);
|
||||
}
|
||||
|
||||
.b08-hydro__box {
|
||||
padding: 12px 18px;
|
||||
border: 2px solid var(--ink);
|
||||
}
|
||||
|
||||
.b08-hydro__kind {
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.b08-hydro__row--wide {
|
||||
padding-left: 0;
|
||||
font-weight: 700;
|
||||
/* 중요 결과 — Qd · 유출량 · 판정 · 유출율 */
|
||||
.is-key.b08-hydro__out,
|
||||
.is-key.b08-hydro__verdict {
|
||||
color: var(--color-danger);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.b08-hydro__figure {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b08-hydro__dims {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.b08-hydro__dims .b08-hydro__row {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.b08-hydro__pipe {
|
||||
width: 170px;
|
||||
height: 145px;
|
||||
@@ -203,46 +164,38 @@
|
||||
|
||||
.b08-hydro__pipe text {
|
||||
font-size: 12px;
|
||||
fill: var(--ink);
|
||||
fill: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b08-hydro__water {
|
||||
fill: #cfe3ff;
|
||||
fill: color-mix(in srgb, var(--color-chart-0) 30%, transparent);
|
||||
}
|
||||
|
||||
.b08-hydro__wall {
|
||||
fill: none;
|
||||
stroke: var(--ink);
|
||||
stroke: var(--color-text-body);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.b08-hydro__line {
|
||||
fill: none;
|
||||
stroke: var(--ink);
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b08-hydro__verdict {
|
||||
width: max-content;
|
||||
margin: 12px auto;
|
||||
padding: 6px 40px;
|
||||
border: 2px dashed var(--ink);
|
||||
color: var(--input);
|
||||
font-weight: 700;
|
||||
padding: var(--spacing-8) 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b08-hydro__verdict.is-ng {
|
||||
background: #ffe0e0;
|
||||
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.b08-hydro__status {
|
||||
margin: 8px 0 0;
|
||||
color: var(--input);
|
||||
}
|
||||
|
||||
.b08-hydro__page {
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
color: #666666;
|
||||
font-size: 12px;
|
||||
margin: var(--spacing-8) 0 0;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Hydro.ts
|
||||
* 4탭 수리집수면적표 — 배수지점 측점마다 보조 탭 · 한 장 = 홍수량 계산서(PLAN 62-2 · 62-3).
|
||||
* 4탭 수리집수면적표 — 배수지점 측점마다 보조 탭 · 한 장 = 홍수량 계산서(PLAN 62-2 · 69-4~6).
|
||||
*
|
||||
* 측점 목록만 저장된 관 지점(pipe_points)에서 · 입력은 이 탭 자기 기본값(B05/B06 값과 안 이음)
|
||||
* 계산 = 서버 단독(POST …/quantity/hydro/calc · 최신 응답만 채택) · 입력은 캐시(sessionStorage)만
|
||||
* 어디에도 쓰지 않음 — 배수유역 · B05/B06 파일 그대로.
|
||||
* 측점 목록만 저장된 관 지점(pipe_points)에서 · 입력은 이 탭 자기 값(B05/B06 값과 안 이음)
|
||||
* 처음 값 = 표준 값 · 측점 고유 값은 빈칸 · 지역 · R24 = 공사지 자료(서버)
|
||||
* 조작 = 캐시(sessionStorage) · [저장] = B08 자기 파일(PUT …/quantity/hydro) · 뒤 캐시 비움
|
||||
* 계산 = 서버 단독(POST …/quantity/hydro/calc · 최신 응답만 채택)
|
||||
* ========================================================================== */
|
||||
|
||||
import "./B08_Quantity_UI_Hydro.css";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
createButton,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { stationInterval } from "./B08_Quantity_UI_Summary";
|
||||
import {
|
||||
@@ -26,6 +32,7 @@ interface HydroStation {
|
||||
}
|
||||
|
||||
type Drafts = Record<string, Partial<HydroInputs>>;
|
||||
type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions;
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
@@ -52,11 +59,11 @@ const keyOf = (station: HydroStation): string => station.chainage_m.toFixed(3);
|
||||
|
||||
export async function renderHydro(host: HTMLElement, projectId: string): Promise<void> {
|
||||
host.replaceChildren(note("홍수량 계산서를 불러오는 중…"));
|
||||
let sheet: { stations: HydroStation[] } & HydroOptions;
|
||||
let sheet: HydroPayload;
|
||||
let interval = 20;
|
||||
try {
|
||||
[sheet, interval] = await Promise.all([
|
||||
request<{ stations: HydroStation[] } & HydroOptions>(`/projects/${projectId}/quantity/hydro`),
|
||||
request<HydroPayload>(`/projects/${projectId}/quantity/hydro`),
|
||||
stationInterval(projectId).catch(() => 20),
|
||||
]);
|
||||
} catch (error) {
|
||||
@@ -70,15 +77,33 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
const drafts: Drafts = readState<Drafts>("b08-hydro", projectId) ?? {};
|
||||
let drafts: Drafts = readState<Drafts>("b08-hydro", projectId) ?? {};
|
||||
const inputsOf = (station: HydroStation): HydroInputs =>
|
||||
({
|
||||
...sheet.defaults,
|
||||
...sheet.saved[keyOf(station)],
|
||||
...drafts[keyOf(station)],
|
||||
}) as HydroInputs;
|
||||
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "b08-hydro__tabs";
|
||||
tabs.setAttribute("role", "tablist");
|
||||
const save = createButton({
|
||||
label: "저장",
|
||||
variant: "filled",
|
||||
onClick: () => void saveAll(),
|
||||
});
|
||||
save.dataset.b08HydroSave = "";
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "b08-hydro__toolbar";
|
||||
toolbar.append(tabs, save);
|
||||
const page = document.createElement("div");
|
||||
host.replaceChildren(tabs, page);
|
||||
host.replaceChildren(toolbar, page);
|
||||
let seq = 0;
|
||||
let openIndex = 0;
|
||||
|
||||
const open = (index: number): void => {
|
||||
openIndex = index;
|
||||
const station = sheet.stations[index];
|
||||
tabs.replaceChildren(
|
||||
...sheet.stations.map((s, i) => {
|
||||
@@ -98,7 +123,6 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
|
||||
const draw = (inputs: HydroInputs): void => {
|
||||
const view = buildHydroSheet(
|
||||
stationLabel(station.chainage_m, interval),
|
||||
{ index: index + 1, total: sheet.stations.length },
|
||||
inputs,
|
||||
sheet,
|
||||
(next) => {
|
||||
@@ -112,7 +136,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
|
||||
page.replaceChildren(view.root);
|
||||
void calc(inputs, view.show);
|
||||
};
|
||||
draw({ ...sheet.defaults, ...drafts[keyOf(station)] } as HydroInputs);
|
||||
draw(inputsOf(station));
|
||||
};
|
||||
|
||||
const calc = async (
|
||||
@@ -131,5 +155,26 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
|
||||
}
|
||||
};
|
||||
|
||||
/** [저장] — 측점 전부의 지금 입력을 B08 자기 파일로 · 성공하면 캐시를 비우고 저장본으로 다시 엶 */
|
||||
const saveAll = async (): Promise<void> => {
|
||||
const stations = Object.fromEntries(sheet.stations.map((s) => [keyOf(s), inputsOf(s)]));
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const body = await request<{ saved: Drafts }>(`/projects/${projectId}/quantity/hydro`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ stations }),
|
||||
});
|
||||
sheet.saved = body.saved;
|
||||
drafts = {};
|
||||
clearState("b08-hydro", projectId);
|
||||
showToast("수리집수면적표를 저장했습니다.", "success");
|
||||
open(openIndex);
|
||||
} catch (error) {
|
||||
showToast(`저장하지 못했습니다 — ${String(error)}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
};
|
||||
|
||||
open(0);
|
||||
}
|
||||
|
||||
@@ -1,56 +1,69 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Hydro_Sheet.ts
|
||||
* 홍수량 계산서 한 장(PLAN 62-2) — 견본(울진 2공구 수리계산서) 모양.
|
||||
* 홍수량 계산서 한 장(PLAN 62-2 · 69-4) — 우리 양식(공용 카드 · 입력 · 드롭다운 · 테마 색).
|
||||
*
|
||||
* 왼쪽 「1. 홍수량 계산」 1-1 합리식 · 1-2 도달시간(Kirpich) · 1-3 강우강도
|
||||
* 오른쪽 「2. 단면검토」 관 단면 그림 · 단면적 · 윤변 · 경심 · 경사 · n · 유속 · 유출량 · 판정
|
||||
* 입력값 빨강 · 계산값 파랑 · 주요 입력 노랑 바탕 · 흰 종이(어두움 테마에서도 같음).
|
||||
* 왼쪽 카드 「1. 홍수량 계산」 1-1 합리식 · 1-2 도달시간(Kirpich) · 1-3 강우강도
|
||||
* 오른쪽 카드 「2. 단면 검토」 관 단면 그림 · 단면적 · 윤변 · 경심 · 경사 · n · 유속 · 유출량 · 판정
|
||||
* 테두리는 입력 · 선택 칸만(공용 ui-input · ui-select) · 계산 칸은 글자만
|
||||
* 굵게 + 붉은 계열은 중요 결과(Qd · 유출량 · 판정 · 유출율)만 · 빈칸에 기대는 계산 칸은 「—」.
|
||||
*
|
||||
* 입력 칸은 한 번 만들고 계산 결과만 제자리에 바꿈 — 고치는 중 칸 포커스를 잃지 않게.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCard, createInputField, createSelectField } from "@ui/ui_template_elements";
|
||||
|
||||
export interface HydroInputs {
|
||||
factor: number;
|
||||
runoff: number;
|
||||
area_km2: number;
|
||||
length_m: number;
|
||||
height_m: number;
|
||||
area_km2: number | null;
|
||||
length_m: number | null;
|
||||
height_m: number | null;
|
||||
tc_min_minutes: number;
|
||||
r24_mm: number;
|
||||
r24_mm: number | null;
|
||||
region: string;
|
||||
return_period: number;
|
||||
pipe_kind: string;
|
||||
n: number | null;
|
||||
diameter_mm: number;
|
||||
slope_denominator: number;
|
||||
diameter_mm: number | null;
|
||||
slope_denominator: number | null;
|
||||
section: "full" | "arc240" | "eff70";
|
||||
}
|
||||
|
||||
export interface HydroResult {
|
||||
area_ha: number;
|
||||
slope_ground: number;
|
||||
tc_raw_hr: number;
|
||||
tc_hr: number;
|
||||
area_ha: number | null;
|
||||
r24_mm: number | null;
|
||||
slope_ground: number | null;
|
||||
tc_raw_hr: number | null;
|
||||
tc_hr: number | null;
|
||||
tc_floored: boolean;
|
||||
intensity: number;
|
||||
flow: number;
|
||||
design_flow: number;
|
||||
intensity: number | null;
|
||||
flow: number | null;
|
||||
design_flow: number | null;
|
||||
n: number;
|
||||
section_area: number;
|
||||
perimeter: number;
|
||||
radius: number;
|
||||
slope_bed: number;
|
||||
velocity: number;
|
||||
capacity: number;
|
||||
ok: boolean;
|
||||
section_area: number | null;
|
||||
perimeter: number | null;
|
||||
radius: number | null;
|
||||
slope_bed: number | null;
|
||||
velocity: number | null;
|
||||
capacity: number | null;
|
||||
ok: boolean | null;
|
||||
ratio_pct: number | null;
|
||||
}
|
||||
|
||||
/** 공사지 — 지역(전처리 행정구역 · 프로젝트 등록) · 관측소 · 재현기간별 24시간 확률강우량 */
|
||||
export interface HydroSite {
|
||||
region: { name: string; full: string; source: string };
|
||||
station: string;
|
||||
periods: number[];
|
||||
r24: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface HydroOptions {
|
||||
defaults: HydroInputs;
|
||||
factors: number[];
|
||||
sections: Record<HydroInputs["section"], string>;
|
||||
pipe_kinds: Record<string, number>;
|
||||
site: HydroSite;
|
||||
}
|
||||
|
||||
export interface HydroSheet {
|
||||
@@ -61,8 +74,20 @@ export interface HydroSheet {
|
||||
|
||||
type Key = keyof HydroInputs;
|
||||
|
||||
const fmt = (value: number | null | undefined, digits: number): string =>
|
||||
value == null || !Number.isFinite(value) ? "-" : value.toFixed(digits);
|
||||
/** 비워 둘 수 있는 칸 — 측점 고유 값 · n(관 종류 값) · R24(공사지 자료가 없을 때 입력) */
|
||||
const NULLABLE = new Set<Key>([
|
||||
"area_km2",
|
||||
"length_m",
|
||||
"height_m",
|
||||
"diameter_mm",
|
||||
"slope_denominator",
|
||||
"n",
|
||||
"r24_mm",
|
||||
]);
|
||||
|
||||
const DASH = "—";
|
||||
const fmt = (value: number | null | undefined, digits: number, unit = ""): string =>
|
||||
value == null || !Number.isFinite(value) ? DASH : `${value.toFixed(digits)}${unit}`;
|
||||
|
||||
function node<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
@@ -87,7 +112,7 @@ function formula(text: string, className = "b08-hydro__formula"): HTMLElement {
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 관 단면 그림 — 단면 가정대로 물 찬 자리를 칠함. */
|
||||
/** 관 단면 그림 — 단면 가정대로 물 찬 자리를 칠함(색은 테마 토큰 · CSS). */
|
||||
function pipeFigure(section: HydroInputs["section"]): SVGSVGElement {
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(ns, "svg");
|
||||
@@ -126,29 +151,29 @@ const SECTION_TEXT: Record<
|
||||
HydroInputs["section"],
|
||||
{ area: string; perimeter: string; radius: string }
|
||||
> = {
|
||||
full: { area: "A = π D^2^ / 4 = 0.785 D^2^", perimeter: "p = π D", radius: "R = A / P = D / 4" },
|
||||
full: { area: "A = π D^2^ / 4 = 0.785 D^2^", perimeter: "P = π D", radius: "R = A / P = D / 4" },
|
||||
arc240: {
|
||||
area: "A = π D^2^/4 × 240/360 + (D/2)^2^ × sin60 × cos60 = 0.632 D^2^",
|
||||
perimeter: "p = π D × 240/360",
|
||||
perimeter: "P = π D × 240/360",
|
||||
radius: "R = A / P",
|
||||
},
|
||||
eff70: {
|
||||
area: "A = 0.7 × π D^2^ / 4",
|
||||
perimeter: "p = π D",
|
||||
perimeter: "P = π D",
|
||||
radius: "R = D / 4 (만관 경심)",
|
||||
},
|
||||
};
|
||||
|
||||
export function buildHydroSheet(
|
||||
stationText: string,
|
||||
page: { index: number; total: number },
|
||||
inputs: HydroInputs,
|
||||
options: HydroOptions,
|
||||
onChange: (next: HydroInputs) => void,
|
||||
): HydroSheet {
|
||||
const site = options.site;
|
||||
const out = new Map<string, HTMLElement>();
|
||||
const outCell = (id: string, className = ""): HTMLElement => {
|
||||
const cell = node("span", `b08-hydro__out ${className}`.trim());
|
||||
const outCell = (id: string, key = false): HTMLElement => {
|
||||
const cell = node("span", `b08-hydro__out${key ? " is-key" : ""}`, DASH);
|
||||
out.set(id, cell);
|
||||
return cell;
|
||||
};
|
||||
@@ -157,165 +182,177 @@ export function buildHydroSheet(
|
||||
current = { ...current, [key]: value } as HydroInputs;
|
||||
onChange(current);
|
||||
};
|
||||
const numberInput = (key: Key, step = "any"): HTMLInputElement => {
|
||||
const input = node("input", "b08-hydro__in");
|
||||
input.type = "number";
|
||||
input.step = step;
|
||||
const numberInput = (key: Key, placeholder = ""): HTMLInputElement => {
|
||||
const { input } = createInputField({ type: "number", compact: true, placeholder });
|
||||
input.classList.add("b08-hydro__in");
|
||||
input.step = "any";
|
||||
input.dataset.b08Hydro = key;
|
||||
const value = inputs[key];
|
||||
input.value = value == null ? "" : String(value);
|
||||
input.addEventListener("change", () => {
|
||||
// n 은 비우면 관 종류 값 · 최소 도달시간은 0 까지 · 그 밖은 0 보다 커야 함 — 아니면 되돌림
|
||||
// 빈칸은 NULLABLE 만 · 최소 도달시간은 0 까지 · 그 밖은 0 보다 커야 함 — 아니면 되돌림
|
||||
const parsed = input.value === "" ? null : Number(input.value);
|
||||
const valid =
|
||||
parsed === null
|
||||
? key === "n"
|
||||
? NULLABLE.has(key)
|
||||
: Number.isFinite(parsed) && (key === "tc_min_minutes" ? parsed >= 0 : parsed > 0);
|
||||
if (valid) commit(key, parsed);
|
||||
else input.value = current[key] == null ? "" : String(current[key]);
|
||||
});
|
||||
return input;
|
||||
};
|
||||
const textInput = (key: Key): HTMLInputElement => {
|
||||
const input = node("input", "b08-hydro__in b08-hydro__in--text");
|
||||
input.value = String(inputs[key] ?? "");
|
||||
input.dataset.b08Hydro = key;
|
||||
input.addEventListener("change", () => commit(key, input.value));
|
||||
return input;
|
||||
};
|
||||
const select = (key: Key, choices: Array<[string, string]>): HTMLSelectElement => {
|
||||
const el = node("select", "b08-hydro__in b08-hydro__select");
|
||||
const select = (
|
||||
key: Key,
|
||||
choices: Array<[string, string]>,
|
||||
toValue: (raw: string) => unknown = (raw) => raw,
|
||||
): HTMLSelectElement => {
|
||||
const { select: el } = createSelectField({
|
||||
compact: true,
|
||||
options: choices.map(([value, text]) => ({ value, text })),
|
||||
value: String(inputs[key]),
|
||||
});
|
||||
el.classList.add("b08-hydro__select");
|
||||
el.dataset.b08Hydro = key;
|
||||
for (const [value, label] of choices) el.append(new Option(label, value));
|
||||
el.value = String(inputs[key]);
|
||||
el.addEventListener("change", () =>
|
||||
commit(key, key === "factor" ? Number(el.value) : el.value),
|
||||
);
|
||||
el.addEventListener("change", () => commit(key, toValue(el.value)));
|
||||
return el;
|
||||
};
|
||||
/** 표 줄 — 이름 · 값 칸(입력 또는 계산) · 덧칸 */
|
||||
const row = (label: string, value: HTMLElement, extra?: HTMLElement, key = false) => {
|
||||
const line = node("div", `b08-hydro__row${key ? " is-key" : ""}`);
|
||||
line.append(node("span", "b08-hydro__label", label), value, extra ?? node("span"));
|
||||
const row = (label: string | HTMLElement, value: HTMLElement, extra?: HTMLElement) => {
|
||||
const line = node("div", "b08-hydro__row");
|
||||
const name = typeof label === "string" ? node("span", "b08-hydro__label", label) : label;
|
||||
line.append(name, value);
|
||||
if (extra) line.append(extra);
|
||||
return line;
|
||||
};
|
||||
/** 오른쪽 줄 — 이름 · 식(또는 입력) · 값 칸 */
|
||||
const rowF = (label: string, middle: HTMLElement | null, value: HTMLElement, key = false) => {
|
||||
const line = node("div", `b08-hydro__row b08-hydro__row--f${key ? " is-key" : ""}`);
|
||||
/** 식 줄 — 이름 · 식 · 값 칸 */
|
||||
const rowF = (label: string, middle: HTMLElement | null, value: HTMLElement) => {
|
||||
const line = node("div", "b08-hydro__row b08-hydro__row--f");
|
||||
line.append(node("span", "b08-hydro__label", label), middle ?? node("span"), value);
|
||||
return line;
|
||||
};
|
||||
const sub = (text: string): HTMLElement => node("h4", "b08-hydro__h2", text);
|
||||
const hint = (text: string): HTMLElement => node("span", "b08-hydro__hint", text);
|
||||
|
||||
// ── 왼쪽 1. 홍수량 계산 ──
|
||||
const left = node("section", "b08-hydro__col");
|
||||
const station = node("div", "b08-hydro__station");
|
||||
station.append("측점 : ", node("mark", "", stationText));
|
||||
const factor = select(
|
||||
"factor",
|
||||
options.factors.map((f) => [String(f), f.toFixed(1)]),
|
||||
options.factors.map((f) => [String(f), `× ${f.toFixed(1)}`]),
|
||||
Number,
|
||||
);
|
||||
const qdLabel = node("span", "b08-hydro__label");
|
||||
qdLabel.append("Qd : 홍수량 × ", factor);
|
||||
const qdRow = node("div", "b08-hydro__row");
|
||||
qdRow.append(qdLabel, outCell("design_flow"));
|
||||
left.append(
|
||||
node("h3", "b08-hydro__h1", "1. 홍 수 량 계 산"),
|
||||
station,
|
||||
node("h4", "b08-hydro__h2", "1-1. Rational Method 공식"),
|
||||
formula("Q = 1/3.6 × f × rt × A"),
|
||||
qdRow,
|
||||
row("Q : 홍수량 ㎥/sec", outCell("flow")),
|
||||
row("f : 유출계수", numberInput("runoff")),
|
||||
row("rt : 평균시간 강우량 mm/hr", outCell("intensity")),
|
||||
row("A : 유역면적 ㎢", numberInput("area_km2"), outCell("area_ha", "b08-hydro__ha"), true),
|
||||
node("h4", "b08-hydro__h2", "1-2. 홍수도달시간"),
|
||||
node("p", "b08-hydro__note", "산지부 (Kirpich 공식)"),
|
||||
formula("T = 0.0663 × L^0.77^ × s^-0.385^ (L : ㎞ · T : hr)"),
|
||||
row("T : 홍수도달시간 (hr)", outCell("tc_hr"), outCell("tc_note", "b08-hydro__tcnote")),
|
||||
row("L : 산정에서 하천시점까지 거리 (m)", numberInput("length_m"), undefined, true),
|
||||
row("s : 지표면경사 (H/L)", outCell("slope_ground")),
|
||||
row("H : 산정에서 하천시점까지 표고차 (m)", numberInput("height_m"), undefined, true),
|
||||
row("최소 도달시간 (분)", numberInput("tc_min_minutes")),
|
||||
node("h4", "b08-hydro__h2", "1-3. 강우강도 rt의 결정"),
|
||||
);
|
||||
const rain = node("div", "b08-hydro__rain");
|
||||
const r24 = node("div", "b08-hydro__row is-key");
|
||||
r24.append(formula("R_24_ : 확률강우량 (mm/day)", "b08-hydro__label"), numberInput("r24_mm"));
|
||||
const place = node("div", "b08-hydro__place");
|
||||
place.append(
|
||||
node("span", "b08-hydro__place-head", "지 역"),
|
||||
node("span", "b08-hydro__place-head", "재현기간"),
|
||||
textInput("region"),
|
||||
numberInput("return_period"),
|
||||
);
|
||||
const rtRow = node("div", "b08-hydro__row");
|
||||
rtRow.append(formula("rt = (R_24_/24) × (24/T)^0.557^"), outCell("intensity2"));
|
||||
rain.append(rtRow, r24, place);
|
||||
left.append(
|
||||
rain,
|
||||
node("p", "b08-hydro__note", "(확률강우량 — 기본값 · 지역 관측소 값으로 고침)"),
|
||||
const r24Site = (period: number): number | undefined => site.r24[String(period)];
|
||||
const hasRain = Object.keys(site.r24).length > 0;
|
||||
const r24Value: HTMLElement = hasRain ? outCell("r24") : numberInput("r24_mm", "입력");
|
||||
const periodSelect = select(
|
||||
"return_period",
|
||||
site.periods.map((p) => [String(p), `${p} 년`]),
|
||||
Number,
|
||||
);
|
||||
periodSelect.addEventListener("change", () => {
|
||||
// 공사지 자료 값은 바로 보임 — 계산은 서버가 같은 표로 다시 함
|
||||
if (hasRain) out.get("r24")!.textContent = fmt(r24Site(current.return_period), 1);
|
||||
});
|
||||
const region: HTMLElement = site.region.name
|
||||
? node("span", "b08-hydro__value", site.region.name)
|
||||
: (() => {
|
||||
const { input } = createInputField({ compact: true, placeholder: "지역 입력" });
|
||||
input.classList.add("b08-hydro__in");
|
||||
input.dataset.b08Hydro = "region";
|
||||
input.value = inputs.region;
|
||||
input.addEventListener("change", () => commit("region", input.value.trim()));
|
||||
return input;
|
||||
})();
|
||||
if (site.region.name) region.dataset.b08Hydro = "region";
|
||||
const regionNote = site.region.full
|
||||
? `${site.region.full} · ${site.region.source}`
|
||||
: "공사지 위치 자료 없음";
|
||||
const rainNote = hasRain
|
||||
? `${site.station ? `${site.station} 관측소 ` : ""}확률강우량(전처리 자료)`
|
||||
: "강우 자료 없음 — 직접 입력";
|
||||
|
||||
// ── 오른쪽 2. 단면검토 ──
|
||||
const right = node("section", "b08-hydro__col");
|
||||
const box = node("div", "b08-hydro__box");
|
||||
const left = createCard({
|
||||
title: "1. 홍수량 계산",
|
||||
body: [
|
||||
sub("1-1. 합리식 (Rational Method)"),
|
||||
formula("Q = 1/3.6 × f × rt × A"),
|
||||
row(
|
||||
(() => {
|
||||
const label = node("span", "b08-hydro__label b08-hydro__label--select");
|
||||
label.append("Qd : 설계 홍수량 (㎥/s) = Q ", factor);
|
||||
return label;
|
||||
})(),
|
||||
outCell("design_flow", true),
|
||||
),
|
||||
row("Q : 홍수량 (㎥/s)", outCell("flow")),
|
||||
row("f : 유출계수", numberInput("runoff")),
|
||||
row("rt : 평균 강우강도 (mm/hr)", outCell("intensity")),
|
||||
row("A : 유역면적 (㎢)", numberInput("area_km2", "입력"), outCell("area_ha")),
|
||||
sub("1-2. 홍수 도달시간 — 산지부 (Kirpich)"),
|
||||
formula("T = 0.0663 × L^0.77^ × s^-0.385^ (L : ㎞ · T : hr)"),
|
||||
row("T : 홍수 도달시간 (hr)", outCell("tc_hr"), outCell("tc_note")),
|
||||
row("L : 산정에서 하천시점까지 거리 (m)", numberInput("length_m", "입력")),
|
||||
row("H : 산정에서 하천시점까지 표고차 (m)", numberInput("height_m", "입력")),
|
||||
row("s : 지표면 경사 (H / L)", outCell("slope_ground")),
|
||||
row("최소 도달시간 (분)", numberInput("tc_min_minutes")),
|
||||
sub("1-3. 강우강도 rt"),
|
||||
formula("rt = (R_24_ / 24) × (24 / T)^0.557^"),
|
||||
row("지역", region, hint(regionNote)),
|
||||
row("재현기간", periodSelect),
|
||||
row(formula("R_24_ : 확률강우량 (mm/day)", "b08-hydro__label"), r24Value, hint(rainNote)),
|
||||
],
|
||||
});
|
||||
left.classList.add("b08-hydro__card");
|
||||
|
||||
// ── 오른쪽 2. 단면 검토 ──
|
||||
const kinds = Object.entries(options.pipe_kinds).map(
|
||||
([kind, n]) => [kind, `${kind} (n ${n})`] as [string, string],
|
||||
);
|
||||
const kindLine = node("div", "b08-hydro__kind");
|
||||
kindLine.append(
|
||||
"- ",
|
||||
select("pipe_kind", kinds),
|
||||
" · ",
|
||||
select("section", Object.entries(options.sections) as Array<[string, string]>),
|
||||
);
|
||||
const total = node("div", "b08-hydro__row b08-hydro__row--wide");
|
||||
total.append(node("span", "b08-hydro__label", "1. 총 홍수량 Qd (㎥/s)"), outCell("design_flow2"));
|
||||
const texts = SECTION_TEXT[inputs.section];
|
||||
const nInput = numberInput("n", String(options.pipe_kinds[inputs.pipe_kind] ?? ""));
|
||||
const dims = node("div", "b08-hydro__dims");
|
||||
const nInput = numberInput("n");
|
||||
nInput.placeholder = String(options.pipe_kinds[inputs.pipe_kind] ?? "");
|
||||
dims.append(
|
||||
rowF("단 면 : D (mm)", null, numberInput("diameter_mm"), true),
|
||||
rowF("단면적 :", formula(texts.area), outCell("section_area")),
|
||||
rowF("윤 변 :", formula(texts.perimeter), outCell("perimeter")),
|
||||
rowF("경 심 :", formula(texts.radius), outCell("radius")),
|
||||
rowF("하상경사 : S = 1 /", numberInput("slope_denominator"), outCell("slope_pct")),
|
||||
rowF("n", node("span", "b08-hydro__hint", "비우면 관 종류 값"), nInput),
|
||||
rowF("단면 D (mm)", null, numberInput("diameter_mm", "입력")),
|
||||
rowF("단면적", formula(texts.area), outCell("section_area")),
|
||||
rowF("윤변", formula(texts.perimeter), outCell("perimeter")),
|
||||
rowF("경심", formula(texts.radius), outCell("radius")),
|
||||
rowF("하상경사 S = 1 /", numberInput("slope_denominator", "입력"), outCell("slope_pct")),
|
||||
rowF("조도계수 n", hint("비우면 관 종류 값"), nInput),
|
||||
);
|
||||
const figure = node("div", "b08-hydro__figure");
|
||||
figure.append(pipeFigure(inputs.section), dims);
|
||||
const verdict = node("div", "b08-hydro__verdict");
|
||||
out.set("verdict", verdict);
|
||||
box.append(
|
||||
kindLine,
|
||||
total,
|
||||
node("div", "b08-hydro__h2", "2. 유 출 량"),
|
||||
figure,
|
||||
rowF("- 유 속 :", formula("V = 1/n × R^2/3^ × S^1/2^ (m/sec)"), outCell("velocity")),
|
||||
rowF("- 유출량 :", formula("Q = A × V (㎥/sec)"), outCell("capacity")),
|
||||
verdict,
|
||||
rowF("- 유출율 (%)", formula("Q / Qd × 100"), outCell("ratio_pct")),
|
||||
);
|
||||
right.append(node("h3", "b08-hydro__h1", "2. 단면검토"), box);
|
||||
const verdict = node("div", "b08-hydro__verdict", DASH);
|
||||
verdict.dataset.b08HydroVerdict = "";
|
||||
const right = createCard({
|
||||
title: "2. 단면 검토",
|
||||
body: [
|
||||
row("관 종류", select("pipe_kind", kinds)),
|
||||
row(
|
||||
"단면 가정",
|
||||
select("section", Object.entries(options.sections) as Array<[string, string]>),
|
||||
),
|
||||
row("총 홍수량 Qd (㎥/s)", outCell("design_flow2", true)),
|
||||
sub("유출량"),
|
||||
figure,
|
||||
rowF("유속 (m/s)", formula("V = 1/n × R^2/3^ × S^1/2^"), outCell("velocity")),
|
||||
rowF("유출량 (㎥/s)", formula("Q = A × V"), outCell("capacity", true)),
|
||||
verdict,
|
||||
rowF("유출율 (%)", formula("Q / Qd × 100"), outCell("ratio_pct", true)),
|
||||
],
|
||||
});
|
||||
right.classList.add("b08-hydro__card");
|
||||
|
||||
const status = node("p", "b08-hydro__status");
|
||||
const paper = node("article", "b08-hydro__paper");
|
||||
paper.dataset.b08HydroSheet = stationText;
|
||||
const sheet = node("div", "b08-hydro__sheet");
|
||||
sheet.dataset.b08HydroSheet = stationText;
|
||||
const body = node("div", "b08-hydro__body");
|
||||
body.append(left, right);
|
||||
paper.append(
|
||||
body,
|
||||
status,
|
||||
node("footer", "b08-hydro__page", `— ${page.index} / ${page.total} 쪽 —`),
|
||||
);
|
||||
sheet.append(body, status);
|
||||
|
||||
const set = (id: string, text: string): void => {
|
||||
const cell = out.get(id);
|
||||
if (cell) cell.textContent = text;
|
||||
};
|
||||
return {
|
||||
root: paper,
|
||||
root: sheet,
|
||||
show(result, message = "") {
|
||||
status.textContent = message;
|
||||
const r = result;
|
||||
@@ -323,27 +360,29 @@ export function buildHydroSheet(
|
||||
set("design_flow2", fmt(r?.design_flow, 2));
|
||||
set("flow", fmt(r?.flow, 2));
|
||||
set("intensity", fmt(r?.intensity, 2));
|
||||
set("intensity2", fmt(r?.intensity, 2));
|
||||
set("area_ha", r ? `${fmt(r.area_ha, 1)} ha` : "-");
|
||||
set("area_ha", fmt(r?.area_ha, 1, " ha"));
|
||||
set("r24", fmt(r?.r24_mm ?? r24Site(current.return_period), 1));
|
||||
set("tc_hr", fmt(r?.tc_hr, 3));
|
||||
set(
|
||||
"tc_note",
|
||||
r?.tc_floored
|
||||
r?.tc_floored && r.tc_raw_hr != null
|
||||
? `Kirpich ${fmt(r.tc_raw_hr * 60, 1)}분 → 최소 ${current.tc_min_minutes}분`
|
||||
: "",
|
||||
);
|
||||
set("slope_ground", fmt(r?.slope_ground, 3));
|
||||
set("section_area", r ? `${fmt(r.section_area, 3)} ㎡` : "-");
|
||||
set("perimeter", r ? `${fmt(r.perimeter, 3)} m` : "-");
|
||||
set("section_area", fmt(r?.section_area, 3, " ㎡"));
|
||||
set("perimeter", fmt(r?.perimeter, 3, " m"));
|
||||
set("radius", fmt(r?.radius, 3));
|
||||
set("slope_pct", r ? `${fmt(r.slope_bed * 100, 2)} %` : "-");
|
||||
set("slope_pct", fmt(r?.slope_bed == null ? null : r.slope_bed * 100, 2, " %"));
|
||||
set("velocity", fmt(r?.velocity, 3));
|
||||
set("capacity", fmt(r?.capacity, 3));
|
||||
set("ratio_pct", fmt(r?.ratio_pct, 0));
|
||||
verdict.classList.toggle("is-ng", !!r && !r.ok);
|
||||
verdict.textContent = r
|
||||
? `${fmt(r.capacity, 3)}㎥/s ${r.ok ? ">" : "<"} Qd = ${fmt(r.design_flow, 2)}㎥/s - ${r.ok ? "O K" : "N G"} -`
|
||||
: "-";
|
||||
const judged = r != null && r.ok != null;
|
||||
verdict.classList.toggle("is-key", judged);
|
||||
verdict.classList.toggle("is-ng", judged && !r.ok);
|
||||
verdict.textContent = judged
|
||||
? `${fmt(r.capacity, 3)} ㎥/s ${r.ok ? "≥" : "<"} Qd ${fmt(r.design_flow, 2)} ㎥/s — ${r.ok ? "O K" : "N G"}`
|
||||
: `판정 ${DASH}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 69-7 박스암거 · 세월교 지점 계산 방법 검토 (검토만 · 코드 안 고침)
|
||||
|
||||
## 결론 한 줄
|
||||
|
||||
- 박스암거 = 사각 단면 Manning(만관 대신 수심 비율 · 20 % 단면 여유) · 근거 있음.
|
||||
- 세월교 · 물넘이 = 지식DB에 월류(위어) 공식 없음 · 실무 사례는 개수로 Manning(지금 B05 `fordSection` 과 같음) · 이 방식 권고.
|
||||
- 「관 + 월류」 유량 나눔 기준 없음 → 사용자 판단 필요.
|
||||
|
||||
## 1. 박스암거
|
||||
|
||||
| 근거 | 자리 | 내용 | 성격 |
|
||||
|---|---|---|---|
|
||||
| 선정 | `original/임도기술교본/3. 임도 계획과 설계/3. 실시설계.md` L387~403 | 수리계산상 배수관 Ø1,500 ㎜ 이상 유역 · 협곡 · 계곡 횡단경사 40 % 이내 | 실무(교본) |
|
||||
| 선정 보조 | `original/임도기술교본/8. 배수공사/1. 암거(속도랑).md` L43 | 유량 많고 계곡 좁으면 박스 · 관 2련 이상이면 물넘이 검토 | 실무(교본) |
|
||||
| 할증 · 여유 | `technical_info/01_임도/02_상세설계/횡단배수관_암거.md` §6(별표2 Ⅰ.2.카) | 통수단면 = 최대홍수유출량 2.0배 이상 · 복토 50 ㎝ 이상 · DB-18 | 법령 정리 |
|
||||
| 식 · n | `technical_info/01_임도/03_계산정보/통수단면.md` §2~4 | V = 1/n·R^2/3·S^1/2 · 콘크리트 수로 n 0.015(양호)/0.017(보통) · 직사각형 B = 2H 경제 | KDS 44 40 00 |
|
||||
| 단면 여유 | `original/표준시방서/…/KDS 44 40 15 횡단배수(23.01).md` L50 | 암거 · 배수관 20 % 단면적 여유 | KDS(도로) |
|
||||
| 사례 | `original/실무문서/2024년 간선임도(기번3-울진.대흥)(1공구)/_분석_수량산출.md` L109 | BOX 3.0×1.2 · n 0.013 → 29.236 ㎥/s · 흄관 Ø1000 부족으로 BOX/물넘이 채택 | 실무 |
|
||||
|
||||
제안 식(사각 단면 · 화면 칸 = 폭 B · 높이 H · 련수 · n · 경사 S):
|
||||
|
||||
- 수심 h = 0.8 H(20 % 여유 · KDS 44 40 15 L50을 높이로 옮긴 것 — 사용자 확정 필요)
|
||||
- A = B·h · P = B + 2h(바닥 + 양 벽 · 뚜껑 안 닿음) · R = A / P
|
||||
- Q = 련수 × A × (1/n)·R^2/3·S^1/2 ≥ Qd(= Q × 2.0) → OK/NG
|
||||
- 지금 관 방식 화면에 「단면 가정」 갈래로 「박스」 하나 더함 → 단면 그림만 사각 · 나머지 같음.
|
||||
|
||||
## 2. 세월교 · 물넘이 포장
|
||||
|
||||
| 근거 | 자리 | 내용 | 성격 |
|
||||
|---|---|---|---|
|
||||
| 법정 | 별표2 Ⅰ.2.아.(2) (`technical_info/01_임도/02_상세설계/개거_세월시설.md` §3) | 소계류는 물넘이포장 · 세월교 · 수리계산으로 적정 단면 — 계산 방법 미지정 | 법령 |
|
||||
| 행정규칙 | `original/행정규칙/임도설치 및 관리 등에 관한 규정/현행_20240719.md` L118 | 계류 횡단 구간은 물넘이 포장(세월교) · 교량 | 행정규칙 |
|
||||
| 실무 기준 | `임도기술교본/3. 임도 계획과 설계/3. 실시설계.md` L341~385 | 종단 3~5 % · 계곡 횡단경사 30 % 이내 · 물받이 길이 ≥ 2H · 두께 ≥ 30 ㎝ · 구체 길이 = 홍수흔적법 · 세월교 구체에 원형 관 검토 | 실무(교본) |
|
||||
| 정의 | `임도기술교본/1. 교본의 개요/3. 용어해설.md` L131·L133 | 세월시설 = 노면 아래 관거 함께 · 평시 관 · 홍수 때 노면 월류 | 실무(교본) |
|
||||
| 사례 | 울진 1공구 `_분석_수량산출.md` L106~109 | 물넘이 B 20 m · 수심 0.27 m · n 0.017 · 개수로 Manning(V 7.636 m/s) · 위어 식 안 씀 | 실무 |
|
||||
| 유일한 위어형 | `original/사방기술교본/3. 야계사방/2-다2. 사방댐설치사업 - 불투과형 설계.md` L171~233 | 사방댐 방수로 Qp = (2/15)·C·√(2g)·(3B1+2B2)·h^3/2 · C 0.6~0.66 | 실무(사방 · 임도 규정 아님) |
|
||||
|
||||
지금 코드: `B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts` `fordSection` — 사각 개수로 Manning 역산으로 월류 수심 · `A00_Common/design/design_structure_ford_calc.ts` 0.01 m 올림 · `config/config_system_design.py` FORD_MANNING_N 0.017 · FORD_SLOPE 0.10(울진 역산).
|
||||
|
||||
제안 갈래(사용자 판단):
|
||||
|
||||
1. **월류만(권고 · 실무 사례 · 지금 B05 와 같은 식)** — 물넘이 폭 B · n 0.017 · S 로 Qd 전량을 노면 월류로 봄 · 수심 h = Manning 역산 · 판정 = h ≤ 허용 월류 수심(값 없음 → 사용자 지정).
|
||||
2. **관 + 월류** — 관 통수량 Qp(지금 원형 관 식) 먼저 · 남는 Qd − Qp 를 월류 수심으로 · 근거 = 용어해설의 정성 정의만 · 나눔 기준 · 빈도 근거 없음.
|
||||
3. **위어 식** — 광정위어 Q = C·B·h^1.5 · 지식DB 근거 없음(사방댐 식만) · 권고 안 함.
|
||||
|
||||
B08 계산서에 넣을 때: 「단면 검토」 카드 머리에 시설 종류(관 · 박스 · 세월교 · 물넘이)를 측점의 facility 로 고르고 칸만 갈래별로 바꿈 · B05 값은 안 이음(69-6 원칙 유지) · 식은 서버 단독.
|
||||
|
||||
## 3. 없는 것
|
||||
|
||||
- 임도용 광정위어 공식 · 계수(C 1.7 · 1.84) — 법령 · KDS · 교본 어디에도 없음.
|
||||
- 세월교 관 · 월류 유량 배분 · 각 빈도 — 없음(공통 100년 · 2.0배만).
|
||||
- 월류 폭 · 수심 상한 — 수치 없음(홍수흔적법 · 지형으로 정함).
|
||||
- 박스 수심 비율(0.8H 등) · 최소 규격 — 없음(20 % 단면 여유 · 2.0배만).
|
||||
- 세월교 · 박스 표준도 수치 — 교본 그림 이미지뿐.
|
||||
|
||||
## 4. 사용자 확인 칸
|
||||
|
||||
- [ ] 박스 수심 비율 0.8H(20 % 여유)로 갈지
|
||||
- [ ] 세월교 · 물넘이 = 갈래 1(월류만) · 2(관 + 월류) 중
|
||||
- [ ] 허용 월류 수심 값
|
||||
@@ -0,0 +1,58 @@
|
||||
/* 공용 표(`ui_template/ui_template_data_table.ts`) 견본 — 토적표 모양 · 머리 3줄 · 30열 · 합계.
|
||||
* 그냥 글(스크립트)로 읽어 씀 — Node 헬퍼와 화면 eval 이 같은 견본을 씀.
|
||||
* const s = sampleDataTable(66); // { columns, rows, total, frozen } */
|
||||
function sampleDataTable(n) {
|
||||
const num = (key, label, group, decimals) => ({ key, label, group, kind: "number", decimals });
|
||||
const trio = (pre, group) => [
|
||||
num(pre + "_area", "단면적", group, 2),
|
||||
num(pre + "_vol", "입적", group, 1),
|
||||
num(pre + "_adj", "보정량", group, 1),
|
||||
];
|
||||
const pair = (pre, group) => [
|
||||
num(pre + "_len", "거리", group, 1),
|
||||
num(pre + "_area", "면적", group, 1),
|
||||
];
|
||||
const columns = [
|
||||
{ key: "sta", label: "측점", width: 80 },
|
||||
num("dist", "거리", [], 0),
|
||||
...trio("cs", ["절토", "토사"]),
|
||||
...trio("cr", ["절토", "연암"]),
|
||||
...trio("ds", ["절토", "측구파기 토사"]),
|
||||
...trio("dr", ["절토", "측구파기 암"]),
|
||||
num("adj_sum", "보정량계", ["절토"], 1),
|
||||
num("fill_area", "단면적", ["성토"], 2),
|
||||
num("fill_vol", "입적", ["성토"], 1),
|
||||
num("reuse", "유용토", [], 1),
|
||||
num("diff", "차인토량", [], 1),
|
||||
num("cum", "누가토량", [], 1),
|
||||
...pair("gf", ["면고르기", "성토면"]),
|
||||
...pair("gc", ["면고르기", "절토면"]),
|
||||
...pair("tf", ["지장목제거", "성토"]),
|
||||
...pair("tc", ["지장목제거", "절토"]),
|
||||
...pair("rd", ["노면정리"]),
|
||||
];
|
||||
const rows = [];
|
||||
let cum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const r = {
|
||||
sta: i % 5 ? `${120 + Math.floor(i / 5)}+${(i % 5) * 4}` : String(120 + i / 5),
|
||||
dist: i ? 4 : 0,
|
||||
};
|
||||
columns.slice(2).forEach((c, j) => {
|
||||
// 빈 값 · 큰 값 · 음수가 섞이게
|
||||
r[c.key] =
|
||||
(i + j) % 7 === 0
|
||||
? null
|
||||
: Math.round(((i * 37 + j * 101) % 997) * (j % 4 === 0 ? 13.7 : 1.13) * 100) / 100;
|
||||
});
|
||||
r.diff = Math.round((r.fill_vol ?? 0) - (r.cs_vol ?? 0)) / 1;
|
||||
cum += r.diff;
|
||||
r.cum = cum;
|
||||
rows.push(r);
|
||||
}
|
||||
const total = { sta: "합계" };
|
||||
columns.slice(1).forEach((c) => {
|
||||
if (c.key !== "cum") total[c.key] = rows.reduce((s, r) => s + (r[c.key] ?? 0), 0);
|
||||
});
|
||||
return { columns, rows, total, frozen: 1 };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/* 공용 표(`ui_template/ui_template_data_table.ts`) 단위검증 헬퍼 —
|
||||
* 트랜스파일해 Node 로 견본 표 HTML 을 만들고 판정할 값을 JSON 으로 냄. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const req = (p) => (p.endsWith(".css") ? {} : require(p));
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
const m = box.exports;
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, "fixture_data_table_sample.js"), "utf8");
|
||||
const sampleDataTable = new Function(src + "; return sampleDataTable;")();
|
||||
|
||||
const s = sampleDataTable(66);
|
||||
const html = m.renderDataTableHtml(s);
|
||||
const head = html.split("<thead>")[1].split("</thead>")[0];
|
||||
const headRows = head.split("</tr>").filter(Boolean);
|
||||
const body = html.split("<tbody>")[1].split("</tbody>")[0];
|
||||
const bodyRows = body.split("</tr>").filter(Boolean);
|
||||
const foot = html.split("<tfoot>")[1].split("</tfoot>")[0];
|
||||
|
||||
const t0 = process.hrtime.bigint();
|
||||
const big = m.renderDataTableHtml(sampleDataTable(1000));
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
|
||||
const one = (c, v) =>
|
||||
m.formatCell(
|
||||
v,
|
||||
c.kind === "number"
|
||||
? new Intl.NumberFormat("ko-KR", {
|
||||
minimumFractionDigits: c.decimals ?? 0,
|
||||
maximumFractionDigits: c.decimals ?? 0,
|
||||
})
|
||||
: null,
|
||||
)[0];
|
||||
const nc = { key: "a", label: "a", kind: "number", decimals: 2 };
|
||||
const tc = { key: "b", label: "b" };
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
cols: s.columns.length,
|
||||
headRows: headRows.length,
|
||||
firstHeadRow: headRows[0],
|
||||
secondHeadRow: headRows[1],
|
||||
bodyRows: bodyRows.length,
|
||||
firstBodyRow: bodyRows[0],
|
||||
foot,
|
||||
frozenCells: (body.match(/class="is-frozen is-edge"/g) || []).length,
|
||||
bigRows: big.split("<tbody>")[1].split("</tbody>")[0].split("</tr>").length - 1,
|
||||
bigMs: ms,
|
||||
fmt: {
|
||||
thousands: one(nc, 1234567.891),
|
||||
neg: one(nc, -97.9),
|
||||
zero: one(nc, 0),
|
||||
nul: one(nc, null),
|
||||
undef: one(nc, undefined),
|
||||
blank: one(nc, ""),
|
||||
nan: one(nc, NaN),
|
||||
text: one(tc, "120+18"),
|
||||
textNum: one(tc, 5),
|
||||
},
|
||||
win: (() => {
|
||||
const b = m
|
||||
.renderDataTableHtml(sampleDataTable(1000), { from: 61, to: 121, rowH: 25 })
|
||||
.split("<tbody>")[1]
|
||||
.split("</tbody>")[0]
|
||||
.split("</tr>")
|
||||
.filter(Boolean);
|
||||
return { n: b.length, top: b[0], second: b[1].slice(0, 20), bottom: b[b.length - 1] };
|
||||
})(),
|
||||
escaped: m.renderDataTableHtml({ columns: [{ key: "x", label: "<b>" }], rows: [{ x: "a&<" }] }),
|
||||
noTotal: m.renderDataTableHtml({ columns: [tc], rows: [] }).includes("<tfoot>"),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,73 @@
|
||||
"""추천 「종단 반영」 켜기 · 끄기(PLAN 64-1) — 점수 분기 시험.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_64_1_profile_toggle.py -q
|
||||
|
||||
켬(기본) = 후보마다 균형 종단을 세워 토공 점수를 그것으로(63-4) · 끔 = 종단 없이 원지반 가늠
|
||||
(절 + 성). 끈 요청은 켠 요청이 묶음 캐시에 남긴 종단을 걷어 내고 처음부터 끈 것과 같아야 한다.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import Terrain, generate_initial_route
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import COST_EARTH_M3
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import profile_earth
|
||||
from B05_Profile.B05_Profile_Router_Replan import RouteInitialRequest
|
||||
|
||||
GRADE = 0.14
|
||||
RADIUS = 12.0
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def hill() -> Terrain:
|
||||
x = np.arange(0.0, 802.0, 2.0)
|
||||
y = np.arange(0.0, 402.0, 2.0)
|
||||
xx, yy = np.meshgrid(x, y)
|
||||
z = 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2)
|
||||
return Terrain(x, y, z, np.ones(xx.shape, dtype=bool))
|
||||
|
||||
|
||||
def _run(terrain: Terrain, mode: str, with_profile: bool) -> dict:
|
||||
expected = [(float(x), 200.0) for x in np.arange(50.0, 752.0, 4.0)]
|
||||
return generate_initial_route(
|
||||
expected,
|
||||
terrain,
|
||||
[],
|
||||
mode=mode,
|
||||
weights=None,
|
||||
max_grade=GRADE,
|
||||
min_radius_m=RADIUS,
|
||||
with_profile=with_profile,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["earthwork", "cost", "follow"])
|
||||
def test_켬은_종단_끔은_원지반(hill: Terrain, mode: str) -> None:
|
||||
on = _run(hill, mode, True)
|
||||
off = _run(hill, mode, False)
|
||||
assert on["with_profile"] is True and off["with_profile"] is False
|
||||
assert "error" not in on["metrics"]["profile"]
|
||||
assert "profile" not in off["metrics"] and "earth_estimate_m3" not in off["metrics"]
|
||||
# 끔 점수 = 절 + 성 · 켬 점수 = 균형 종단 토공
|
||||
assert GOALS["earthwork"](off["metrics"]) == (
|
||||
off["metrics"]["cut_m3"] + off["metrics"]["fill_m3"],
|
||||
)
|
||||
assert GOALS["earthwork"](on["metrics"]) == (profile_earth(on["metrics"]["profile"]),)
|
||||
# 공사비 어림 = 켬은 토공 몫을 종단 점수로 바꾼 값(고른 노선이 달라도 그 노선의 처음 값 기준)
|
||||
metrics = on["metrics"]
|
||||
shift = COST_EARTH_M3 * (profile_earth(metrics["profile"]) - metrics["earth_estimate_m3"])
|
||||
assert metrics["cost_index"] == pytest.approx(metrics["cost_index_base"] + shift, abs=1.0)
|
||||
|
||||
|
||||
def test_끈_요청은_묶음_캐시의_종단을_걷어_낸다(hill: Terrain) -> None:
|
||||
first = _run(hill, "cost", False)
|
||||
_run(hill, "cost", True) # 같은 묶음 캐시에 종단을 붙임
|
||||
again = _run(hill, "cost", False)
|
||||
assert again["metrics"] == first["metrics"] and again["planned"] == first["planned"]
|
||||
assert "profile" not in again["metrics"]
|
||||
|
||||
|
||||
def test_요청_기본은_켬이고_끔을_받는다() -> None:
|
||||
assert RouteInitialRequest(mode="earthwork").with_profile is True
|
||||
assert RouteInitialRequest(mode="earthwork", with_profile=False).with_profile is False
|
||||
@@ -0,0 +1,119 @@
|
||||
"""B08 4탭 홍수량 계산서 — 처음 값 빈칸 · 공사지 지역 · 재현기간별 R24 · 저장 파일 (PLAN 69-5 · 69-6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Hydro import compute # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Hydro_Defaults import DEFAULTS # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Hydro_Site import ( # noqa: E402
|
||||
SAVED_FILE,
|
||||
read_saved,
|
||||
site_info,
|
||||
site_r24,
|
||||
write_saved,
|
||||
)
|
||||
|
||||
SQUARE = [[[128.0, 36.0], [129.0, 36.0], [129.0, 37.0], [128.0, 37.0], [128.0, 36.0]]]
|
||||
FAR = [[[120.0, 30.0], [121.0, 30.0], [121.0, 31.0], [120.0, 31.0], [120.0, 30.0]]]
|
||||
|
||||
|
||||
def _geojson(*features: tuple[list, dict]) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": p,
|
||||
"geometry": {"type": "Polygon", "coordinates": g},
|
||||
}
|
||||
for g, p in features
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def project(tmp_path: Path) -> Path:
|
||||
drainage = tmp_path / "B04_PreProcess" / "drainage"
|
||||
processed = tmp_path / "B04_PreProcess" / "processed"
|
||||
drainage.mkdir(parents=True)
|
||||
processed.mkdir(parents=True)
|
||||
rain = {
|
||||
"lat": 36.5,
|
||||
"lon": 128.5,
|
||||
"station": {"name": "영양군(도곡리)"},
|
||||
"values": [
|
||||
{"return_period_yr": 50, "duration_min": 1440, "depth_mm": 207.69},
|
||||
{"return_period_yr": 100, "duration_min": 1440, "depth_mm": 231.56},
|
||||
{"return_period_yr": 100, "duration_min": 60, "depth_mm": 70.0},
|
||||
],
|
||||
}
|
||||
(drainage / "rainfall_table.json").write_text(json.dumps(rain), encoding="utf-8")
|
||||
(processed / "행정구역_시군구_bounds.geojson").write_text(
|
||||
_geojson(
|
||||
(FAR, {"sig_kor_nm": "봉화군", "full_nm": "경상북도 봉화군"}),
|
||||
(SQUARE, {"sig_kor_nm": "영양군", "full_nm": "경상북도 영양군"}),
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(processed / "행정구역_읍면동_bounds.geojson").write_text(
|
||||
_geojson((SQUARE, {"full_nm": "경상북도 영양군 일월면 용화리"})), encoding="utf-8"
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_처음_값은_표준_값만_측점_고유_값은_빈칸() -> None:
|
||||
for key in ("area_km2", "length_m", "height_m", "diameter_mm", "slope_denominator", "r24_mm"):
|
||||
assert DEFAULTS[key] is None
|
||||
assert DEFAULTS["factor"] == 2.0 and DEFAULTS["runoff"] == 0.8
|
||||
assert DEFAULTS["return_period"] == 100 and DEFAULTS["section"] == "full"
|
||||
|
||||
|
||||
def test_빈칸이면_그_값에_기대는_칸만_비움() -> None:
|
||||
out = compute(DEFAULTS)
|
||||
assert out["design_flow"] is None and out["capacity"] is None
|
||||
assert out["ok"] is None and out["ratio_pct"] is None
|
||||
assert out["n"] == 0.024 # 관 종류 값은 늘 있음
|
||||
# 단면만 채우면 유출량은 나오고 판정은 빈칸
|
||||
half = compute({**DEFAULTS, "diameter_mm": 1000.0, "slope_denominator": 20.0})
|
||||
assert half["capacity"] > 0 and half["design_flow"] is None and half["ok"] is None
|
||||
|
||||
|
||||
def test_지역은_노선_대표점이_드는_행정구역(project: Path) -> None:
|
||||
site = site_info(project, db_region="엉뚱")
|
||||
assert site["region"]["name"] == "영양군"
|
||||
assert site["region"]["full"] == "경상북도 영양군 일월면 용화리"
|
||||
assert site["station"] == "영양군(도곡리)"
|
||||
assert site["periods"] == [50, 100]
|
||||
assert site["r24"] == {"50": 207.69, "100": 231.56}
|
||||
|
||||
|
||||
def test_재현기간을_바꾸면_R24_가_바뀜(project: Path) -> None:
|
||||
assert site_r24(project, 100) == 231.56
|
||||
assert site_r24(project, 50) == 207.69
|
||||
assert site_r24(project, 30) is None
|
||||
|
||||
|
||||
def test_자료가_없으면_등록_지역_재현기간_기본_목록(tmp_path: Path) -> None:
|
||||
site = site_info(tmp_path, db_region="울진군")
|
||||
assert site["region"] == {"name": "울진군", "full": "울진군", "source": "프로젝트 등록"}
|
||||
assert 100 in site["periods"] and site["r24"] == {}
|
||||
assert site_r24(tmp_path, 100) is None
|
||||
|
||||
|
||||
def test_저장_파일은_B08_자기_자리(tmp_path: Path) -> None:
|
||||
stations = {"85.050": {**DEFAULTS, "area_km2": 0.05}}
|
||||
write_saved(tmp_path, stations)
|
||||
assert (tmp_path / SAVED_FILE).is_file()
|
||||
assert read_saved(tmp_path) == stations
|
||||
assert [p.name for p in (tmp_path / SAVED_FILE).parent.iterdir()] == ["hydro_inputs.json"]
|
||||
@@ -4,6 +4,8 @@
|
||||
② 종류 더하기 · 지우기(기본 넷 포함) · 다시 읽어 같음
|
||||
③ 대피소 · 사토장 자리 — 한 자리에 둘 다
|
||||
④ 고른 조각만 도면 층으로 얹힘
|
||||
⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 ·
|
||||
옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -19,13 +21,16 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
|
||||
layer_id,
|
||||
slope_pieces,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
_kinds_dir,
|
||||
_refuge_items,
|
||||
add_kind,
|
||||
delete_kind,
|
||||
list_kinds,
|
||||
read_marks,
|
||||
slope_records,
|
||||
update_kind,
|
||||
write_marks,
|
||||
)
|
||||
|
||||
@@ -89,8 +94,11 @@ def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> No
|
||||
longitudinal_path, longitudinal, designs = _project(tmp_path)
|
||||
records = slope_records(longitudinal_path, longitudinal, designs, 0.0, 40.0)
|
||||
route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)]
|
||||
outline, ticks = slope_pieces(route, records, 0.0, 40.0)
|
||||
left_edge, left_end, right_edge, right_end = outline["lines"]
|
||||
edge, fill, cut, ticks = slope_pieces(route, records, 0.0, 40.0)
|
||||
left_edge, right_edge = edge["lines"]
|
||||
# 좌(+)가 높은 지반 — 좌측 비탈은 절토 · 우측은 성토, 각각 한 줄로 이어짐
|
||||
assert all(r["left_cut"] and not r["right_cut"] for r in records)
|
||||
(left_end,), (right_end,) = cut["lines"], fill["lines"]
|
||||
for chainage in (20, 40): # 견본 측점 둘
|
||||
design = {**designs[chainage], "_c": chainage}
|
||||
index = next(i for i, p in enumerate(left_edge) if abs(p[1] - chainage) < 1e-6)
|
||||
@@ -157,3 +165,68 @@ def test_drawing_has_only_chosen_pieces() -> None:
|
||||
assert layer_id("contour") not in layers and layer_id("station") not in layers
|
||||
labels = [e["shapeData"]["label"] for e in drawing["entities"] if e["type"] == "Text"]
|
||||
assert "R=15" in labels and "계획평면도(시험)" in labels
|
||||
|
||||
|
||||
def test_split_runs_switch_cut_fill_and_stay_joined() -> None:
|
||||
route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)]
|
||||
base = {"left_edge_m": 2.0, "left_end_m": 5.0, "right_edge_m": -2.0, "right_end_m": -5.0}
|
||||
records = [
|
||||
{"chainage_m": 0.0, **base, "left_cut": True, "right_cut": False},
|
||||
{"chainage_m": 40.0, **base, "left_cut": False, "right_cut": False},
|
||||
]
|
||||
_edge, fill, cut, _ticks = slope_pieces(route, records, 0.0, 40.0)
|
||||
(cut_left,) = cut["lines"]
|
||||
fill_left = next(line for line in fill["lines"] if line[0][0] < 0)
|
||||
assert cut_left[-1] == fill_left[0] # 바뀌는 자리에서 두 선이 이어짐
|
||||
assert cut_left[0][1] == 0.0 and fill_left[-1][1] == 40.0
|
||||
|
||||
|
||||
def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None:
|
||||
root = tmp_path / "project"
|
||||
outline = {"road_edge", "fill_outline", "cut_outline"}
|
||||
for (
|
||||
kind
|
||||
) in DEFAULT_KINDS: # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음
|
||||
assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"]
|
||||
folder = _kinds_dir(root)
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "old.json").write_text(
|
||||
json.dumps({"name": "옛것", "pieces": ["slope_outline", "station"], "order": 0}), "utf-8"
|
||||
)
|
||||
(kind,) = list_kinds(root)
|
||||
assert set(kind["pieces"]) == outline | {"station"}
|
||||
|
||||
|
||||
def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None:
|
||||
root = tmp_path / "project"
|
||||
manifest = _read_manifest(root)
|
||||
manifest["drawings"] = {
|
||||
"plan_terrain": {"confirmed": True},
|
||||
"plan_route": {"confirmed": True},
|
||||
}
|
||||
_write_manifest(root, manifest)
|
||||
update_kind(root, "terrain", "지형2", ["contour", "centerline"]) # 기본 종류 고침
|
||||
again = {k["id"]: k for k in list_kinds(root)} # 다시 읽어 같음
|
||||
assert again["terrain"] == {
|
||||
"id": "terrain",
|
||||
"name": "지형2",
|
||||
"pieces": ["contour", "centerline"],
|
||||
}
|
||||
assert [k["id"] for k in list_kinds(root)][0] == "terrain" # 차례 그대로
|
||||
drawings = _read_manifest(root)["drawings"]
|
||||
assert drawings["plan_terrain"]["confirmed"] is False
|
||||
assert drawings["plan_route"]["confirmed"] is True
|
||||
with pytest.raises(ValueError):
|
||||
update_kind(root, "terrain", "노선배치도", ["contour"]) # 다른 종류와 같은 이름
|
||||
with pytest.raises(ValueError):
|
||||
update_kind(root, "terrain", "지형", [])
|
||||
with pytest.raises(FileNotFoundError):
|
||||
update_kind(root, "nokind", "없음", ["contour"])
|
||||
|
||||
|
||||
def test_drainage_symbol_piece() -> None:
|
||||
pieces = {"drainage": {"items": [{"x": 0.0, "y": 20.0, "label": "D800"}]}}
|
||||
drawing = build_plan_kind_drawing("plan_k2", "시험", (-50.0, -10.0, 50.0, 50.0), pieces)
|
||||
mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("drainage")]
|
||||
assert {e["type"] for e in mine} >= {"Text"} and len(mine) >= 2
|
||||
assert any(e["type"] == "Text" and e["shapeData"]["label"] == "D800" for e in mine)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""공용 표 양식(PLAN 70-1) 회귀검증 — 묶음 머리 · 고정 열 · 숫자 꼴 · 빈 값 · 합계 · 1,000줄.
|
||||
|
||||
Node 헬퍼(`helper_ui_data_table.cjs`)가 `ui_template/ui_template_data_table.ts` 를 트랜스파일해
|
||||
견본(`fixture_data_table_sample.js` · 머리 3줄 · 30열 · 66줄)으로 HTML 을 만들고, 여기서 판정함.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_ui_data_table.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_견본_크기(r):
|
||||
assert r["cols"] == 30
|
||||
assert r["headRows"] == 3
|
||||
assert r["bodyRows"] == 66
|
||||
|
||||
|
||||
def test_묶음_머리_합침(r):
|
||||
first = r["firstHeadRow"]
|
||||
# 측점 · 거리 · 유용토 등 묶음 없는 열은 머리 3줄을 세로로 차지
|
||||
assert 'rowspan="3" class="is-frozen is-edge" style="left:0px" title="측점">측점</th>' in first
|
||||
assert first.count('rowspan="3"') == 5 # 측점 거리 유용토 차인토량 누가토량
|
||||
assert 'colspan="13">절토</th>' in first # 토사 · 연암 · 측구파기 토사 · 암 각 3 + 보정량계
|
||||
assert 'colspan="4">면고르기</th>' in first
|
||||
assert 'colspan="2">노면정리</th>' in first
|
||||
second = r["secondHeadRow"]
|
||||
assert 'colspan="3">토사</th>' in second and 'colspan="3">연암</th>' in second
|
||||
assert 'rowspan="2" data-num="" title="보정량계"' in second # 한 단 묶음은 머리 2줄
|
||||
|
||||
|
||||
def test_고정_열과_숫자_칸(r):
|
||||
row = r["firstBodyRow"]
|
||||
assert row.startswith('<tr><td class="is-frozen is-edge" style="left:0px">120</td>')
|
||||
assert r["frozenCells"] == 66
|
||||
assert '<td data-num="">0</td>' in row # 거리 0 은 빈 값 아님
|
||||
|
||||
|
||||
def test_숫자_꼴(r):
|
||||
f = r["fmt"]
|
||||
assert f["thousands"] == "1,234,567.89"
|
||||
assert f["neg"] == "-97.90"
|
||||
assert f["zero"] == "0.00"
|
||||
assert f["nul"] == f["undef"] == f["blank"] == f["nan"] == "—"
|
||||
assert f["text"] == "120+18" and f["textNum"] == "5"
|
||||
|
||||
|
||||
def test_합계_줄(r):
|
||||
assert r["foot"].startswith('<tr><td class="is-frozen is-edge" style="left:0px">합계</td>')
|
||||
assert "is-empty" in r["foot"] # 누가토량 합계는 비움
|
||||
assert r["noTotal"] is False
|
||||
|
||||
|
||||
def test_글_새지_않음(r):
|
||||
assert "<b>" in r["escaped"] and "a&<" in r["escaped"]
|
||||
|
||||
|
||||
def test_천_줄_빠름(r):
|
||||
assert r["bigRows"] == 1000
|
||||
assert r["bigMs"] < 300
|
||||
|
||||
|
||||
def test_보이는_줄만_그림(r):
|
||||
w = r["win"]
|
||||
assert w["n"] == 62 # 위 빈 줄 + 60줄 + 아래 빈 줄
|
||||
assert 'style="height:1525px"><td colspan="30">' in w["top"] # 61줄 × 25
|
||||
assert w["second"].startswith(
|
||||
'<tr class="is-alt">'
|
||||
) # 61 번째(홀수) 줄 — 번갈아 색은 전체 줄 번호로
|
||||
assert 'style="height:21975px"' in w["bottom"] # (1000 - 121) × 25
|
||||
@@ -0,0 +1,18 @@
|
||||
/* 64-1 — B05 노선 추천 「종단 반영」 켬 · 끔 점수 비교
|
||||
* 시작 화면: B05 종단설계 → [계획노선 편집] → [노선 추천] 열림 · 검증 프로젝트(검증_sub1)
|
||||
* 부르는 법: 실행 뒤 `window.__r` 를 읽음(끝나면 채워짐)
|
||||
* 기대 수치: 끔 = 균형 종단 줄 · 종단 그림 · 덮개 줄 없음 · 토공 최소 절 + 성 = 원지반 가늠 ·
|
||||
* 켬 = 균형 종단 줄 있음 · 토공 최소 노선 달라짐(연장 1,156.7 → 1,153.2 m) · 공사비 어림 6,206 → 18,606
|
||||
*/
|
||||
(()=>{window.__r=undefined;(async()=>{
|
||||
const w=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
const q=s=>document.querySelector(s);
|
||||
const waitDone=async()=>{const t0=Date.now();await w(400);for(;;){await w(300);const s=q('[data-state]').textContent;if(s.includes('미리 보기:')||s.includes('셈하지 못')) return (Date.now()-t0)/1000;if(Date.now()-t0>280000) return -1}};
|
||||
const snap=()=>({m:q('[data-line=metrics]').textContent,w:q('[data-line=metrics-work]').textContent,bal:q('[data-line=balance]').textContent,prof:!q('[data-line=profile]').hidden,cands:q('[data-line=candidates]').textContent.length});
|
||||
const setBox=v=>{if(q('[data-with-profile]').checked!==v) q('[data-with-profile]').click()};
|
||||
const out={};
|
||||
for(const l of ['earthwork','cost','gentle']){
|
||||
setBox(false); q('[data-logic='+l+']').click(); const t1=await waitDone(); out[l+'_off']={t:t1,...snap()};
|
||||
setBox(true); q('[data-act=rerun]').click(); const t2=await waitDone(); out[l+'_on']={t:t2,...snap()};
|
||||
}
|
||||
window.__r=out;})();return 'started'})()
|
||||
@@ -0,0 +1,20 @@
|
||||
/* 64-2 — B05 노선 추천 창 오른쪽 설명 = 한 갈래씩 ◀ ▶ 쪽 넘김
|
||||
* 시작 화면: B05 종단설계 → [계획노선 편집] → [노선 추천] (첫 미리 보기가 뜬 뒤) · 검증 프로젝트(검증_sub1)
|
||||
* 기대 수치: 쪽 수 = 갈래 수 = 8 · 한 번에 한 쪽만 보임 · 처음 쪽 = 고른 갈래 · 1 / 8 에서 ◀ 꺼짐 ·
|
||||
* 8 / 8 에서 ▶ 꺼짐 · 다른 칸을 만져 다시 그려도 넘겨 보던 쪽 그대로
|
||||
*/
|
||||
(async()=>{
|
||||
const w=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
const q=s=>document.querySelector(s);
|
||||
const help=()=>({page:q('[data-help-page]').textContent,visible:[...document.querySelectorAll('[data-help-logic]')].filter(x=>!x.hidden).map(x=>x.dataset.helpLogic),on:[...document.querySelectorAll('[data-help-logic].is-on')].map(x=>x.dataset.helpLogic),prevDis:q('[data-help-nav="-1"]').disabled,nextDis:q('[data-help-nav="1"]').disabled,total:document.querySelectorAll('[data-help-logic]').length});
|
||||
const o={};
|
||||
o.start=help();
|
||||
for(let i=0;i<10;i++){q('[data-help-nav="-1"]').click()} o.firstPage=help();
|
||||
for(let i=0;i<10;i++){q('[data-help-nav="1"]').click()} o.lastPage=help();
|
||||
q('[data-help-nav="-1"]').click(); o.prev=help();
|
||||
// 렌더가 돌아도 넘겨 보던 쪽 유지 — 체크 토글로 render 유발
|
||||
q('[data-with-profile]').click(); await w(100); o.afterRender=help();
|
||||
q('[data-with-profile]').click();
|
||||
const a=q('.b05-routerec__help'); const r=a.getBoundingClientRect(); o.helpBox={w:Math.round(r.width),h:Math.round(r.height),scrollH:a.scrollHeight};
|
||||
const art=[...document.querySelectorAll('[data-help-logic]')].find(x=>!x.hidden); o.artText=art.innerText.slice(0,260);
|
||||
return JSON.stringify(o,null,1)})()
|
||||
@@ -0,0 +1,34 @@
|
||||
/* 공용 체크박스(ui_template_checkbox.ts) — 글 한 줄 · 색은 테마 토큰 */
|
||||
.ui-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
color: var(--color-text-body);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-checkbox__input {
|
||||
flex: 0 0 auto;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-checkbox__input:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.ui-checkbox__text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ui-checkbox:has(.ui-checkbox__input:disabled) {
|
||||
color: var(--color-text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* =============================================================================
|
||||
* ui_template_checkbox.ts
|
||||
* 공용 체크박스 — [□ 글] 한 줄 · 색은 테마 토큰(밝음 · 어두움 같이). 줄 세우기는 부르는 쪽 몫.
|
||||
*
|
||||
* const box = createCheckbox("등고선", { checked: true, value: "contour" });
|
||||
* panel.append(box.root); // 읽기 = box.input.checked · box.input.value
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_checkbox.css";
|
||||
|
||||
export interface Checkbox {
|
||||
root: HTMLLabelElement;
|
||||
input: HTMLInputElement;
|
||||
}
|
||||
|
||||
export function createCheckbox(
|
||||
text: string,
|
||||
options: { checked?: boolean; value?: string } = {},
|
||||
): Checkbox {
|
||||
const root = document.createElement("label");
|
||||
root.className = "ui-checkbox";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "ui-checkbox__input";
|
||||
input.checked = options.checked ?? false;
|
||||
if (options.value !== undefined) input.value = options.value;
|
||||
const caption = document.createElement("span");
|
||||
caption.className = "ui-checkbox__text";
|
||||
caption.textContent = text;
|
||||
root.append(input, caption);
|
||||
return { root, input };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/* =============================================================================
|
||||
* ui_template_data_table.css — 많은 자료용 읽기 표(`ui_template_data_table.ts`)
|
||||
* 색은 테마 토큰만 · 칸 배경은 불투명(고정 칸 아래로 비치지 않게)
|
||||
* ========================================================================== */
|
||||
|
||||
.ui-data-table {
|
||||
--dt-row-h: 24px;
|
||||
--dt-zebra: var(--color-surface);
|
||||
--dt-hover: color-mix(in srgb, var(--color-accent) 14%, var(--color-canvas));
|
||||
--dt-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface));
|
||||
--dt-line: var(--color-border);
|
||||
--dt-edge: color-mix(in srgb, var(--color-text-secondary) 45%, var(--color-border));
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 260px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--dt-line);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas);
|
||||
outline: none;
|
||||
overflow-anchor: none; /* 줄을 갈아 그릴 때 브라우저가 스크롤을 옮기지 않게 */
|
||||
}
|
||||
|
||||
.ui-data-table:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.ui-data-table__table {
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.ui-data-table__table caption {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
|
||||
.ui-data-table__table th,
|
||||
.ui-data-table__table td {
|
||||
height: var(--dt-row-h);
|
||||
padding: 0 var(--spacing-8);
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--dt-line);
|
||||
border-bottom: 1px solid var(--dt-line);
|
||||
background: var(--color-canvas);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 숫자 — 오른쪽 맞춤 · 자리 폭 같게 */
|
||||
.ui-data-table__table [data-num] {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ui-data-table__table td.is-empty {
|
||||
color: var(--color-text-secondary); /* muted 는 작은 글에 대비 3:1 — 모자람 */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 머리 — 스크롤해도 위에 붙음 */
|
||||
.ui-data-table__table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.ui-data-table__table thead th {
|
||||
background: var(--dt-head);
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-data-table__table thead tr:last-child th,
|
||||
.ui-data-table__table thead th[rowspan] {
|
||||
border-bottom-color: var(--dt-edge);
|
||||
}
|
||||
|
||||
/* 줄 번갈아 색 · 올린 줄 강조 */
|
||||
.ui-data-table__table tbody tr.is-alt td {
|
||||
background: var(--dt-zebra);
|
||||
}
|
||||
|
||||
.ui-data-table__table tbody tr:hover td {
|
||||
background: var(--dt-hover);
|
||||
}
|
||||
|
||||
/* 안 그린 줄 자리 */
|
||||
.ui-data-table__table tbody tr.ui-data-table__pad td {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* 앞 고정 열 — left 는 칸마다 글에 들어 있음 */
|
||||
.ui-data-table__table .is-frozen {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ui-data-table__table thead .is-frozen {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.ui-data-table__table .is-edge {
|
||||
border-right-color: var(--dt-edge);
|
||||
}
|
||||
|
||||
/* 합계 줄 — 맨 아래 붙음 · 굵게 */
|
||||
.ui-data-table__table tfoot {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.ui-data-table__table tfoot td {
|
||||
border-top: 1px solid var(--dt-edge);
|
||||
background: var(--dt-head);
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/* =============================================================================
|
||||
* ui_template_data_table.ts
|
||||
* 많은 자료용 읽기 표(B08 · B09 공용) — 묶음 머리 여러 줄 · 머리 · 앞 열 고정 스크롤 ·
|
||||
* 숫자 오른쪽 맞춤 · 천 단위 쉼표 · 열마다 소수 자리 · 빈 값 「—」 · 합계 줄.
|
||||
* 줄 1,000개도 빠르게 — HTML 글로 통째로 넣고(칸마다 요소 만들지 않음) 보이는 줄 근처만 그림.
|
||||
*
|
||||
* const t = createDataTable({ columns, rows, frozen: 1, total });
|
||||
* host.append(t.root); t.setRows(rows2, total2);
|
||||
* 열 = { key: "cut_soil_area", label: "단면적", group: ["절토", "토사"], kind: "number", decimals: 2 }
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_data_table.css";
|
||||
|
||||
export interface DataColumn {
|
||||
/** 줄 객체에서 값을 꺼낼 키 */
|
||||
key: string;
|
||||
/** 맨 아래 머리 글 */
|
||||
label: string;
|
||||
/** 묶음 머리(위 → 아래) — 이웃 열과 앞부분이 같으면 한 칸으로 합침 */
|
||||
group?: string[];
|
||||
/** 기본 글 · 숫자면 오른쪽 맞춤 · 천 단위 쉼표 */
|
||||
kind?: "number" | "text";
|
||||
/** 숫자 소수 자리(기본 0) */
|
||||
decimals?: number;
|
||||
/** 열 폭 px(기본 숫자 84 · 글 96) */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export type DataRow = Record<string, unknown>;
|
||||
|
||||
export interface DataTableOptions {
|
||||
columns: DataColumn[];
|
||||
rows: DataRow[];
|
||||
/** 앞에서부터 고정할 열 수(기본 0) */
|
||||
frozen?: number;
|
||||
/** 합계 줄(맨 아래 고정) — 없으면 안 그림 */
|
||||
total?: DataRow | null;
|
||||
/** 표 이름(화면 읽기용) */
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export interface DataTable {
|
||||
root: HTMLElement;
|
||||
setRows: (rows: DataRow[], total?: DataRow | null) => void;
|
||||
}
|
||||
|
||||
const EMPTY = "—";
|
||||
const ESC: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """ };
|
||||
const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC[c]);
|
||||
|
||||
function colWidth(c: DataColumn): number {
|
||||
return c.width ?? (c.kind === "number" ? 84 : 96);
|
||||
}
|
||||
|
||||
/** 값 한 칸 → [보일 글, 빈 값인지] */
|
||||
export function formatCell(v: unknown, fmt: Intl.NumberFormat | null): [string, boolean] {
|
||||
if (v === null || v === undefined || v === "") return [EMPTY, true];
|
||||
if (fmt && typeof v === "number")
|
||||
return Number.isFinite(v) ? [fmt.format(v), false] : [EMPTY, true];
|
||||
return [String(v), false];
|
||||
}
|
||||
|
||||
function headHtml(cols: DataColumn[], left: (number | null)[]): string {
|
||||
const depth = Math.max(0, ...cols.map((c) => c.group?.length ?? 0));
|
||||
const same = (a: DataColumn, b: DataColumn, n: number) =>
|
||||
(b.group?.length ?? 0) > n &&
|
||||
a.group!.slice(0, n + 1).join("\u0000") === b.group!.slice(0, n + 1).join("\u0000");
|
||||
const stick = (from: number, to: number) =>
|
||||
left[to] !== null
|
||||
? ` class="is-frozen${left[to + 1] == null ? " is-edge" : ""}" style="left:${left[from]}px"`
|
||||
: "";
|
||||
const rows: string[] = [];
|
||||
for (let lv = 0; lv <= depth; lv++) {
|
||||
let tr = "";
|
||||
for (let j = 0; j < cols.length; j++) {
|
||||
const c = cols[j];
|
||||
const g = c.group?.length ?? 0;
|
||||
if (g > lv) {
|
||||
let k = j;
|
||||
while (k + 1 < cols.length && same(c, cols[k + 1], lv)) k++;
|
||||
tr += `<th scope="colgroup" colspan="${k - j + 1}"${stick(j, k)}>${esc(c.group![lv])}</th>`;
|
||||
j = k;
|
||||
} else if (g === lv) {
|
||||
const num = c.kind === "number" ? ' data-num=""' : "";
|
||||
const span = depth - lv + 1;
|
||||
tr += `<th scope="col" rowspan="${span}"${num}${stick(j, j)} title="${esc(c.label)}">${esc(c.label)}</th>`;
|
||||
}
|
||||
}
|
||||
rows.push(`<tr>${tr}</tr>`);
|
||||
}
|
||||
return rows.join("");
|
||||
}
|
||||
|
||||
function rowHtml(
|
||||
cols: DataColumn[],
|
||||
fmts: (Intl.NumberFormat | null)[],
|
||||
left: (number | null)[],
|
||||
r: DataRow,
|
||||
alt = false,
|
||||
): string {
|
||||
let tr = "";
|
||||
for (let j = 0; j < cols.length; j++) {
|
||||
const [txt, empty] = formatCell(r[cols[j].key], fmts[j]);
|
||||
const frz = left[j] !== null;
|
||||
const cls = [frz && "is-frozen", frz && left[j + 1] == null && "is-edge", empty && "is-empty"]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const attrs =
|
||||
(cls ? ` class="${cls}"` : "") +
|
||||
(frz ? ` style="left:${left[j]}px"` : "") +
|
||||
(fmts[j] ? ' data-num=""' : "");
|
||||
tr += `<td${attrs}>${esc(txt)}</td>`;
|
||||
}
|
||||
return alt ? `<tr class="is-alt">${tr}</tr>` : `<tr>${tr}</tr>`;
|
||||
}
|
||||
|
||||
/** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */
|
||||
export interface RowWindow {
|
||||
from: number;
|
||||
to: number;
|
||||
rowH: number;
|
||||
}
|
||||
|
||||
const pad = (h: number, n: number) =>
|
||||
h > 0 ? `<tr class="ui-data-table__pad" style="height:${h}px"><td colspan="${n}"></td></tr>` : "";
|
||||
|
||||
/** 표 전체 HTML 글 — DOM 없이 만듦(시험 · 서버 그리기도 같은 글) · win 없으면 모든 줄 */
|
||||
export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): string {
|
||||
const cols = o.columns;
|
||||
const frozen = Math.min(o.frozen ?? 0, cols.length);
|
||||
const left: (number | null)[] = [];
|
||||
let x = 0;
|
||||
cols.forEach((c, i) => {
|
||||
left.push(i < frozen ? x : null);
|
||||
x += colWidth(c);
|
||||
});
|
||||
const fmts = cols.map((c) =>
|
||||
c.kind === "number"
|
||||
? new Intl.NumberFormat("ko-KR", {
|
||||
minimumFractionDigits: c.decimals ?? 0,
|
||||
maximumFractionDigits: c.decimals ?? 0,
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const colgroup = cols.map((c) => `<col style="width:${colWidth(c)}px">`).join("");
|
||||
const n = o.rows.length;
|
||||
const from = win ? Math.max(0, Math.min(win.from, n)) : 0;
|
||||
const to = win ? Math.max(from, Math.min(win.to, n)) : n;
|
||||
let body = win ? pad(from * win.rowH, cols.length) : "";
|
||||
for (let i = from; i < to; i++) body += rowHtml(cols, fmts, left, o.rows[i], i % 2 === 1);
|
||||
if (win) body += pad((n - to) * win.rowH, cols.length);
|
||||
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total)}</tfoot>` : "";
|
||||
const cap = o.caption ? `<caption>${esc(o.caption)}</caption>` : "";
|
||||
return (
|
||||
`<table class="ui-data-table__table" style="width:${x}px">${cap}<colgroup>${colgroup}</colgroup>` +
|
||||
`<thead>${headHtml(cols, left)}</thead><tbody>${body}</tbody>${foot}</table>`
|
||||
);
|
||||
}
|
||||
|
||||
/** 화면 위아래로 더 그려 둘 줄 수 · 구간은 이 단위로 끊어 스크롤마다 다시 그리지 않음 */
|
||||
const OVER = 40;
|
||||
|
||||
export function createDataTable(opts: DataTableOptions): DataTable {
|
||||
const root = document.createElement("div");
|
||||
root.className = "ui-data-table";
|
||||
root.tabIndex = 0; // 키보드로 표 안 스크롤
|
||||
const state = { ...opts };
|
||||
let win: RowWindow = { from: -1, to: -1, rowH: 24 }; // --dt-row-h · 그린 뒤 재서 고침
|
||||
// 보이는 줄 근처만 그림 — 줄 1,000개를 다 배치하면 수백 ms
|
||||
const draw = (force: boolean) => {
|
||||
const n = state.rows.length;
|
||||
const view = Math.ceil((root.clientHeight || 800) / win.rowH);
|
||||
const from = Math.max(0, Math.floor(root.scrollTop / win.rowH / OVER) * OVER - OVER);
|
||||
const to = Math.min(n, from + view + 3 * OVER);
|
||||
if (!force && from === win.from && to === win.to) return;
|
||||
win = { ...win, from, to };
|
||||
root.innerHTML = renderDataTableHtml(state, win);
|
||||
// 줄 높이는 그린 줄을 재서 맞춤(글꼴 · 토큰이 바뀌어도)
|
||||
const tr = root.querySelector<HTMLElement>("tbody tr:not(.ui-data-table__pad)");
|
||||
if (tr && tr.offsetHeight && tr.offsetHeight !== win.rowH) {
|
||||
win = { ...win, rowH: tr.offsetHeight };
|
||||
draw(true);
|
||||
}
|
||||
};
|
||||
root.addEventListener("scroll", () => draw(false), { passive: true });
|
||||
draw(true);
|
||||
return {
|
||||
root,
|
||||
setRows(rows, total) {
|
||||
state.rows = rows;
|
||||
if (total !== undefined) state.total = total;
|
||||
draw(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user