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

This commit is contained in:
2026-10-30 14:44:50 +09:00
35 changed files with 1599 additions and 347 deletions
+21 -1
View File
@@ -1,7 +1,8 @@
/* =============================================================================
* D03_PlanAnalysis_Api.ts
* D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 ·
* 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 지점 · 기준점 함께 591-2) ·
* 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 지점 · 기준점 함께 591-2 ·
* 지점 · 선 이름 · 노드 기록 · [저장] 기준점 594) · 주소 · 장소 찾기(594-4 공용 `/map/search`) ·
* 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림 · 581 — 음영기복 · 보호구역 · 띠 · 산림입지도).
* ========================================================================== */
@@ -17,6 +18,8 @@ export interface BasePoint {
lat: number;
x: number;
y: number;
/** 지점 이름(594-1) — 없음 = 「지점 N」. */
name?: string;
}
/** 지점(긋기에 붙는 점 · 작업 좌표계 m + 경위도) · 등록 기준점(처음 받는 기준 · 591-2) ·
@@ -66,6 +69,10 @@ export interface ServerNetwork {
epsg: number;
/** 지점(PUT 몸만 · 빈 값 = 저장된 지점 그대로). */
points?: Vertex[];
/** 지점 이름(PUT 몸만 · points 와 같은 차례 · points 와 함께 · 빈 칸 = 「지점 N」 · 594-1). */
point_names?: Array<string | null>;
/** 기준점(PUT 몸만 · 바뀌면 서버가 등록 칸 `projects.site_*` 고침 · 주소 = 서버가 다시 찾음 · 594-3). */
site?: { lon: number; lat: number; address?: string | null };
lines: PlanLine[];
marks: Array<{ line: string; at_m: number }>;
pieces: PlanPiece[];
@@ -143,6 +150,19 @@ export const savePlanNetwork = (
): Promise<PlanNetworkView> =>
request(`${base(projectId)}/network`, { method: "PUT", body: JSON.stringify(network) });
/** 주소 · 장소 찾기 후보(594-4) — 주소 = 도로명 · 지번 · 장소 = 이름(`title`) + 주소. */
export interface PlaceHit {
kind: "address" | "place";
title: string;
address: string;
lon: number;
lat: number;
}
/** 주소 · 장소 찾기(서버 VWorld · 등록 D01 · 예정노선 위치 칸이 같이 씀) — 없음 = 빈 줄. */
export const searchPlaces = async (query: string): Promise<PlaceHit[]> =>
(await request<{ items: PlaceHit[] }>(`/map/search?q=${encodeURIComponent(query)}`)).items;
/** 사업 전체 지형면 — 모델 행 경로 `…/models_plan/…` · 격자 = 평활본 · 없으면 null. */
export async function fetchPlanSurface(projectId: string): Promise<PlanSurface | null> {
const { models } = await listSurfaceModels(projectId);
+130 -6
View File
@@ -9,7 +9,9 @@
길이 · 측점 · 높이는 저장하지 않음(늘 다시 셈 · 높이는 지형에서). 측점 = 선마다 No.0 부터.
조각 = 선의 마크 사이 구간(구역 × 갈래) · 업체 지정은 조각 단위(`plan_route_assignees.piece_id`).
지점(`points` · 긋기에 붙는 점) = 같은 파일 · [저장] 몸에 실림(빈 값 = 그대로) ·
기준점(`projects.site_*`) = 처음 자료 받는 기준일 뿐(노선 · 붙음 무관 · PLAN 591-2).
기준점(`projects.site_*`) = 처음 자료 받는 기준일 뿐(노선 · 붙음 무관 · PLAN 591-2) ·
[저장] 몸 `site` 가 바뀌었으면 같은 칸을 고침(594-3 · 등록과 한 칸).
지점 · 선 이름(20 자 · 파일 이름 못 쓰는 글자 막음) · 선 노드 기록 = 파일에 있을 때만(594-1).
계산 자리 = ① 파이썬·TS 짝(`D03_PlanAnalysis_Draw`) — 화면은 그리는 즉시 · [저장]은 여기서 다시 셈.
"""
@@ -18,12 +20,14 @@ from __future__ import annotations
import hashlib
import json
import math
from typing import Any
import re
from typing import Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from B01_Dashboard.B01_Dashboard_Map import reverse_place
from common_util.common_util_auth import is_forest_service, require_company
from common_util.common_util_crs import tm_epsg_for_lon, tm_to_lonlat
from common_util.common_util_save_guard import project_lock
@@ -45,10 +49,29 @@ class Branch(BaseModel):
vertex: int = Field(ge=0)
class NodeRecord(BaseModel):
"""노드 기록(594-1) — 찍을 때 방식 · 길이(m) · 기울기(%) · 안 쓴 값 = None."""
kind: Literal["free", "length", "slope", "both"]
length_m: float | None = Field(default=None, allow_inf_nan=False)
slope_pct: float | None = Field(default=None, allow_inf_nan=False)
class Line(BaseModel):
id: str = Field(pattern=ID)
vertices: list[tuple[float, float]] = Field(min_length=1)
branch: Branch | None = None
# 594-1 — 빈 값 = 차례 이름 · 노드 기록 = 꼭짓점 수와 같음(없음 = 기록 없음)
name: str | None = None
nodes: list[NodeRecord | None] | None = None
class Site(BaseModel):
"""[저장] 몸 기준점(594-3) — 주소는 서버가 다시 찾음(못 찾으면 이 값)."""
lon: float = Field(ge=120, le=135)
lat: float = Field(ge=30, le=45)
address: str | None = Field(default=None, max_length=255)
class Mark(BaseModel):
@@ -68,20 +91,78 @@ class Network(BaseModel):
epsg: int
# 지점(작업 좌표계 m) — 빈 값(None) = 저장된 지점 그대로
points: list[tuple[float, float]] | None = Field(default=None, max_length=MAX_POINTS)
# 지점 이름(594-1) — points 와 같은 차례 · points 와 함께만 읽음 · 빈 칸 = 「지점 N」
point_names: list[str | None] | None = Field(default=None, max_length=MAX_POINTS)
lines: list[Line] = []
marks: list[Mark] = []
pieces: list[Piece] = []
site: Site | None = None
class NetworkError(ValueError):
pass
NAME_MAX = 20
NAME_BAD = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
def name_error(name: str) -> str | None:
"""이름 틀림 까닭 글 · 맞으면 None(앞뒤 빈칸 뺀 값으로 봄 · 594-1).
⚠ 짝 = TS `logic/D03_PlanAnalysis_Plan.ts` `nameError` — 거울 시험 530-3."""
text = name.strip()
if len(text) > NAME_MAX:
return f"이름은 {NAME_MAX} 자까지입니다."
if NAME_BAD.search(text):
return '이름에 \\ / : * ? " < > | 를 쓸 수 없습니다.'
return None
def _clean_name(name: str | None, what: str) -> str | None:
"""검사한 이름 · 빈 값 = None · 틀리면 400."""
if name is None:
return None
if error := name_error(name):
raise HTTPException(status_code=400, detail=f"{what} — {error}")
return name.strip() or None
def _check_points(points: list[tuple[float, float]] | None) -> None:
if points is not None and not all(math.isfinite(v) for p in points for v in p):
raise HTTPException(status_code=400, detail="지점 좌표가 수가 아닙니다.")
def _points_out(
points: list[tuple[float, float]], names: list[str | None] | None
) -> list[dict[str, Any]]:
"""파일 지점 꼴 `{x, y, name?}` — 이름 칸은 있을 때만."""
names = names or []
if len(names) > len(points):
raise HTTPException(status_code=400, detail="지점 이름이 지점보다 많습니다.")
out = []
for i, (x, y) in enumerate(points):
name = _clean_name(names[i] if i < len(names) else None, f"지점 {i + 1} 이름")
out.append({"x": float(x), "y": float(y), **({"name": name} if name else {})})
return out
def _lines_out(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""파일 선 꼴 — 이름 · 노드 기록 칸은 있을 때만(옛 꼴 그대로)."""
out = []
for line in lines:
name = _clean_name(line.get("name"), f"선 {line['id']} 이름")
base = {key: value for key, value in line.items() if key not in ("name", "nodes")}
out.append(
{
**base,
**({"name": name} if name else {}),
**({"nodes": line["nodes"]} if line.get("nodes") else {}),
}
)
return out
def _check(ok: bool, message: str) -> None:
if not ok:
raise NetworkError(message)
@@ -100,6 +181,11 @@ def derive(network: dict[str, Any], year_from: int | None = None) -> dict[str, A
all(math.isfinite(v) for point in vertices for v in point),
f"선 {line['id']} 점이 수가 아닙니다.",
)
nodes = line.get("nodes")
_check(
nodes is None or len(nodes) == len(vertices),
f"선 {line['id']} 노드 기록 수가 꼭짓점 수와 다릅니다.",
)
branch = line.get("branch")
if branch:
parent = lines.get(branch["line"])
@@ -221,10 +307,17 @@ def work_epsg(plan: dict[str, Any], site: dict[str, Any] | None) -> int | None:
def base_view(plan: dict[str, Any], site: dict[str, Any] | None) -> dict[str, Any]:
"""{epsg, points: [{lon, lat, x, y}], site} — 지점(TM) + 경위도 · 기준점(경위도 · 주소)."""
"""{epsg, points: [{lon, lat, x, y, name?}], site} — 지점(TM) + 경위도 · 이름 ·
기준점(경위도 · 주소)."""
epsg = work_epsg(plan, site)
points = [
{"lon": lon, "lat": lat, "x": float(p["x"]), "y": float(p["y"])}
{
"lon": lon,
"lat": lat,
"x": float(p["x"]),
"y": float(p["y"]),
**({"name": p["name"]} if p.get("name") else {}),
}
for p in plan["points"]
if epsg is not None
for lon, lat in [tm_to_lonlat(float(p["x"]), float(p["y"]), epsg)]
@@ -308,6 +401,31 @@ async def _store_status(
)
async def _moved_site(
current: dict[str, Any] | None, site: dict[str, Any] | None
) -> dict[str, Any] | None:
"""[저장] 몸 기준점(594-3) — 그대로면 None · 바뀌면 {lon, lat, address}(주소 = 서버 VWorld ·
못 찾으면 몸 주소)."""
if site is None or (
current
and abs(current["lon"] - site["lon"]) < 1e-9
and abs(current["lat"] - site["lat"]) < 1e-9
):
return None
found = (await reverse_place(site["lon"], site["lat"]))["address"]
address = found or (site.get("address") or "").strip() or None
return {"lon": float(site["lon"]), "lat": float(site["lat"]), "address": address}
async def _store_site(connection: Any, project_id: UUID, site: dict[str, Any]) -> None:
"""기준점 칸 — 등록(D01)과 같은 `projects.site_*`(사업 지역 · 이름은 안 바꿈)."""
async with connection.cursor() as cursor:
await cursor.execute(
"UPDATE projects SET site_lon = %s, site_lat = %s, site_address = %s WHERE id = %s",
(site["lon"], site["lat"], site["address"], str(project_id)),
)
@router.put("/{project_id}/plan/network", response_model=None)
async def put_plan_network(
project_id: UUID,
@@ -322,6 +440,8 @@ async def put_plan_network(
raise HTTPException(status_code=403, detail="예정노선은 산림청만 고칠 수 있습니다.")
network = payload.model_dump()
points = network.pop("points")
point_names = network.pop("point_names")
site = network.pop("site")
async with project_lock(str(project_id)):
root, plan = await store.load_plan(project_id) # 없음 404 · 예정노선 아님 400
if plan["epsg"] is not None and plan["epsg"] != network["epsg"]:
@@ -332,15 +452,19 @@ async def put_plan_network(
except NetworkError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
_check_points(points)
plan = {**plan, **network}
plan = {**plan, **network, "lines": _lines_out(network["lines"])}
if points is not None:
plan["points"] = [{"x": float(x), "y": float(y)} for x, y in points]
plan["points"] = _points_out(points, point_names)
site = await _moved_site(project["site"], site)
async with get_db_pool().acquire() as connection:
await connection.begin()
try:
await _store_status(
connection, project_id, derived["pieces"], year_rows(derived["pieces"])
)
if site:
await _store_site(connection, project_id, site)
project = {**project, "site": site}
store.save_plan(root, plan)
await connection.commit()
except Exception:
+97 -50
View File
@@ -1,25 +1,47 @@
/* =============================================================================
* D03_PlanAnalysis_UI_3D.ts
* D03 [3D] 큰 모달(PLAN 530-3 · 563-1) — 사업 전체 DEM + 그은 선 하나(지반에 붙임) + 측점 막대.
* 선 고름 = 모달 위 칸(본선 · 갈래) · 뷰어 = B05 `createRouteViewer` 그대로 · 닫으면 버림(WebGL 문맥까지).
* 예상 코리도 없음(꺾은선 · 계획고 = 지반고라 절성토 0 — 요구서 389 3장 11).
* D03 [3D] 큰 모달(PLAN 530-3 · 563-1 · 601-3 · 요구 463-21) — 사업 전체 DEM + 노선망 전체(본선 · 갈래) 한 번에.
* 선마다 예상 코리도 = 측점 예상 단면(`logic/D03_PlanAnalysis_Expect` · 리핑암 · 산쪽 절토 · 일반 측구 ·
* 계획고 = 지반고) → B05 `buildCorridor` → 뷰어 `setCorridors`(벌마다 노면 · 지형 잘림 한 번) ·
* 갈래 머리 = 부모 노면만큼 잘라 냄 · 뷰어 = B05 `createRouteViewer` · 닫으면 버림(WebGL 문맥까지) · 보기만(저장 없음).
* ========================================================================== */
import "../B05_Profile/B05_Profile_UI_Style.css";
import { createSelectField } from "@ui/ui_template_input";
import { openModal } from "@ui/ui_template_modal";
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import type { DtmGrid } from "../common_util/common_util_surface_sampler";
import { createRouteViewer, type RouteViewer } from "../B05_Profile/B05_Profile_UI_Viewer";
import { sectionsGround } from "../B05_Profile/logic/B05_Profile_Sections_Ground";
import { buildCorridor } from "../B05_Profile/logic/B05_Profile_Corridor_Build";
import type { CorridorBuildResult } from "../B05_Profile/logic/B05_Profile_Corridor_Build";
import type { SectionStationMarker } from "../B05_Profile/B05_Profile_UI_Markers";
import { fetchSectionContext } from "../B06_Section/B06_Section_Api_Context";
import type { CrossSection } from "../B06_Section/B06_Section_Api_Types";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
import {
branchTrimM,
expectLine,
expectSections,
type ExpectStandard,
} from "./logic/D03_PlanAnalysis_Expect";
export interface Plan3dLine {
name: string;
line: Vertex[];
/** 선 위 측점 누가(선마다 No.0 부터). */
stations: number[];
/** 갈래(다른 선에 붙은 선) — 머리를 부모 노면만큼 잘라 지음. */
branch: boolean;
}
/** 표준 횡단 기본값(config · 서버) — 못 받으면 null(선만). */
function loadExpectStandard(projectId: string): Promise<ExpectStandard | null> {
return fetchSectionContext(projectId).then(
(context) => ({
standard: context.standard_cross_section as ExpectStandard["standard"],
rockBoundaryM: context.rock_boundary_default_offset_m,
}),
() => null,
);
}
/** 격자 → 뷰어 상자(지형에 붙는 선 · 막대는 높이 원점과 무관 · x · y 만 메시와 맞으면 됨). */
@@ -46,58 +68,84 @@ export function openPlan3d(
projectId: string,
surface: { modelId: number; grid: DtmGrid },
lines: Plan3dLine[],
intervalM: number,
): void {
if (!lines.length) return void showToast("3D 로 볼 선이 없습니다.", "warning");
let viewer: RouteViewer | null = createRouteViewer();
let loaded = false;
const pick = createSelectField({
options: lines.map((item, i) => ({ value: String(i), text: item.name })),
value: "0",
size: 28,
ariaLabel: "선",
}).select;
pick.classList.add("d03-plan__3d-year");
const top = document.createElement("div");
top.className = "d03-plan__3d-top";
top.append(pick);
const standard = loadExpectStandard(projectId);
const load = async (index: number): Promise<void> => {
const item = lines[index] ?? lines[0];
const load = async (): Promise<void> => {
if (!viewer) return;
const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true });
progress.set(null);
viewer.root.append(progress.root);
const started = performance.now();
const stat: Record<string, unknown> = { line: item.name, done: false };
const stat: Record<string, unknown> = { lines: lines.length, done: false };
(window as unknown as { __d03Plan3d?: unknown }).__d03Plan3d = stat;
try {
if (!loaded) {
await viewer.loadSurface(
projectId,
surface.modelId,
"dtm",
true,
5,
gridBounds(surface.grid),
);
loaded = true;
}
const [std] = await Promise.all([
standard,
viewer.loadSurface(projectId, surface.modelId, "dtm", true, 5, gridBounds(surface.grid)),
]);
if (!viewer) return;
viewer.markers.renderRoute(item.line.map(([x, y]) => ({ x, y })));
const ground = sectionsGround(item.line, item.stations, surface.grid);
viewer.renderStationLines(
ground.stations.map((station, i) => ({
station_id: String(item.stations[i] ?? station.chainage_m),
center_x: station.center_x,
center_y: station.center_y,
center_z: station.center_z,
frame: { left_xy: station.left_xy },
kind: "regular",
chainage_m: item.stations[i] ?? station.chainage_m,
})),
2.5,
);
stat.stations = ground.stations.length;
stat.surfaceMs = Math.round(performance.now() - started);
viewer.markers.renderRoutes(lines.map((item) => item.line.map(([x, y]) => ({ x, y }))));
const built = performance.now();
const trim = std ? branchTrimM(std) : 0;
const markers: SectionStationMarker[] = [];
const builds: CorridorBuildResult[] = [];
const perLine: Array<Record<string, unknown>> = [];
lines.forEach((item, index) => {
const plan = expectLine(item.line, item.branch ? trim : 0, intervalM);
if (!plan) return;
const ground = sectionsGround(plan.line, plan.stations, surface.grid);
ground.stations.forEach((station, i) => {
const chainage = (plan.stations[i] ?? station.chainage_m) + plan.offsetM;
markers.push({
station_id: `${index}:${chainage}`,
center_x: station.center_x,
center_y: station.center_y,
center_z: station.center_z,
frame: { left_xy: station.left_xy },
kind: "regular",
chainage_m: chainage,
});
});
if (!std) return;
const sections = expectSections(
ground.stations,
std,
surface.grid,
) as unknown as CrossSection[];
const build = buildCorridor(
sections,
plan.line.map(([x, y]) => ({ x, y })),
);
if (build) {
build.sections = sections; // 지형 잘림이 읽음
builds.push(build);
}
perLine.push({
name: item.name,
branch: item.branch,
offsetM: plan.offsetM,
sections: sections.length,
corridor: !!build,
// 노면 중심(설계선 오프셋 0) − 측점 지반고
centerDelta: sections.map((c) =>
c.center_z === null ? null : (c.design?.design_elevation_m ?? NaN) - c.center_z,
),
});
});
viewer.renderStationLines(markers, 2.5);
viewer.setCorridors(builds);
Object.assign(stat, {
trimM: trim,
corridors: builds.length,
stations: markers.length,
buildMs: Math.round(performance.now() - built),
perLine,
});
viewer.setView("iso");
} catch (error) {
showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error");
@@ -107,16 +155,15 @@ export function openPlan3d(
progress.remove();
}
};
pick.addEventListener("change", () => void load(Number(pick.value)));
openModal({
title: "3D 보기 — 지형 + 그은 선",
title: "3D 보기 — 지형 + 노선망(표준 횡단)",
closeLabel: "닫기",
wide: true,
dialogClass: "d03-plan__3d-modal",
mount: (body) => {
body.classList.add("d03-plan__3d-body");
body.append(top, viewer!.root);
body.append(viewer!.root);
viewer!.setActive(true);
},
beforeClose: () => {
@@ -125,5 +172,5 @@ export function openPlan3d(
return true;
},
});
void load(0);
void load();
}
+25 -101
View File
@@ -11,18 +11,15 @@
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중
* 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈).
* 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 ·
* 여러 해 = 다 그은 뒤 도구줄 「연도 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름.
* 여러 해 = 다 그은 뒤 그리기 칸 「연도 구역 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름.
* 고르기 · 고치기 · 마커(쉴 때) = `D03_PlanAnalysis_UI_Edit`.
* 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄).
* 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` ·
* 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지).
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import { showToast } from "@ui/ui_template_elements";
import { keyIs } from "../A00_Common/key_registry";
import { createInputField } from "@ui/ui_template_input";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import {
compassCandidates,
compassPoint,
@@ -50,17 +47,19 @@ import {
paintNetwork,
paintPreview,
screenGap,
slopeDeg,
SNAP_PX,
} from "./D03_PlanAnalysis_UI_DrawPaint";
import {
createDrawKindFields,
createDrawPanel,
DRAW_DEFAULT_LENGTH_M,
DRAW_DEFAULT_SLOPE_PCT,
type DrawConstraint,
} from "./D03_PlanAnalysis_UI_DrawPanel";
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */
export const DRAW_DEFAULT_LENGTH_M = 20;
export const DRAW_DEFAULT_SLOPE_PCT = 9;
const START_HINT = "지점 · 선 끝 · 선 위를 눌러 시작합니다 — 빈 땅은 우클릭 「여기서 긋기 시작」.";
export type DrawConstraint = "free" | "length" | "slope" | "both";
export type DrawMode = "idle" | "draw";
export interface DrawPreview {
@@ -87,7 +86,7 @@ export interface PlanDrawParams {
export interface PlanDraw {
layer: PlanMapLayer;
/** 좌측 「그리기」 칸 몸(모드 단추 · 제약 칸 · 안내). */
/** 좌측 「그리기」 칸 몸(`D03_PlanAnalysis_UI_DrawPanel` — 긋기 · 연도 구역 마커 · 방식 칸). */
panel: HTMLElement;
mode: () => DrawMode;
setMode: (mode: DrawMode) => void;
@@ -119,94 +118,23 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
// ── 좌측 칸 ─────────────────────────────────────────────
const drawButton = createButton({
label: "긋기",
variant: "filled",
size: 28,
onClick: () => setMode(mode === "draw" ? "idle" : "draw"),
const kindFields = createDrawKindFields({
value: { kind, lengthM, slopePct },
onChange: (value) => ({ kind, lengthM, slopePct } = value),
});
const modeRow = document.createElement("div");
modeRow.className = "d03-plan__row";
modeRow.append(drawButton);
const kindSegment = createSegment({
options: [
{ value: "free", label: "자유" },
{ value: "length", label: "길이 고정" },
{ value: "slope", label: "기울기 고정" },
{ value: "both", label: "둘 다" },
],
value: kind,
size: 24,
ariaLabel: "긋기 제약",
onChange: (value) => {
kind = value as DrawConstraint;
fieldsState();
},
const drawPanel = createDrawPanel({
kind: kindFields,
onDraw: () => setMode(mode === "draw" ? "idle" : "draw"),
});
kindSegment.root.dataset.role = "draw-constraint";
const lengthField = createInputField({
label: "길이",
type: "number",
value: String(lengthM),
min: 1,
unit: "m",
size: 24,
onInput: (value) => {
const next = Number(value);
if (next > 0) lengthM = next;
lengthField.setError(next > 0 ? undefined : "0 보다 큰 수");
},
});
lengthField.input.dataset.role = "draw-length";
const slopeField = createInputField({
label: "기울기(± 같음)",
type: "number",
value: String(slopePct),
unit: "%",
size: 24,
onInput: (value) => {
const next = Number(value);
const ok = value.trim() !== "" && Number.isFinite(next);
if (ok) slopePct = next;
slopeField.setError(ok ? undefined : "숫자");
degText();
},
});
slopeField.input.dataset.role = "draw-slope";
const slopeDegText = createText("", "muted");
slopeDegText.dataset.role = "draw-slope-deg";
const degText = (): void => {
slopeDegText.textContent = `≈ ${fmt(slopeDeg(Math.abs(slopePct)))}°`;
};
degText();
const fieldRow = document.createElement("div");
fieldRow.className = "d03-plan__row";
fieldRow.append(lengthField.root, slopeField.root, slopeDegText);
const hint = createText("", "muted");
hint.classList.add("d03-plan__hint");
const panel = document.createElement("div");
panel.className = "d03-plan__draw";
panel.append(modeRow, kindSegment.root, fieldRow, hint);
function fieldsState(): void {
lengthField.input.disabled = kind === "free" || kind === "slope";
slopeField.input.disabled = kind === "free" || kind === "length";
}
const panel = drawPanel.root;
function panelState(): void {
drawButton.querySelector(".ui-btn__label")!.textContent = mode === "draw" ? "긋기 끝" : "긋기";
drawButton.disabled = !editable;
drawButton.setAttribute("aria-pressed", String(mode === "draw"));
panel.dataset.mode = mode;
hint.textContent = !editable
? "보기만 — 산림청 계정에서 긋습니다."
: mode === "draw"
? active === null
? START_HINT
: "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기"
: "[긋기]로 지점 · 선에서 긋습니다 · 지도 우클릭 = 긋기 시작 · 지점 놓기 · 선 누름 = 고름 · 「연도 마커」를 선 위로 끌면 구역을 나눕니다.";
drawPanel.state({
drawing: mode === "draw",
editable,
canMark: params.network().lines.some((line) => line.vertices.length > 1),
});
}
fieldsState();
// ── 셈 ──────────────────────────────────────────────────
const lastOf = (line: number): Vertex | null => {
@@ -524,13 +452,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
constraint: () => ({ kind, lengthM, slopePct }),
setConstraint(next, length, slope) {
kind = next;
kindSegment.set(next);
if (length !== undefined) [lengthM, lengthField.input.value] = [length, String(length)];
if (slope !== undefined) {
[slopePct, slopeField.input.value] = [slope, String(slope)];
degText();
}
fieldsState();
if (length !== undefined) lengthM = length;
if (slope !== undefined) slopePct = slope;
kindFields.set({ kind, lengthM, slopePct });
},
};
}
@@ -33,6 +33,32 @@ export const LINE_HIT_PX = 8;
/** 구간 길이 라벨을 보일 화면 길이(px). */
const LABEL_MIN_PX = 70;
/** 지도 위 그림 검은 테(600-1) — 지금 길을 검은 선 → 색 선 두 번 긋고 점선 무늬를 되돌림 · 길은 부르는 쪽이 먼저 만듦. */
export function strokeCased(
context: CanvasRenderingContext2D,
color: string,
width: number,
dash: number[] = [],
): void {
context.setLineDash(dash);
context.lineWidth = width + 3;
context.strokeStyle = planColors().casing;
context.stroke();
context.lineWidth = width;
context.strokeStyle = color;
context.stroke();
context.setLineDash([]);
}
/** 지도 위 점 — 색 채움 + 검은 테. */
function fillCased(context: CanvasRenderingContext2D, color: string): void {
context.fillStyle = color;
context.fill();
context.lineWidth = 1.5;
context.strokeStyle = planColors().casing;
context.stroke();
}
export const fmt = (value: number): string => value.toFixed(1);
const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`;
/** 기울기 % → 각(°) — 가상 종단 기울기를 두 단위로. */
@@ -151,6 +177,9 @@ export function pointLayer(points: () => Vertex[]): PlanMapLayer {
context.closePath();
context.fillStyle = pointColor(i);
context.fill();
context.lineWidth = 5;
context.strokeStyle = planColors().casing;
context.stroke();
context.lineWidth = 2;
context.strokeStyle = "#ffffff";
context.stroke();
@@ -218,6 +247,9 @@ export function paintMark(
context.closePath();
context.fillStyle = hollow ? "#ffffff" : color;
context.fill();
context.lineWidth = hollow ? 5.5 : 4.5;
context.strokeStyle = planColors().casing;
context.stroke();
context.lineWidth = hollow ? 2.5 : 1.5;
context.strokeStyle = hollow ? color : "#ffffff";
context.stroke();
@@ -235,31 +267,24 @@ export function paintPreview(
const [ax, ay] = map.toScreen(from);
const [bx, by] = map.toScreen(preview.point);
if (compass) {
context.strokeStyle = colors.active;
context.lineWidth = 1;
context.setLineDash([3, 4]);
context.beginPath();
context.arc(ax, ay, compass.lengthM * map.pxPerMeter, 0, Math.PI * 2);
context.stroke();
context.fillStyle = colors.active;
strokeCased(context, colors.active, 1.5, [3, 4]);
for (const c of compass.candidates) {
const [cx, cy] = map.toScreen(c);
context.beginPath();
context.arc(cx, cy, 3, 0, Math.PI * 2);
context.fill();
context.arc(cx, cy, 3.5, 0, Math.PI * 2);
fillCased(context, colors.active);
}
}
context.strokeStyle = preview.blocked ? colors.warn : colors.active;
context.lineWidth = 2;
context.setLineDash([6, 4]);
const color = preview.blocked ? colors.warn : colors.active;
context.beginPath();
context.moveTo(ax, ay);
context.lineTo(bx, by);
context.stroke();
context.setLineDash([]);
strokeCased(context, color, 2.5, [6, 4]);
context.beginPath();
context.arc(bx, by, 5, 0, Math.PI * 2);
context.stroke();
strokeCased(context, color, 2.5);
const value =
`${fmt(preview.lengthM)} m · ` +
(preview.slopePct === null ? "지형 밖" : slopeText(preview.slopePct));
@@ -0,0 +1,190 @@
/* =============================================================================
* D03_PlanAnalysis_UI_DrawPanel.ts
* D03 좌측 「그리기」 칸 몸(PLAN 599-1 · 599-4 · 요구 463-10 · 22) — [긋기] · 연도 구역 마커 · 긋기 방식 칸.
*
* 방식 칸 `createDrawKindFields` = 마디 고름 2 × 2(자유 · 길이 고정 · 기울기 고정 · 길이·기울기 고정) +
* 길이 · 기울기 두 열(기울기 아래 ≈ °) — 그리기 칸 · 노드 판(598-4)이 같은 부품을 씀.
* 연도 구역 마커 = 끌어 선 위에 놓기(`YEAR_MARK_DRAG` · 받음 = `D03_PlanAnalysis_UI_Edit` drop).
* 설명 글 없음(요구 463-6) — 칸 오류 · ≈ ° 값만.
* ========================================================================== */
import type { UiSize } from "@ui/ui_template_button";
import { createButton } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import { fmt, slopeDeg } from "./D03_PlanAnalysis_UI_DrawPaint";
import { YEAR_MARK_DRAG } from "./D03_PlanAnalysis_UI_Map";
/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */
export const DRAW_DEFAULT_LENGTH_M = 20;
export const DRAW_DEFAULT_SLOPE_PCT = 9;
export type DrawConstraint = "free" | "length" | "slope" | "both";
export interface DrawKindValue {
kind: DrawConstraint;
lengthM: number;
slopePct: number;
}
export const DRAW_KIND_LABELS: Record<DrawConstraint, string> = {
free: "자유",
length: "길이 고정",
slope: "기울기 고정",
both: "길이·기울기 고정",
};
export interface DrawKindFields {
root: HTMLElement;
get: () => DrawKindValue;
/** 값을 바꿈(onChange 는 안 부름). */
set: (value: Partial<DrawKindValue>) => void;
setDisabled: (disabled: boolean) => void;
}
/** 긋기 방식 칸 — 고름 · 길이 · 기울기가 바뀔 때마다 `onChange(지금 값)`(빈 · 틀린 수는 칸 오류만). */
export function createDrawKindFields(opts: {
value: DrawKindValue;
onChange: (value: DrawKindValue) => void;
size?: UiSize;
}): DrawKindFields {
const size = opts.size ?? 24;
let value = { ...opts.value };
let disabled = false;
const changed = (): void => opts.onChange({ ...value });
const segment = createSegment({
options: (Object.keys(DRAW_KIND_LABELS) as DrawConstraint[]).map((kind) => ({
value: kind,
label: DRAW_KIND_LABELS[kind],
})),
value: value.kind,
size,
columns: 2,
ariaLabel: "긋기 방식",
onChange: (next) => {
value.kind = next as DrawConstraint;
fieldsState();
changed();
},
});
segment.root.dataset.role = "draw-constraint";
const lengthField = createInputField({
label: "길이",
type: "number",
value: String(value.lengthM),
min: 1,
unit: "m",
size,
onInput: (text) => {
const next = Number(text);
lengthField.setError(next > 0 ? undefined : "0 보다 큰 수");
if (!(next > 0)) return;
value.lengthM = next;
changed();
},
});
lengthField.input.dataset.role = "draw-length";
const slopeField = createInputField({
label: "기울기",
type: "number",
value: String(value.slopePct),
unit: "%",
size,
onInput: (text) => {
const next = Number(text);
const ok = text.trim() !== "" && Number.isFinite(next);
slopeField.setError(ok ? undefined : "숫자");
if (!ok) return;
value.slopePct = next;
degText();
changed();
},
});
slopeField.input.dataset.role = "draw-slope";
const slopeDegText = createText("", "muted");
slopeDegText.dataset.role = "draw-slope-deg";
const degText = (): void => {
slopeDegText.textContent = `≈ ${fmt(slopeDeg(Math.abs(value.slopePct)))}°`;
};
const slopeColumn = document.createElement("div");
slopeColumn.append(slopeField.root, slopeDegText);
const fields = document.createElement("div");
fields.className = "d03-plan__draw-fields";
fields.append(lengthField.root, slopeColumn);
const root = document.createElement("div");
root.className = "d03-plan__draw-kind";
root.append(segment.root, fields);
function fieldsState(): void {
for (const button of segment.root.querySelectorAll("button")) button.disabled = disabled;
lengthField.input.disabled = disabled || value.kind === "free" || value.kind === "slope";
slopeField.input.disabled = disabled || value.kind === "free" || value.kind === "length";
}
degText();
fieldsState();
return {
root,
get: () => ({ ...value }),
set(next) {
value = { ...value, ...next };
segment.set(value.kind);
lengthField.input.value = String(value.lengthM);
slopeField.input.value = String(value.slopePct);
lengthField.setError(undefined);
slopeField.setError(undefined);
degText();
fieldsState();
},
setDisabled(next) {
disabled = next;
fieldsState();
},
};
}
export interface DrawPanel {
root: HTMLElement;
/** 단추 글 · 막힘 — 긋는 중 · 고칠 수 있음 · 마커 놓을 선 있음. */
state: (state: { drawing: boolean; editable: boolean; canMark: boolean }) => void;
}
/** 좌측 「그리기」 칸 — [긋기] · 연도 구역 마커(끌기) · 방식 칸. */
export function createDrawPanel(opts: { kind: DrawKindFields; onDraw: () => void }): DrawPanel {
const drawButton = createButton({
label: "긋기",
variant: "filled",
size: 28,
onClick: opts.onDraw,
});
const markButton = createButton({
label: "연도 구역 마커",
variant: "ghost",
size: 28,
iconStart: "flag",
});
markButton.title = "선 위로 끌어 놓으면 구역을 나눕니다";
markButton.dataset.role = "draw-year-mark";
markButton.draggable = true;
markButton.addEventListener("dragstart", (event) => {
event.dataTransfer?.setData(YEAR_MARK_DRAG, "1");
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy";
});
const row = document.createElement("div");
row.className = "d03-plan__row";
row.append(drawButton, markButton);
const root = document.createElement("div");
root.className = "d03-plan__draw";
root.append(row, opts.kind.root);
return {
root,
state({ drawing, editable, canMark }) {
drawButton.querySelector(".ui-btn__label")!.textContent = drawing ? "긋기 끝" : "긋기";
drawButton.disabled = !editable;
drawButton.setAttribute("aria-pressed", String(drawing));
markButton.disabled = !editable || drawing || !canMark;
root.dataset.mode = drawing ? "draw" : "idle";
},
};
}
+19 -18
View File
@@ -17,7 +17,7 @@
* ========================================================================== */
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
import { themeColor } from "@ui/ui_template_palette";
import { routeColor, themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
computeMapRect,
@@ -26,7 +26,6 @@ import {
createNormalizer,
lonLatToScreen,
normalizedToScreen,
routeLineColor,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { bindRouteEditNavigation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input";
@@ -36,17 +35,27 @@ import type { GlBase } from "./D03_PlanAnalysis_UI_MapGl";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { localTmFrame, tmParams } from "./logic/D03_PlanAnalysis_Tm";
/** 도구줄 「연도 마커」 끌어 놓기 자료 꼴(585-3). */
/** 「연도 구역 마커」 끌어 놓기 자료 꼴(585-3 · 단추 = 그리기 칸 599-4). */
export const YEAR_MARK_DRAG = "application/x-aislo-year-mark";
/** 측점 간격(m) — B 규칙 측점과 같음. */
export const PLAN_STATION_INTERVAL_M = 20;
/** 겹 그림 색(그림 전용 값 `--draw-*` · 밝음 · 어두움 같음) — 선 · 그리는 중 · 경고 · 글 테두리. */
export function planColors(): { line: string; active: string; warn: string; halo: string } {
/** 지도 위 그림 색(600-1) — 지도 전용 값 `--draw-map-*`(테마 안 따름 · 바탕 늘 어두움) + 검은 테 `casing`.
* `line` = 첫 선 색 · 선마다 차례 색 = `routeColor(index)`. */
export function planColors(): {
line: string;
active: string;
warn: string;
measure: string;
casing: string;
halo: string;
} {
return {
line: routeLineColor(),
active: themeColor("--draw-primary", "#6d28d9"),
warn: themeColor("--draw-danger", "#dc2626"),
line: routeColor(0),
active: themeColor("--draw-map-sketch", "#ffe94d"),
warn: themeColor("--draw-map-warn", "#ff5a4d"),
measure: themeColor("--draw-map-measure", "#ffffff"),
casing: "rgba(0, 0, 0, 0.85)",
halo: "rgba(0, 0, 0, 0.65)",
};
}
@@ -115,7 +124,7 @@ export interface PlanTools {
view3d: boolean;
/** 거리재기 켜짐(눌림). */
measure: boolean;
/** 「연도 마커」 끌 수 있음(고칠 수 있고 선 있음). */
/** 받기만 — 연도 구역 마커 단추는 그리기 칸(599-4 · `D03_PlanAnalysis_UI_DrawPanel`)이 켬끔. */
yearMark: boolean;
}
@@ -188,12 +197,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
const redoButton = mapToolButton("redo", { onClick: events.onRedo });
const viewButton = mapToolButton("view3d", { onClick: events.on3d });
const measureButton = mapToolButton("measure", { onClick: events.onMeasure });
const yearMarkButton = mapToolButton("year-mark", { disabled: true });
yearMarkButton.draggable = true;
yearMarkButton.addEventListener("dragstart", (event) => {
event.dataTransfer?.setData(YEAR_MARK_DRAG, "1");
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy";
});
const tools = createMapTools({
label: "평면 도구",
hint: {
@@ -207,7 +210,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
"Shift+누름 = 구간 더함 · 뺌",
"빈 곳 오른쪽으로 끌기 = 창 안 구간 · 왼쪽으로 = 걸친 구간까지",
"고른 선 위 끌기 = 꺾임점 더해 옮김",
"「연도 마커」 선 위로 끌어 놓기 = 구역 나눔",
"그리기 칸 「연도 구역 마커」 선 위로 끌어 놓기 = 구역 나눔",
"꺾임점 · 마크 끌기 = 옮김 · 마크를 선 밖으로 = 지움(앞 구간과 합침)",
"오른쪽 누름 = 꺾임점 · 마크 지움",
"Delete = 고른 선 · 마크 지움",
@@ -220,7 +223,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
[undoButton, redoButton],
[viewButton, "rotate-ccw", "rotate-cw"],
[markButton("stations", "측점"), markButton("slopes", "구간 길이 · 기울기"), measureButton],
[yearMarkButton],
],
});
const toolbar = tools.root;
@@ -517,7 +519,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
if (tools.view3d !== undefined) viewButton.disabled = !tools.view3d;
if (tools.measure !== undefined)
measureButton.setAttribute("aria-pressed", String(tools.measure));
if (tools.yearMark !== undefined) yearMarkButton.disabled = !tools.yearMark;
},
redraw: drawTop,
redrawBase: draw,
+14 -17
View File
@@ -7,6 +7,7 @@
import { groundSlopePct, segmentLength } from "./logic/D03_PlanAnalysis_Draw";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import { strokeCased } from "./D03_PlanAnalysis_UI_DrawPaint";
export interface PlanMeasure {
layer: PlanMapLayer;
@@ -40,25 +41,23 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb
if (!from) return;
const colors = planColors();
const [ax, ay] = map.toScreen(from);
context.lineWidth = 2;
context.strokeStyle = colors.active;
context.fillStyle = colors.active;
context.setLineDash([6, 4]);
const dot = (x: number, y: number): void => {
context.beginPath();
context.arc(x, y, 4.5, 0, Math.PI * 2);
context.fillStyle = colors.measure;
context.fill();
context.lineWidth = 1.5;
context.strokeStyle = colors.casing;
context.stroke();
};
if (to) {
const [bx, by] = map.toScreen(to);
context.beginPath();
context.moveTo(ax, ay);
context.lineTo(bx, by);
context.stroke();
context.setLineDash([]);
for (const [x, y] of [
[ax, ay],
[bx, by],
]) {
context.beginPath();
context.arc(x, y, 4, 0, Math.PI * 2);
context.fill();
}
strokeCased(context, colors.measure, 2.5, [6, 4]);
dot(ax, ay);
dot(bx, by);
const { lengthM, slopePct, riseM } = measure(from, to);
const text =
`${fmt(lengthM)} m · ` +
@@ -75,9 +74,7 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb
context.fillText(text, mx, my);
});
} else {
context.beginPath();
context.arc(ax, ay, 4, 0, Math.PI * 2);
context.fill();
dot(ax, ay);
}
},
press(px, py, map, button) {
+6 -4
View File
@@ -57,8 +57,8 @@ import {
} from "./D03_PlanAnalysis_UI_Map";
import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure";
import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points";
import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces";
import { createPlanProps } from "./D03_PlanAnalysis_UI_Props";
import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw";
import {
emptyNetwork,
fromServer,
@@ -146,6 +146,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
yearStart: () => yearStart,
commit,
});
const pieces = createPlanPieces({ network: () => network, edit });
const measure = createPlanMeasure(() => z);
const marks = createPlanPoints({
points: () => points,
@@ -219,7 +220,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const drawCard = createPanelCard("그리기", false);
drawCard.append(draw.panel);
const pieceCard = createPanelCard("구역 · 연도", false);
pieceCard.append(props.list);
pieceCard.append(pieces.list);
const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() });
const resetButton = createButton({
label: "초기화",
@@ -337,6 +338,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
});
map.redraw();
props.render();
pieces.render();
marks.render();
retryButton.hidden = !editable || busy || !failed;
roadButton.hidden = !editable;
@@ -492,10 +494,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
.map((line, index) => ({
name: names[index],
line: line.vertices,
stations: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
branch: line.branch !== null,
}))
.filter((item) => item.line.length > 1);
openPlan3d(projectId, surface, lines);
openPlan3d(projectId, surface, lines, PLAN_STATION_INTERVAL_M);
}
// 검증 창구 — 꼴 · 미리보기 · 제약 · 화면 자리 · 재기 · 회전 · 저장 시간(ORCA 판정)
@@ -0,0 +1,105 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Pieces.ts
* D03 좌측 「구역 · 연도」 목록 몸(PLAN 597) — 해 줄(같은 해) · 선 머리 줄(선) · 구간 줄(구간).
* 속성 판(`UI_Props.ts`)에서 떼어냄 — 목록 = 보기 + 고르기.
* ========================================================================== */
import { createText } from "@ui/ui_template_text";
import { stationLabel } from "../common_util/common_util_station";
import { lineLength } from "./logic/D03_PlanAnalysis_Draw";
import { lineNames, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan";
import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit";
import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1);
const meters = (m: number): string => `${m.toFixed(1)} m`;
export interface PlanPiecesParams {
network: () => PlanNetwork;
edit: PlanEdit;
}
export interface PlanPieces {
/** 좌측 「구역 · 연도」 목록 몸. */
list: HTMLElement;
render: () => void;
}
/** 좌측 줄 — 누름 = 고름(div · 열쇠 Enter 도). */
const pickRow = (row: HTMLElement, onPick: () => void): HTMLElement => {
row.tabIndex = 0;
row.setAttribute("role", "button");
row.addEventListener("click", onPick);
row.addEventListener("keydown", (event) => event.key === "Enter" && onPick());
return row;
};
export function createPlanPieces(params: PlanPiecesParams): PlanPieces {
const { edit } = params;
const list = document.createElement("div");
list.className = "d03-plan__pieces";
function render(): void {
const network = params.network();
const names = lineNames(network.lines);
const picked = new Set(edit.selected()?.pieces ?? []);
const scope = edit.selected()?.scope;
const rows: HTMLElement[] = [];
const byYear = new Map<number, typeof network.pieces>();
for (const piece of network.pieces)
byYear.set(piece.year, [...(byYear.get(piece.year) ?? []), piece]);
for (const [year, pieces] of [...byYear].sort(([a], [b]) => a - b)) {
const row = document.createElement("div");
row.className = "d03-plan__piece d03-plan__piece-year";
row.dataset.year = String(year);
row.classList.toggle(
"is-selected",
scope === "year" && pieces.every((p) => picked.has(p.id)),
);
const length = pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0);
row.append(createText(`${year}년 · 구간 ${pieces.length} · ${meters(length)}`, "muted"));
rows.push(
pickRow(row, () => {
edit.select([pieces[0].id]);
edit.setScope("year");
}),
);
}
network.lines.forEach((line, index) => {
const pieces = network.pieces.filter((piece) => piece.line === line.id);
if (!pieces.length) return;
const head = document.createElement("div");
head.className = "d03-plan__piece d03-plan__piece-head";
head.dataset.line = line.id;
head.classList.toggle(
"is-selected",
scope === "line" && pieces.every((p) => picked.has(p.id)),
);
head.append(createText(`${names[index]} · ${meters(lineLength(line.vertices))}`, "muted"));
rows.push(
pickRow(head, () => {
edit.select([pieces[0].id]);
edit.setScope("line");
}),
);
for (const piece of pieces) {
const row = document.createElement("div");
row.className = "d03-plan__piece";
row.classList.toggle("is-selected", picked.has(piece.id));
row.dataset.piece = piece.id;
row.append(
createText(
`${station(piece.from_m)} ~ ${station(piece.to_m)} (${meters(piece.to_m - piece.from_m)})`,
"muted",
),
createText(`${piece.year}년`, "muted"),
);
rows.push(pickRow(row, () => edit.select([piece.id])));
}
});
if (!rows.length) rows.push(createText("선을 그으면 구역이 생깁니다.", "muted"));
list.replaceChildren(...rows);
}
return { list, render };
}
+2 -81
View File
@@ -8,16 +8,14 @@
* [방향 바꾸기] [갈래 시점 지정] [선 지움](선 하나일 때) · [마크 지움](마크 고름) · 보기만 계정 = 값만.
* 측점 방향(585-3 · 요구 446 3) = 사용자가 정함 — 본선 = [방향 바꾸기] · 다른 선에 닿은 끝 = [갈래 시점 지정]
* (그 점이 시점 · 꼬리에 닿은 머리 = 한 선으로 이음) · 자동 판정 없음.
* 좌측 목록 = 보기 + 고르기만(해 고침은 속성 판 한 곳) — 해 줄(같은 해) · 선 머리 줄(선) · 구간 줄(구간).
* 좌측 「구역 · 연도」 목록 = `UI_Pieces.ts`(페이지가 붙임).
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { createSelectField } from "@ui/ui_template_input";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import { stationLabel } from "../common_util/common_util_station";
import { lineLength } from "./logic/D03_PlanAnalysis_Draw";
import { lineNames, setPiecesYear, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan";
import type { PlanEdit, PlanScope } from "./D03_PlanAnalysis_UI_Edit";
import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
@@ -39,9 +37,7 @@ export interface PlanPropsParams {
export interface PlanProps {
/** 지도 위 속성 판(지도 칸에 붙임). */
panel: HTMLElement;
/** 좌측 「구역 · 연도」 목록 몸. */
list: HTMLElement;
/** 고름 · 꼴이 바뀜 — 판 · 목록 다시. */
/** 고름 · 꼴이 바뀜 — 판 다시. */
render: () => void;
/** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */
yearOptions: () => Array<{ value: string; text: string }>;
@@ -57,8 +53,6 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
float.root.classList.add("d03-plan__props");
float.root.dataset.role = "plan-props";
float.root.hidden = true;
const list = document.createElement("div");
list.className = "d03-plan__pieces";
function yearOptions(): Array<{ value: string; text: string }> {
const start = params.yearStart();
@@ -167,83 +161,10 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
float.body.replaceChildren(scope.root, year.root, rows, actions);
}
/** 좌측 줄 — 누름 = 고름(div · 열쇠 Enter 도). */
const pickRow = (row: HTMLElement, onPick: () => void): HTMLElement => {
row.tabIndex = 0;
row.setAttribute("role", "button");
row.addEventListener("click", onPick);
row.addEventListener("keydown", (event) => event.key === "Enter" && onPick());
return row;
};
function renderList(): void {
const network = params.network();
const names = lineNames(network.lines);
const picked = new Set(edit.selected()?.pieces ?? []);
const scope = edit.selected()?.scope;
const rows: HTMLElement[] = [];
const byYear = new Map<number, typeof network.pieces>();
for (const piece of network.pieces)
byYear.set(piece.year, [...(byYear.get(piece.year) ?? []), piece]);
for (const [year, pieces] of [...byYear].sort(([a], [b]) => a - b)) {
const row = document.createElement("div");
row.className = "d03-plan__piece d03-plan__piece-year";
row.dataset.year = String(year);
row.classList.toggle(
"is-selected",
scope === "year" && pieces.every((p) => picked.has(p.id)),
);
const length = pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0);
row.append(createText(`${year}년 · 구간 ${pieces.length} · ${meters(length)}`, "muted"));
rows.push(
pickRow(row, () => {
edit.select([pieces[0].id]);
edit.setScope("year");
}),
);
}
network.lines.forEach((line, index) => {
const pieces = network.pieces.filter((piece) => piece.line === line.id);
if (!pieces.length) return;
const head = document.createElement("div");
head.className = "d03-plan__piece d03-plan__piece-head";
head.dataset.line = line.id;
head.classList.toggle(
"is-selected",
scope === "line" && pieces.every((p) => picked.has(p.id)),
);
head.append(createText(`${names[index]} · ${meters(lineLength(line.vertices))}`, "muted"));
rows.push(
pickRow(head, () => {
edit.select([pieces[0].id]);
edit.setScope("line");
}),
);
for (const piece of pieces) {
const row = document.createElement("div");
row.className = "d03-plan__piece";
row.classList.toggle("is-selected", picked.has(piece.id));
row.dataset.piece = piece.id;
row.append(
createText(
`${station(piece.from_m)} ~ ${station(piece.to_m)} (${meters(piece.to_m - piece.from_m)})`,
"muted",
),
createText(`${piece.year}년`, "muted"),
);
rows.push(pickRow(row, () => edit.select([piece.id])));
}
});
if (!rows.length) rows.push(createText("선을 그으면 구역이 생깁니다.", "muted"));
list.replaceChildren(...rows);
}
return {
panel: float.root,
list,
render() {
renderPanel();
renderList();
},
yearOptions,
};
@@ -195,3 +195,21 @@
.d03-plan__piece-year {
font-weight: 600;
}
/* 599 그리기 칸 — 방식 칸(마디 고름 2 × 2 · 길이 · 기울기 두 열) */
.d03-plan__draw-kind {
display: flex;
flex-direction: column;
gap: 6px;
}
.d03-plan__draw-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-8);
align-items: start;
}
.d03-plan__draw-fields .ui-field {
margin: 0;
}
@@ -0,0 +1,162 @@
/* =============================================================================
* D03_PlanAnalysis_Expect.ts
* D03 예상 횡단(PLAN 544-1 · 601-1 · 요구 363-1 · 463-21) — 측점 지반 → 표준 횡단 기본값 설계선 · 절 · 성토 ㎡.
* 셈 = 공용 `computeCrossDesign`(① 짝 `compute_cross_design` · 거울 `test_b06_cross_design_mirror.py`)
* · 기본 = B 초기 설계와 같음(리핑암 · 산쪽 절토 · 일반 측구 · 암 경계 = 서버 기본) · 계획고 = 측점 중심 지반고
* · 미교차 = `extendUnclosed`(격자 있을 때) · 곡선 확폭 없음 · 보기만(저장 없음).
* 3D = 같은 단면을 B05 `buildCorridor` 가 읽는 측점 꼴로(`expectSections`) · 노선망 전체 = 선마다(`expectLine`) ·
* 갈래 머리 = 부모 노면(차도 반 + 노견)만큼 잘라 냄(두 노면 겹침 막음).
* 시험 `resources/tester/test_544_1_plan_expect.py`.
* ========================================================================== */
import {
computeCrossDesign,
type CrossDesignOptions,
type CrossDesignResult,
type StandardCrossSectionSpec,
} from "../../common_util/common_util_cross_design";
import type { DtmGrid } from "../../common_util/common_util_surface_sampler";
import {
extendUnclosed,
type CrossGroundPoint,
type StationGround,
} from "../../B05_Profile/logic/B05_Profile_Sections_Ground";
import { lineLength, sliceLine, stationChainages, type Vertex } from "./D03_PlanAnalysis_Draw";
/** 서버 `sections/context` 의 표준 횡단(config) · 암 경계 기본 오프셋. */
export interface ExpectStandard {
standard: StandardCrossSectionSpec;
rockBoundaryM: number | null;
}
export interface ExpectCross {
design: CrossDesignResult;
/** 설계에 쓴 지반 표본(미교차로 넓혔으면 넓힌 것). */
samples: CrossGroundPoint[];
}
/** B 초기 설계 기본 — 리핑암 · 산쪽 절토(모르면 좌) · 일반 측구. */
export function expectOptions(
uphill: "left" | "right" | null,
std: ExpectStandard,
): CrossDesignOptions {
return {
groundType: "ripping_rock",
sectionMode: `${uphill ?? "left"}_cut`,
ditchType: "standard",
standard: std.standard,
rockBoundaryOffsetM: std.rockBoundaryM,
};
}
/** 측점 하나 예상 단면 — 지반고 · 표본이 없으면 null. */
export function expectCross(
station: StationGround,
std: ExpectStandard,
grid?: DtmGrid | null,
): ExpectCross | null {
const elevation = station.center_z;
if (elevation === null) return null;
const options = expectOptions(station.uphill_side, std);
try {
let samples = station.samples;
let design = computeCrossDesign(samples, elevation, options);
const wide =
design.slope_unclosed && grid
? extendUnclosed(
samples,
[station.center_x, station.center_y],
station.left_xy,
(next) => computeCrossDesign(next, elevation, options),
grid,
)
: null;
if (wide) {
samples = wide.samples as CrossGroundPoint[];
design = computeCrossDesign(samples, elevation, options);
}
return { design, samples };
} catch {
return null; // 표본 부족 — B 와 같은 태도(그 측점 건너뜀)
}
}
/** B06 `CrossSection` 중 `buildCorridor` 가 읽는 칸(B06 형 파일은 별칭 import 라 여기 안 끌어옴). */
export interface ExpectSection {
station_id: string;
chainage_m: number;
label: string;
kind: "regular";
center_z: number | null;
azimuth_deg: null;
center_x: number;
center_y: number;
uphill_side: "left" | "right" | null;
frame: { left_xy: [number, number] };
samples: CrossGroundPoint[];
design: CrossDesignResult;
}
/** 3D 코리도 입력 — 측점마다 예상 단면(못 세운 측점 뺌) · 누가 = 연도 노선 위 누가. */
export function expectSections(
stations: StationGround[],
std: ExpectStandard,
grid?: DtmGrid | null,
): ExpectSection[] {
return stations.flatMap((station) => {
const cross = expectCross(station, std, grid);
if (!cross) return [];
return [
{
station_id: String(station.chainage_m),
chainage_m: station.chainage_m,
label: "",
kind: "regular",
center_z: station.center_z,
azimuth_deg: null,
center_x: station.center_x,
center_y: station.center_y,
uphill_side: station.uphill_side,
frame: { left_xy: station.left_xy },
samples: cross.samples,
design: cross.design,
},
];
});
}
/** 갈래 머리 잘라 낼 길이(m) — 리핑암 표준 차도 반 + 넓은 노견(부모 노면 안). */
export function branchTrimM(std: ExpectStandard): number {
const rock = std.standard.rock;
if (!rock) return 0;
return (
(rock.road_width_m ?? 0) / 2 + Math.max(rock.shoulder_left_m ?? 0, rock.shoulder_right_m ?? 0)
);
}
/** 3D 한 선 — 그릴 꺾은선 · 그 위 측점(꺾은선 첫 점 = 0) · 원래 누가 = 측점 + `offsetM`. */
export interface ExpectLine {
line: Vertex[];
offsetM: number;
stations: number[];
}
/** 선 하나 3D 입력 — 측점 = 원래 누가(`intervalM`) · `trimM` > 0(갈래)이면 머리를 잘라 낸 자리부터.
* 남는 길이가 1 m 안 되면 null. */
export function expectLine(
vertices: ReadonlyArray<Vertex>,
trimM: number,
intervalM: number,
): ExpectLine | null {
const length = lineLength(vertices);
const offsetM = Math.max(0, trimM);
if (vertices.length < 2 || length - offsetM < 1) return null;
const line = offsetM > 0 ? sliceLine(vertices, offsetM, length) : [...vertices];
const stations = [
0,
...stationChainages(length, intervalM)
.filter((m) => m > offsetM + 1e-6)
.map((m) => m - offsetM),
];
return { line, offsetM, stations };
}
@@ -2,7 +2,7 @@
* D03_PlanAnalysis_Plan.ts
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 ·
* 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김 · 여러 조각 연도 · 끌어 고르기 판정(585-2) ·
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3).
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1).
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음.
* ========================================================================== */
@@ -11,12 +11,38 @@ import { lineLength, segmentLength, sliceLine } from "./D03_PlanAnalysis_Draw";
export type Vertex = [number, number];
/** 노드 긋기 방식(594-1) — 그리기 칸 방식 값 그대로. */
export type NodeKind = "free" | "length" | "slope" | "both";
/** 노드 기록(594-1) — 그 노드를 찍을 때 방식 · 길이(m) · 기울기(%) · 안 쓴 값 = null. */
export interface NodeRecord {
kind: NodeKind;
length_m: number | null;
slope_pct: number | null;
}
/** 그은 선 하나 — `branch` 없음 = 본선(시점 · 종점에서) · 있음 = 갈래(붙은 선 · 꺾임점 차례) ·
* 꼭짓점 차례 = 측점 차례(첫 점 = No.0). */
* 꼭짓점 차례 = 측점 차례(첫 점 = No.0) · `name` 없음 = 차례 이름(`lineNames`) ·
* `nodes` = 꼭짓점마다 기록(같은 길이 · 칸 없음 = 기록 없음 · 594-1). */
export interface PlanLine {
id: string;
vertices: Vertex[];
branch: { line: string; vertex: number } | null;
name?: string | null;
nodes?: Array<NodeRecord | null> | null;
}
/** 지점 · 선 이름 한도(594-1). */
export const NAME_MAX = 20;
const NAME_BAD = /[\\/:*?"<>|\u0000-\u001f]/;
/** 이름 틀림 까닭 글 · 맞으면 null(앞뒤 빈칸 뺀 값으로 봄).
* ⚠ 짝 = `D03_PlanAnalysis_Network.name_error`(서버 400) — 거울 시험 530-3. */
export function nameError(name: string): string | null {
const text = name.trim();
if (text.length > NAME_MAX) return `이름은 ${NAME_MAX} 자까지입니다.`;
if (NAME_BAD.test(text)) return '이름에 \\ / : * ? " < > | 를 쓸 수 없습니다.';
return null;
}
/** 조각 = 선(id) 위 [from_m, to_m] 구역 · 연도(구역 마크 = 조각 경계 · id = 업체 지정이 붙는 열쇠라 나눠도 앞 조각이 지님). */
@@ -262,15 +288,17 @@ export function marksOf(network: PlanNetwork): Array<{ line: string; at_m: numbe
.map((piece) => ({ line: piece.line, at_m: piece.from_m }));
}
/** 선 이름 — 갈래 없는 선 = 본선(둘 이상이면 본선 1 · 2 …) · 갈래 = 갈래 1 · 2 …(선 차례).
/** 선 이름 — 붙인 이름(594-1) · 없으면 차례 이름: 갈래 없는 선 = 본선(둘 이상이면 본선 1 · 2 …) ·
* 갈래 = 갈래 1 · 2 …(선 차례 · 이름 붙은 선도 수를 셈).
* ⚠ 짝 = `D04_PlanConfirm_Repository.line_names`(조각 · 내보내기 이름) — 고치면 같이(거울 시험 530-3). */
export function lineNames(lines: ReadonlyArray<Pick<PlanLine, "branch">>): string[] {
export function lineNames(lines: ReadonlyArray<Pick<PlanLine, "branch" | "name">>): string[] {
const roots = lines.filter((line) => !line.branch).length;
let root = 0;
let branch = 0;
return lines.map((line) =>
line.branch ? `갈래 ${++branch}` : roots > 1 ? `본선 ${++root}` : "본선",
);
return lines.map((line) => {
const auto = line.branch ? `갈래 ${++branch}` : roots > 1 ? `본선 ${++root}` : "본선";
return line.name?.trim() || auto;
});
}
/** 선 차례 — 갈래는 부모 뒤(서버 셈이 앞에서부터 부모를 찾음) · 이미 맞으면 그대로. */