feat(B02/B03/B04/B05): 계획노선 사용 범위 · 절단 여유 3m · 배수유역도 줌·측점 표기

- 계획노선 사용 범위: B02 등록에 시작·종료 누가거리 두 칸 추가, B01 수정 모달에서도
  변경. projects.route_start_m·route_end_m 신설(015_route_range.sql).
  load_design_route 가 범위 절단 → 서피스 트림 순서로 적용. 시작 >= 종료는 화면·서버
  양쪽에서 차단. 비우면 전 구간으로 종전과 같음.
- 서피스 절단 여유 기본값 30m → 3m (SURFACE_ROUTE_EDGE_TRIM_M).
- B04 지도·B05 배수유역도 줌 상한을 「화면 폭 20m」 기준으로 계산(고정 8배·16배 폐지).
  4배를 넘으면 배경 그림 흐림 보간 해제.
- 계획선 위 측점 눈금·번호 표기(측점번호+잔여거리). 관 마커와 겹치면 반대쪽으로 밀고,
  되꺾임 구간에서 라벨이 겹치면 건너뜀. 그리기 코드는 두 화면 공용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:08:11 +09:00
co-authored by Claude Opus 5
parent cc15a83f67
commit 4e4bfa2354
20 changed files with 412 additions and 36 deletions
+6
View File
@@ -43,6 +43,9 @@ export interface ProjectItem {
road_type?: string | null;
project_year?: number | null;
estimated_length_m?: number | null;
/** 계획노선 사용 범위 (2026-09-04 사용자 지시) — 비우면 전 구간. */
route_start_m?: number | null;
route_end_m?: number | null;
memo?: string | null;
status?: string | null;
/** 도면 표제란·표지에 실리는 값 — 프로그램이 지어낼 수 없어 사람이 넣는다 */
@@ -154,6 +157,9 @@ export interface UpdateProjectRequest {
road_type?: string | null;
project_year?: number | null;
estimated_length_m?: number | null;
/** 계획노선 사용 범위 (2026-09-04 사용자 지시) — 비우면 전 구간. */
route_start_m?: number | null;
route_end_m?: number | null;
memo?: string | null;
status?: string | null;
client_org?: string | null;
+11 -5
View File
@@ -158,7 +158,8 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]:
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT id, company_id, name, region, road_type, project_year,
estimated_length_m, memo, status, updated_at, created_at,
estimated_length_m, route_start_m, route_end_m,
memo, status, updated_at, created_at,
client_org, project_number, work_amount, design_date,
pm_user_id, field_lead_user_id, designer_user_id,
logo_asset_id, signature_asset_id
@@ -174,7 +175,8 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]:
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year,
p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at,
p.estimated_length_m, p.route_start_m, p.route_end_m,
p.memo, p.status, p.updated_at, p.created_at,
p.client_org, p.project_number, p.work_amount, p.design_date,
p.pm_user_id, p.field_lead_user_id, p.designer_user_id,
p.logo_asset_id, p.signature_asset_id,
@@ -192,7 +194,8 @@ async def list_all_projects() -> list[dict[str, Any]]:
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year,
p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at,
p.estimated_length_m, p.route_start_m, p.route_end_m,
p.memo, p.status, p.updated_at, p.created_at,
p.client_org, p.project_number, p.work_amount, p.design_date,
p.pm_user_id, p.field_lead_user_id, p.designer_user_id,
p.logo_asset_id, p.signature_asset_id,
@@ -209,7 +212,7 @@ async def get_project(project_id: str) -> dict[str, Any] | None:
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT id, user_id, company_id, name, region, road_type, project_year,
estimated_length_m, memo, status,
estimated_length_m, route_start_m, route_end_m, memo, status,
client_org, project_number, work_amount, design_date,
pm_user_id, field_lead_user_id, designer_user_id,
logo_asset_id, signature_asset_id
@@ -226,7 +229,8 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
await cursor.execute(
"""UPDATE projects
SET name = %s, region = %s, road_type = %s, project_year = %s,
estimated_length_m = %s, memo = %s, status = COALESCE(%s, status),
estimated_length_m = %s, route_start_m = %s, route_end_m = %s,
memo = %s, status = COALESCE(%s, status),
client_org = %s, project_number = %s, work_amount = %s,
design_date = %s, pm_user_id = %s, field_lead_user_id = %s,
designer_user_id = %s, logo_asset_id = %s, signature_asset_id = %s
@@ -237,6 +241,8 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
data.get("road_type"),
data.get("project_year"),
data.get("estimated_length_m"),
data.get("route_start_m"),
data.get("route_end_m"),
data.get("memo"),
data.get("status"),
data.get("client_org"),
+7
View File
@@ -264,6 +264,13 @@ async def dashboard_update_project(
if not _can_edit_project(session, project):
raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.")
data = payload.model_dump()
# 시작이 종료보다 뒤면 남는 구간이 없다 — B02 등록과 같은 규칙 (2026-09-04 사용자 지시).
start_m, end_m = data.get("route_start_m"), data.get("route_end_m")
if start_m is not None and end_m is not None and start_m >= end_m:
raise HTTPException(
status_code=400,
detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다.",
)
await check_project_refs(int(project["company_id"]), data)
if not await update_project(project_id, data, int(session["user_id"])):
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
+3
View File
@@ -56,6 +56,9 @@ class UpdateProjectRequest(BaseModel):
road_type: str | None = Field(default=None, max_length=100)
project_year: int | None = Field(default=None, ge=1900, le=2100)
estimated_length_m: float | None = Field(default=None, ge=0)
# 계획노선 사용 범위 (2026-09-04 사용자 지시) — 비우면 전 구간.
route_start_m: float | None = Field(default=None, ge=0)
route_end_m: float | None = Field(default=None, ge=0)
memo: str | None = Field(default=None, max_length=5000)
status: str | None = Field(default=None, max_length=50)
# 도면 표제란·표지에 실리는 값 (2026-09-02). 프로그램이 지어낼 수 없어 사람이 넣는다.
+19
View File
@@ -124,6 +124,19 @@ export async function openEditProjectModal(
type: "number",
value: String(project.estimated_length_m ?? ""),
});
// 계획노선 사용 범위 — 등록(B02)에서 받은 값을 여기서도 고친다 (2026-09-04 사용자 지시).
const routeStart = createInputField({
label: "노선 시작 누가거리 (m)",
type: "number",
value: project.route_start_m == null ? "" : String(project.route_start_m),
placeholder: "비우면 처음부터",
});
const routeEnd = createInputField({
label: "노선 종료 누가거리 (m)",
type: "number",
value: project.route_end_m == null ? "" : String(project.route_end_m),
placeholder: "비우면 끝까지",
});
const memo = createInputField({ label: "비고", value: project.memo ?? "" });
// 도면 표제란·표지에 그대로 실리는 값 — 프로그램이 지어낼 수 없어 여기서 받는다.
const clientOrg = createInputField({
@@ -209,6 +222,8 @@ export async function openEditProjectModal(
roadType.input.disabled = true;
year.input.disabled = true;
length.input.disabled = true;
routeStart.input.disabled = true;
routeEnd.input.disabled = true;
memo.input.disabled = true;
clientOrg.input.disabled = true;
projectNumber.input.disabled = true;
@@ -228,6 +243,8 @@ export async function openEditProjectModal(
roadType.root,
year.root,
length.root,
routeStart.root,
routeEnd.root,
memo.root,
clientOrg.root,
projectNumber.root,
@@ -253,6 +270,8 @@ export async function openEditProjectModal(
road_type: roadType.input.value.trim() || null,
project_year: year.input.value ? Number(year.input.value) : null,
estimated_length_m: length.input.value ? Number(length.input.value) : null,
route_start_m: routeStart.input.value ? Number(routeStart.input.value) : null,
route_end_m: routeEnd.input.value ? Number(routeEnd.input.value) : null,
memo: memo.input.value.trim() || null,
status: project.status,
client_org: clientOrg.input.value.trim() || null,
@@ -68,12 +68,13 @@ async def create_project(
"""
INSERT INTO projects (
id, user_id, company_id, name, region, road_type,
project_year, estimated_length_m, memo, status,
project_year, estimated_length_m, route_start_m, route_end_m,
memo, status,
crs_epsg, storage_path, created_at, updated_at,
client_org, project_number, work_amount, design_date,
pm_user_id, field_lead_user_id, designer_user_id, logo_asset_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s,
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s)
""",
(
@@ -85,6 +86,8 @@ async def create_project(
road_type,
project_year,
estimated_length_m,
fields.get("route_start_m"),
fields.get("route_end_m"),
memo,
storage_path,
now,
@@ -116,7 +119,8 @@ async def create_project(
await cursor.execute(
"""
SELECT id AS project_id, name, region, road_type, project_year,
estimated_length_m, memo, status, storage_path,
estimated_length_m, route_start_m, route_end_m,
memo, status, storage_path,
DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS created_at
FROM projects
WHERE id = %s
@@ -138,7 +142,8 @@ async def get_project_by_id(project_id: str) -> dict[str, Any] | None:
await cursor.execute(
"""
SELECT id AS project_id, user_id, company_id, name, region, road_type,
project_year, estimated_length_m, memo, status, storage_path,
project_year, estimated_length_m, route_start_m, route_end_m,
memo, status, storage_path,
DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS created_at
FROM projects
WHERE id = %s AND deleted_at IS NULL
@@ -34,8 +34,17 @@ async def post_project(
"field_lead_user_id",
"designer_user_id",
"logo_asset_id",
"route_start_m",
"route_end_m",
}
)
# 시작이 종료보다 뒤면 남는 구간이 없다 — 저장 전에 막는다 (2026-09-04 사용자 지시).
start_m, end_m = payload.route_start_m, payload.route_end_m
if start_m is not None and end_m is not None and start_m >= end_m:
raise HTTPException(
status_code=400,
detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다.",
)
# 담당자·로고는 같은 회사 것만 (B01 프로젝트 수정과 같은 규칙).
await check_project_refs(int(company_id), title_block)
@@ -15,6 +15,10 @@ class CreateProjectRequest(BaseModel):
road_type: str = Field(..., pattern="^(main|fire|work)$")
project_year: int = Field(..., ge=2000, le=2100)
estimated_length_m: float | None = Field(default=None, ge=0)
# 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (2026-09-04 사용자 지시).
# 둘 다 비우면 전 구간. 시작 >= 종료 는 라우터에서 막는다.
route_start_m: float | None = Field(default=None, ge=0)
route_end_m: float | None = Field(default=None, ge=0)
memo: str | None = Field(default=None, max_length=1000)
# 도면 표제란·표지 값 — 등록 때부터 받는다 (2026-09-02 사용자 지시).
# 프로젝트 수정 모달(B01)과 같은 칸이며, 비워 두면 도면에 빈칸으로 나간다.
@@ -37,6 +41,8 @@ class CreateProjectResponse(BaseModel):
road_type: str | None
project_year: int | None
estimated_length_m: float | None
route_start_m: float | None = None
route_end_m: float | None = None
memo: str | None
status: str
storage_path: str
@@ -90,6 +90,20 @@ export function renderB02ProjRegister(root: HTMLElement): void {
type: "number",
min: 0,
});
// 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (2026-09-04 사용자 지시).
// 둘 다 비우면 전 구간을 쓴다.
const routeStartField = createInputField({
label: "노선 시작 누가거리 (m)",
placeholder: "비우면 처음부터",
type: "number",
min: 0,
});
const routeEndField = createInputField({
label: "노선 종료 누가거리 (m)",
placeholder: "비우면 끝까지",
type: "number",
min: 0,
});
const memoField = createInputField({
label: L("B02_Proj_Field_Memo"),
placeholder: L("B02_Proj_Field_Memo_Placeholder"),
@@ -189,6 +203,8 @@ export function renderB02ProjRegister(root: HTMLElement): void {
regionField.setError();
yearField.setError();
lengthField.setError();
routeStartField.setError();
routeEndField.setError();
// 1차 유효성: 필수값 검사
let hasError = false;
@@ -212,6 +228,25 @@ export function renderB02ProjRegister(root: HTMLElement): void {
lengthField.setError(L("Common_Validation_NumberRange"));
hasError = true;
}
const routeStart = isBlank(routeStartField.input.value)
? null
: Number.parseFloat(routeStartField.input.value);
const routeEnd = isBlank(routeEndField.input.value)
? null
: Number.parseFloat(routeEndField.input.value);
if (routeStart !== null && (!Number.isFinite(routeStart) || routeStart < 0)) {
routeStartField.setError(L("Common_Validation_NumberRange"));
hasError = true;
}
if (routeEnd !== null && (!Number.isFinite(routeEnd) || routeEnd < 0)) {
routeEndField.setError(L("Common_Validation_NumberRange"));
hasError = true;
}
// 시작이 종료보다 뒤면 남는 구간이 없다 — 서버도 같은 규칙으로 막는다.
if (routeStart !== null && routeEnd !== null && routeStart >= routeEnd) {
routeEndField.setError("종료 누가거리는 시작보다 커야 합니다.");
hasError = true;
}
if (hasError) return;
showLoadingOverlay();
@@ -226,6 +261,8 @@ export function renderB02ProjRegister(root: HTMLElement): void {
road_type: roadTypeField.select.value,
project_year: projectYear,
estimated_length_m: estimatedLength,
route_start_m: routeStart,
route_end_m: routeEnd,
memo: memoField.input.value.trim() || null,
client_org: clientOrgField.input.value.trim() || null,
project_number: projectNumberField.input.value.trim() || null,
@@ -269,6 +306,8 @@ export function renderB02ProjRegister(root: HTMLElement): void {
roadTypeField.root,
yearField.root,
lengthField.root,
routeStartField.root,
routeEndField.root,
memoField.root,
clientOrgField.root,
projectNumberField.root,
+16 -4
View File
@@ -26,16 +26,19 @@ logger = logging.getLogger(__name__)
def _planned_route_points_in_project_crs(
project_root: Path, surface: dict[str, Any] | None = None
project_root: Path,
surface: dict[str, Any] | None = None,
route_range: tuple[float | None, float | None] | None = None,
) -> list[dict[str, float]] | None:
"""설계용 계획노선을 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None.
읽기·좌표계 변환·트림·조밀화는 `load_design_route()` 곳에서 한다 배수유역·유입도
같은 함수를 쓰므로 여기만 트림되는 일이 없다.
같은 함수를 쓰므로 여기만 트림되는 일이 없다. 사용자가 정한 사용 범위(`route_range`)
서피스 트림보다 먼저 적용된다 (2026-09-04 사용자 지시).
"""
from common_util.common_util_route_geometry import load_design_route
planned = load_design_route(project_root, surface)
planned = load_design_route(project_root, surface, route_range)
if planned is None:
if surface:
logger.warning(
@@ -176,8 +179,17 @@ async def run_auto_design_chain(
# 끊긴다(2026-08-30 실사고). 노선 트림도 이 지표면을 기준으로 한다.
async with pool.acquire() as connection:
defaults = await get_surface_confirmation_params(connection, str(project_id))
# 사용자가 B02·B01 에서 정한 계획노선 사용 범위 (2026-09-04 사용자 지시).
# 비어 있으면 전 구간 — 지금까지와 같다.
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT route_start_m, route_end_m FROM projects WHERE id = %s",
(str(project_id),),
)
range_row = await cursor.fetchone()
route_range = (range_row[0], range_row[1]) if range_row else None
points = _planned_route_points_in_project_crs(project_root, defaults)
points = _planned_route_points_in_project_crs(project_root, defaults, route_range)
if not points:
logger.warning("자동 설계 체인 중단(계획노선 없음): project_id=%s", project_id)
mark_design_failed(project_root, "계획노선이 없어 초기 노선을 세울 수 없습니다.")
@@ -7,6 +7,7 @@
* ========================================================================== */
import { themeColor } from "@ui/ui_template_palette";
import { stationLabel } from "@util/common_util_svg";
/** 상류 세류망 강조선 색. 정의처는 `ui_template_theme.css`(`--map-upstream`). */
const upstreamLineColor = (): string => themeColor("--map-upstream", "rgba(29, 78, 216, 0.95)");
@@ -184,3 +185,100 @@ export function drawRidgeRing(
context.stroke();
context.restore();
}
/* -----------------------------------------------------------------------------
* · (2026-09-04 )
*
* ·3D와 `측점번호+잔여거리` . 3D
* (5 2 ). ****
* . B04 B05 .
* -------------------------------------------------------------------------- */
export interface StationTickOptions {
/** 규칙 측점 간격(m). */
intervalM: number;
/** 화면 1m 당 픽셀 — 라벨 솎기 단계를 여기서 정한다. */
pxPerMeter: number;
toScreen: (x: number, y: number) => [number, number];
/** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */
avoidChainages?: ReadonlyArray<number>;
}
export function drawStationTicks(
context: CanvasRenderingContext2D,
points: ReadonlyArray<{ x: number; y: number }>,
options: StationTickOptions,
): void {
if (points.length < 2) return;
const interval = options.intervalM > 0 ? options.intervalM : 20;
// 라벨 사이가 좁아지면 솎는다 — 화면에서 잰 간격(px)으로 정한다.
const gapPx = interval * options.pxPerMeter;
const step = gapPx >= 90 ? 1 : gapPx >= 40 ? 2 : 5;
const avoid = options.avoidChainages ?? [];
// 정점 누가거리 — 측점 자리는 정점 사이에 떨어지므로 보간해서 찍는다.
const cumulative: number[] = [0];
for (let index = 1; index < points.length; index += 1) {
cumulative.push(
cumulative[index - 1] +
Math.hypot(points[index].x - points[index - 1].x, points[index].y - points[index - 1].y),
);
}
const total = cumulative[cumulative.length - 1];
if (total <= 0) return;
context.save();
context.font = "11px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
// 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다 — 이미 그린 라벨과
// 겹치는 자리는 건너뛴다(2026-09-04 실측에서 4px 간격까지 붙었다).
const drawn: Array<{ x: number; y: number; half: number }> = [];
let cursor = 1;
for (let chainage = 0; chainage <= total; chainage += interval) {
const stationNo = Math.round(chainage / interval);
if (stationNo % step !== 0) continue;
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
const back = points[cursor - 1];
const front = points[cursor];
const segment = cumulative[cursor] - cumulative[cursor - 1] || 1;
const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment));
const px = back.x + (front.x - back.x) * ratio;
const py = back.y + (front.y - back.y) * ratio;
const [sx, sy] = options.toScreen(px, py);
const [bx, by] = options.toScreen(back.x, back.y);
const [fx, fy] = options.toScreen(front.x, front.y);
const dx = fx - bx;
const dy = fy - by;
const length = Math.hypot(dx, dy) || 1;
// 계획선에 직각인 방향 — 눈금과 라벨을 이 방향으로 놓는다.
const ux = -dy / length;
const uy = dx / length;
const nearPipe = avoid.some((pipe) => Math.abs(pipe - chainage) < interval / 2);
const side = nearPipe ? -1 : 1;
context.beginPath();
context.moveTo(sx - ux * 6, sy - uy * 6);
context.lineTo(sx + ux * 6, sy + uy * 6);
context.lineWidth = 1.2;
context.strokeStyle = "rgba(40, 40, 40, 0.85)";
context.stroke();
const label = stationLabel(chainage, interval);
const lx = sx + ux * side * 16;
const ly = sy + uy * side * 16;
const width = context.measureText(label).width + 6;
const half = width / 2;
const collides = drawn.some(
(item) => Math.abs(item.x - lx) < item.half + half && Math.abs(item.y - ly) < 16,
);
if (collides) continue;
drawn.push({ x: lx, y: ly, half });
// 배경을 깔아 등고선 위에서도 읽히게 한다.
context.fillStyle = "rgba(255, 255, 255, 0.78)";
context.fillRect(lx - half, ly - 8, width, 16);
context.fillStyle = "#222222";
context.fillText(label, lx, ly);
}
context.restore();
}
@@ -121,6 +121,59 @@ export function computeMapRect(meta: VWorldMeta | null, width: number, height: n
* (2026-08-01 지시: 도로 , + 200m까지). */
export const ROUTE_VIEW_MARGIN_M = 200;
/**
* (B04 ·B05 ).
*
*
* (2026-09-04). `pxPerMeter` · .
*/
export function createMetricProjector(
meta: VWorldMeta,
view: ViewState,
): { toScreen: (x: number, y: number) => [number, number]; pxPerMeter: number } {
const spanX = meta.width_meters || 1;
const spanY = meta.height_meters || 1;
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
return {
toScreen: (x, y) => [
((x - meta.x_min) / spanX) * ax + bx,
(1 - (y - meta.y_min) / spanY) * ay + by,
],
pxPerMeter: ax / spanX,
};
}
/** 규칙 측점 간격(m) — 종단 패널과 같은 20m 고정. 지도·배수유역도 눈금 표기 기준(2026-09-04). */
export const MAP_STATION_INTERVAL_M = 20;
/** (m) 20m 1~2
* (2026-09-04 ). (8·16) . */
export const MAX_ZOOM_VIEW_WIDTH_M = 20;
/** 배율 상한의 안전장치 — 도엽 메타가 이상해도 여기서 멈춘다. */
export const ZOOM_SCALE_HARD_CAP = 2000;
/**
* `MAX_ZOOM_VIEW_WIDTH_M` .
*
* 1 (`meta.width_meters`) (px) ,
* (px) = width_meters × viewportWidth / (mapRect.width × scale) .
* 20m scale . .
*/
export function computeMaxScale(
meta: VWorldMeta | null,
mapRectWidth: number,
viewportWidth: number,
fallback: number,
): number {
if (!meta || mapRectWidth <= 0 || viewportWidth <= 0) return fallback;
const scale = (meta.width_meters * viewportWidth) / (mapRectWidth * MAX_ZOOM_VIEW_WIDTH_M);
return Math.min(ZOOM_SCALE_HARD_CAP, Math.max(fallback, scale));
}
/** 평면 좌표(m) 범위. */
export interface PlanBounds {
x_min: number;
+31 -2
View File
@@ -26,7 +26,10 @@ import { createFlowStrengthOverlay } from "./B04_PreProcess_UI_FlowStrength";
import { createWatershedOverlay } from "./B04_PreProcess_UI_Watershed";
import {
computeMapRect,
computeMaxScale,
computeRouteView,
createMetricProjector,
MAP_STATION_INTERVAL_M,
createNormalizer,
drawPreparedLabels,
drawPreparedLayer,
@@ -41,6 +44,7 @@ import {
type PreparedLayer,
type ViewState,
} from "./B04_PreProcess_UI_MapRender";
import { drawStationTicks } from "./B04_PreProcess_UI_MapOverlays";
import type { WatershedAnalysis } from "./B04_PreProcess_Api_Fetch";
export interface SurfaceMapViewer {
@@ -160,8 +164,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
);
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS.filter((layer) => GIS_DEFAULT_ON[layer]));
let showContourLabels = CONTOUR_LABEL_DEFAULT_ON;
// 계획선(B03 업로드 계획노선) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다.
// 계획선(B03 계획노선 정본) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다.
let routeLayer: PreparedLayer | null = null;
// 측점 눈금·번호를 찍기 위한 원본 점 목록 (2026-09-04 사용자 지시).
let routePoints: ReadonlyArray<{ x: number; y: number }> = [];
let showRoute = true;
let scale = 1;
let offsetX = 0;
@@ -314,6 +320,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
function updateImageTransform(): void {
backgroundImages.forEach((image) => {
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
// 크게 당기면 배경 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다
// (2026-09-04 사용자 지시). 실제 크기는 축척 막대로 읽는다.
image.style.imageRendering = scale > 4 ? "pixelated" : "auto";
});
}
@@ -416,6 +425,15 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
context.strokeStyle = routeLineColor();
drawPreparedLayer(context, routeLayer, view, "dot");
}
// 측점 눈금·번호 — 계획선 위, 유역 오버레이 아래. B05 배수유역도와 같은 규칙이다.
if (showRoute && meta && routePoints.length > 1) {
const projector = createMetricProjector(meta, view);
drawStationTicks(context, routePoints, {
intervalM: MAP_STATION_INTERVAL_M,
pxPerMeter: projector.pxPerMeter,
toScreen: projector.toScreen,
});
}
// 흐름 강도(도로 색·유입 집중점 마커)는 계획선 위에 얹는다.
flowStrength.draw(context, normalizer, view);
// 세부유역 채움과 관 마커는 그 위 — 편집 대상이라 다른 레이어에 가려지면 집을 수 없다.
@@ -443,6 +461,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
meta = null;
preparedLayers.clear();
routeLayer = null;
routePoints = [];
resetView();
status.textContent = L("B04_Surface_Map_Loading");
showProgress(0, L("B04_Surface_Map_Loading"));
@@ -480,6 +499,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
normalizer = createNormalizer(nextMeta);
routeLayer =
planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null;
routePoints = planned.points;
// 흐름 강도는 계획선 위에 칠하므로 같은 점 목록·같은 메타를 쓴다(어긋나면 색이 밀린다).
flowStrength.setRoute(planned.points, nextMeta);
// 관 마커도 같은 계획선 위에 스냅한다 — 목록이 다르면 마커가 노선을 벗어난다.
@@ -518,7 +538,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
event.preventDefault();
const prevScale = scale;
// 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). 일반 스크롤과 반대 방향이다.
scale = Math.min(8, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87)));
// 상한은 「화면 폭 20m」로 계산한다 — 도엽 크기가 달라도 체감이 같다(2026-09-04 사용자 지시).
const wheelRect = viewport.getBoundingClientRect();
const wheelWidth = Math.max(1, Math.floor(wheelRect.width));
const maxScale = computeMaxScale(
meta,
computeMapRect(meta, wheelWidth, Math.max(1, Math.floor(wheelRect.height))).width,
wheelWidth,
8,
);
scale = Math.min(maxScale, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87)));
// 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정.
// screen = center + (base - center)·scale + offset 이므로,
// 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale).
@@ -9,10 +9,12 @@
* ========================================================================== */
import {
computeMaxScale,
lonLatToScreen,
type Normalizer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { pointInRings } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
@@ -29,6 +31,8 @@ export interface DrainageInteractParams {
currentView: () => ViewState;
getScale: () => number;
setScale: (value: number) => void;
/** 배율 상한을 도엽 실폭으로 계산하기 위한 메타 (2026-09-04). 없으면 종전 고정 상한. */
getMeta: () => VWorldMeta | null;
getOffset: () => { x: number; y: number };
setOffset: (x: number, y: number) => void;
scheduleDraw: () => void;
@@ -55,7 +59,10 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void {
event.preventDefault();
const prevScale = params.getScale();
// 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). B04 2D 지도와 같은 방향이다.
const scale = Math.min(16, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87)));
// 상한은 「화면 폭 20m」로 계산한다 — B04 지도와 같은 규칙(2026-09-04 사용자 지시).
const view = currentView();
const maxScale = computeMaxScale(params.getMeta(), view.mapRect.width, view.width, 16);
const scale = Math.min(maxScale, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87)));
params.setScale(scale);
// 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작).
const ratio = scale / prevScale;
@@ -12,6 +12,7 @@ import {
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
computeMapRect,
MAP_STATION_INTERVAL_M,
createNormalizer,
prepareLayer,
prepareMetricPolyline,
@@ -200,6 +201,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
function updateImageTransform(): void {
backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
// 크게 당기면 도엽 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다
// (2026-09-04 사용자 지시). 축척 막대가 실제 크기를 알려 준다.
backgroundImage.style.imageRendering = scale > 4 ? "pixelated" : "auto";
}
function draw(): void {
@@ -244,6 +248,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
pipeEditor,
pipeColor,
markedChainage,
stationIntervalM: MAP_STATION_INTERVAL_M,
});
updateImageTransform();
}
@@ -488,6 +493,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
setScale: (value) => {
scale = value;
},
getMeta: () => meta,
getOffset: () => ({ x: offsetX, y: offsetY }),
setOffset: (x, y) => {
offsetX = x;
+2 -18
View File
@@ -171,24 +171,8 @@ export function renderBasinRows(
/** (m) px .
* . */
export function createMetricProjector(
meta: VWorldMeta,
view: ViewState,
): { toScreen: (x: number, y: number) => [number, number]; pxPerMeter: number } {
const spanX = meta.width_meters || 1;
const spanY = meta.height_meters || 1;
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
return {
toScreen: (x, y) => [
((x - meta.x_min) / spanX) * ax + bx,
(1 - (y - meta.y_min) / spanY) * ay + by,
],
pxPerMeter: ax / spanX,
};
}
// 미터 좌표 → 화면 좌표 변환기는 B04 지도와 공용이다(정의처: MapRender).
export { createMetricProjector } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
/** , .
@@ -22,6 +22,7 @@ import {
drawFilledRing,
drawRidgeRing,
drawRingBadge,
drawStationTicks,
drawUpstreamLines,
ringCenterOnScreen,
} from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
@@ -64,6 +65,8 @@ export interface DrainageScene {
pipeColor: (chainage: number, position: number) => string;
/** 선택된 측점의 누가거리(m). 계획선 위 그 자리에 선택 표식을 그린다(null=없음). */
markedChainage: number | null;
/** 규칙 측점 간격(m) — 눈금·번호 표기 기준 (2026-09-04 사용자 지시). */
stationIntervalM: number;
}
export function drawDrainageScene(
@@ -182,6 +185,13 @@ export function drawDrainageScene(
context.stroke();
context.restore();
}
// 측점 눈금·번호 — 관 마커 위, 유역 번호 아래 (2026-09-04 사용자 지시). B04 지도와 공용.
drawStationTicks(context, scene.strengthSamples, {
intervalM: scene.stationIntervalM,
pxPerMeter: projector.pxPerMeter,
toScreen: projector.toScreen,
avoidChainages: scene.pipeEditor.chainages(),
});
// 유역 번호 — 무엇에도 가리지 않게 맨 마지막.
drawBadges(context, badges);
}
+61 -1
View File
@@ -213,7 +213,9 @@ def find_planned_route_file(input_dir: Path) -> Path | None:
def load_design_route(
project_root: Path, surface_params: dict[str, Any] | None = None
project_root: Path,
surface_params: dict[str, Any] | None = None,
route_range: tuple[float | None, float | None] | None = None,
) -> PlannedRoute | None:
"""설계가 쓸 계획노선 한 벌을 만든다 — 읽기·좌표계 변환·트림·조밀화를 여기서 끝낸다.
@@ -225,6 +227,9 @@ def load_design_route(
`surface_params`(확정 필터·방식·스무딩) 주면 지표면이 덮지 못하는 구간을 잘라 내고,
B05 격자 탐색이 계획노선을 바꾸지 않도록 정점 간격을 직결 문턱 아래로 좁힌다.
주지 않으면 읽어서 좌표계만 맞춘 원본을 돌려준다.
`route_range`(시작·종료 누가거리 m) 주면 **서피스 트림보다 먼저** 구간만 남긴다
(2026-09-04 사용자 지시). 순서가 바뀌면 사용자가 정한 시점이 서피스 트림에 밀린다.
"""
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
from common_util.common_util_initial_snapshot import design_route_csv_path
@@ -264,6 +269,10 @@ def load_design_route(
transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True)
points = [transformer.transform(x, y) for x, y in points]
# 사용자가 정한 범위가 먼저다 — 그 다음에 서피스 밖을 깎는다.
if route_range:
points = clip_route_by_chainage(points, route_range[0], route_range[1])
if surface_params:
from common_util.common_util_surface_sampler import build_surface_sampler
@@ -311,6 +320,57 @@ def replace_vertices(
)
def clip_route_by_chainage(
points: list[tuple[float, float]],
start_m: float | None,
end_m: float | None,
) -> list[tuple[float, float]]:
"""사용자가 정한 누가거리 구간만 남긴다 (2026-09-04 사용자 지시).
계획노선 자료가 공사지 전체일 있어 ** 구간을 사용자가 정한다**(B02 등록 화면).
경계는 정점 사이에 떨어질 있으므로 자리에 점을 하나 만들어 끼운다.
없으면 원본 그대로. 남는 구간이 2 미만이면 원본을 돌려준다 범위가 자료를
벗어난 경우까지 여기서 노선을 지우면 원인을 찾는다(판정·안내는 화면·라우터 ).
"""
if len(points) < 2 or (start_m is None and end_m is None):
return list(points)
low = max(0.0, float(start_m)) if start_m is not None else 0.0
high = float(end_m) if end_m is not None else float("inf")
if high <= low:
return list(points)
clipped: list[tuple[float, float]] = []
travelled = 0.0
for index in range(1, len(points)):
ax, ay = points[index - 1]
bx, by = points[index]
length = math.dist((ax, ay), (bx, by))
if length <= 0.0:
continue
seg_start, seg_end = travelled, travelled + length
travelled = seg_end
if seg_end < low or seg_start > high:
continue
# 이 구간에서 남길 부분의 시작·끝 비율.
t0 = max(0.0, (low - seg_start) / length)
t1 = min(1.0, (high - seg_start) / length)
if t1 <= t0:
continue
first = (ax + (bx - ax) * t0, ay + (by - ay) * t0)
last = (ax + (bx - ax) * t1, ay + (by - ay) * t1)
if not clipped:
clipped.append(first)
clipped.append(last)
if len(clipped) < 2:
logger.warning(
"계획노선 범위 절단: 남는 구간이 없어 전 구간을 씁니다 — 범위 %s~%s m",
start_m,
end_m,
)
return list(points)
return clipped
def _log_trim_wipeout(points: list[tuple[float, float]], sampler: Any, target_crs: str) -> None:
"""트림이 노선을 통째로 지운 이유를 **수치로** 남긴다.
+2 -1
View File
@@ -55,7 +55,8 @@ SURFACE_GROUND_RATIO_WARN = float(os.getenv("SURFACE_GROUND_RATIO_WARN", "0.01")
# 계획노선이 지표면 밖으로 나가 잘릴 때, 잘린 쪽 끝에서 더 깎을 길이(m).
# 서피스 가장자리는 점 밀도가 떨어져 외곽선이 불규칙하다 — 경계에 딱 붙여 자르면
# 그 구간 지반고가 못 미덥다 (2026-09-01 사용자 확정).
SURFACE_ROUTE_EDGE_TRIM_M = float(os.getenv("SURFACE_ROUTE_EDGE_TRIM_M", "30.0"))
# 30m 는 너무 많이 깎는다는 지적으로 3m 로 낮춤 (2026-09-04 사용자 지시).
SURFACE_ROUTE_EDGE_TRIM_M = float(os.getenv("SURFACE_ROUTE_EDGE_TRIM_M", "3.0"))
# ─────────────────────────────────────────────────────────────────────────
# 5-2. 지표면 모델 생성 파라미터 (TIN/DTM/NURBS/implicit/meshfree)
+16
View File
@@ -0,0 +1,16 @@
-- 015_route_range.sql
-- 계획노선 사용 범위 (2026-09-04 사용자 지시)
--
-- 계획노선 자료가 공사지 전체일 수 있어 **어느 구간을 쓸지 사용자가 정한다**.
-- B02 등록 화면에서 시작·종료 누가거리(m)를 받고, B03 이 계획노선을 세울 때 이 범위로
-- 먼저 자른 뒤 서피스 밖을 잘라 낸다(순서가 바뀌면 사용자가 정한 시점이 밀린다).
--
-- 둘 다 NULL 이면 지금처럼 **전 구간**을 쓴다 — 기존 행·기존 동작은 그대로다.
USE aislo_db;
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS route_start_m DOUBLE NULL
COMMENT '계획노선 시작 누가거리(m) — NULL 이면 처음부터' AFTER estimated_length_m,
ADD COLUMN IF NOT EXISTS route_end_m DOUBLE NULL
COMMENT '계획노선 종료 누가거리(m) — NULL 이면 끝까지' AFTER route_start_m;