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
+45
View File
@@ -116,6 +116,51 @@ async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]:
return await asyncio.to_thread(_search_sync, query.strip(), size)
def _search_place_sync(query: str, size: int) -> list[dict[str, Any]]:
"""장소 이름(POI) 찾기(594-4) — {title, road, parcel, lon, lat} · 실패 = 빈 목록."""
params = urllib.parse.urlencode(
{
"service": "search",
"request": "search",
"version": "2.0",
"crs": "EPSG:4326",
"size": str(size),
"page": "1",
"query": query,
"type": "place",
"format": "json",
"errorformat": "json",
"key": VWORLD_API_KEY,
}
)
try:
body = json.loads(_fetch(f"{_SEARCH_URL}?{params}", timeout=8).decode("utf-8"))
except Exception:
return []
found = []
for item in ((body.get("response") or {}).get("result") or {}).get("items") or []:
address = item.get("address") or {}
point = item.get("point") or {}
try:
lon, lat = float(point["x"]), float(point["y"])
except (KeyError, TypeError, ValueError):
continue
found.append(
{
"title": item.get("title") or "",
"road": address.get("road") or "",
"parcel": address.get("parcel") or "",
"lon": lon,
"lat": lat,
}
)
return found
async def search_place(query: str, size: int = 10) -> list[dict[str, Any]]:
return await asyncio.to_thread(_search_place_sync, query.strip(), size)
def _address_items(lon: float, lat: float) -> dict[str, dict[str, Any]]:
"""좌표 → VWorld getAddress 결과(종류 road · parcel → 한 칸) · 실패 = 빈 dict."""
query = urllib.parse.urlencode(
+10 -7
View File
@@ -42,13 +42,15 @@ const GRID_M = 2;
* 잇는 띠를 1.5~2.5 m 로 늘림. 변 판정(boundary.touches)은 그대로. */
export const SEAM_M = 1.5;
/** 노면부 띠 — 행마다 좌우 노면 끝 사이 사각형(리본이 덮는 자리 · 망에서 뺀다). */
/** 노면부 띠 — 행마다 좌우 노면 끝 사이 사각형(리본이 덮는 자리 · 망에서 뺀다) · 여러 벌이면 띠 전부. */
class RoadStrip extends CellRegion {
constructor(road: TinDesign["road"]) {
constructor(roads: ReadonlyArray<TinDesign["road"]>) {
super();
const p = ([x, z]: [number, number]) => ({ x, z });
for (let i = 0; i + 1 < road.left.length; i += 1) {
this.addCell(p(road.left[i]), p(road.right[i]), p(road.right[i + 1]), p(road.left[i + 1]));
for (const road of roads) {
for (let i = 0; i + 1 < road.left.length; i += 1) {
this.addCell(p(road.left[i]), p(road.right[i]), p(road.right[i + 1]), p(road.left[i + 1]));
}
}
this.buildIndex();
}
@@ -199,12 +201,13 @@ export function tinTerrain(
const design = designer(build, groundAt, bounds);
if (!design) return null;
const outer = new LoopIndex([design.outer]);
const outers = design.outers ?? [design.outer];
const outer = new LoopIndex(outers);
const holes = new LoopIndex(design.holes);
const inRegion = (x: number, z: number): boolean => outer.contains(x, z) || holes.contains(x, z);
const boundary = new SegGrid(GRID_M);
let segment = 0;
[design.outer, ...design.holes].forEach((loop) => {
[...outers, ...design.holes].forEach((loop) => {
for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) {
boundary.add(segment, loop[j][0], loop[j][1], loop[i][0], loop[i][1]);
segment += 1;
@@ -276,7 +279,7 @@ export function tinTerrain(
}
const { triangles, failed } = pslg.triangulate();
const road = new RoadStrip(design.road);
const road = new RoadStrip(design.roads ?? [design.road]);
const positions: number[] = [];
const colors: number[] = [];
const keep: number[] = [];
+14
View File
@@ -253,6 +253,19 @@ export function createRouteMarkers(
warnings: Array<{ polyline_start_index: number; polyline_end_index: number }> = [],
): void {
disposeGroup(routeGroup);
drawRoute(polyline, warnings);
}
/** 여러 선(D03 노선망 · PLAN 601-2) — 앞 선들을 버리고 한꺼번에 · 경고 구간 없음. */
function renderRoutes(polylines: Array<Array<{ x: number; y: number; z?: number }>>): void {
disposeGroup(routeGroup);
polylines.forEach((polyline) => drawRoute(polyline, []));
}
function drawRoute(
polyline: Array<{ x: number; y: number; z?: number }>,
warnings: Array<{ polyline_start_index: number; polyline_end_index: number }>,
): void {
const bounds = getBounds();
if (!bounds || polyline.length < 2) return;
// 정점 사이를 잘게 나눠 **각 점의 지형고를 찍어** 선을 지면에 붙인다(2026-09-06).
@@ -551,6 +564,7 @@ export function createRouteMarkers(
},
renderMarkers,
renderRoute,
renderRoutes,
renderStationLines,
selectStation,
setStationLinesVisible(visible: boolean) {
+3
View File
@@ -82,6 +82,8 @@ export interface RouteViewer {
* 이 호출 뒤에 할 것. null이면 코리도 제거.
*/
setCorridor: (build: CorridorBuildResult | null) => void;
/** 여러 벌(D03 노선망 · PLAN 601-2) — 벌마다 노면 · 지형 잘림 한 번 · 빈 배열 = 지움. */
setCorridors: (builds: CorridorBuildResult[]) => void;
/** [예상형상] 토글 — ON: 클리핑 지형+코리도(공사 후), OFF: 원본 지형 완전체. */
setCorridorVisible: (visible: boolean) => void;
renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void;
@@ -494,6 +496,7 @@ export function createRouteViewer(): RouteViewer {
corridor.setSurfaceVisible(visible);
},
setCorridor: corridor.set,
setCorridors: corridor.setMany,
setCorridorVisible: corridor.setVisible,
setSurfaceGrayscale(grayscale) {
if (surfaceGrayscale === grayscale) return;
+97 -29
View File
@@ -3,6 +3,8 @@
* 3D 뷰어의 예상형상(코리도) 조각 — 원본/예상형상 지형 스왑 + 계획 서피스 조합.
* 예상형상 = 제안 ③(측점 횡단 기반 사면 · `_Loft.ts`) 하나 · 노면부만 코리도로 그린다.
* ③ 을 못 만들면(높이 색인 없음 · 측점 부족) 원지형 그대로.
* 여러 벌(D03 노선망 · PLAN 601-2 `setMany`) = 벌마다 노면 그룹 · 지형 잘림은 벌마다 설계를 한 PSLG 로 합쳐 한 번에.
* 한 벌(`set`)은 옛 길 그대로(B05 화면 동작 같음).
*
* 뷰어 본체(`B05_Profile_UI_Viewer.ts`)가 700줄을 넘어 이 부분만 뗐다(계획서 24-3a).
* 지형 · 범위 · 높이 색인은 뷰어가 쥐고, 여기서는 받아 읽기만 한다.
@@ -15,7 +17,11 @@ import { createCorridorGroup } from "./B05_Profile_UI_Corridor_Mesh";
import { setCorridorBuildSummary } from "./B05_Profile_UI_Viewer_Debug";
import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain";
import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split";
import { loftTerrain } from "./B05_Profile_UI_Corridor_Loft";
import { loftDesign, loftTerrain } from "./B05_Profile_UI_Corridor_Loft";
import { smoothSlopeNormals } from "./B05_Profile_UI_Corridor_Loft_Shade";
import { tinTerrain, type TinDesigner } from "./B05_Profile_UI_Corridor_Tin";
import type { TinDesign } from "./logic/B05_Profile_UI_Corridor_Tin_Design";
import { Pslg } from "./logic/B05_Profile_UI_Corridor_Tin_Pslg";
/** 서피스 삼각형 수 — 예상형상 지형이 실제로 바뀌었는지 확인하는 계측용. */
function countTriangles(root: THREE.Object3D | null): number {
@@ -81,6 +87,8 @@ export interface ViewerCorridorDeps {
export interface ViewerCorridor {
set: (build: CorridorBuildResult | null) => void;
/** 여러 벌 — 빈 배열 = 지움 · 한 벌 = `set` 과 같음. */
setMany: (builds: CorridorBuildResult[]) => void;
setVisible: (visible: boolean) => void;
setSurfaceVisible: (visible: boolean) => void;
/** 지형이 바뀌기 전 — 밴드 분할본을 버린다. */
@@ -100,9 +108,57 @@ function roadOnly(build: CorridorBuildResult): CorridorBuildResult {
};
}
/** 벌마다 설계를 한 PSLG 로 — 점 · 변 옮겨 담고 벌 사이 교차를 다시 나눔 · 둘레 · 띠 = 벌마다(합집합). */
function mergeDesigns(designs: TinDesign[]): TinDesign | null {
if (designs.length <= 1) return designs[0] ?? null;
const pslg = new Pslg();
const stats = { rows: 0, sections: 0, bowtie: 0, bendLines: 0, holes: 0, splits: 0 };
for (const design of designs) {
const from = design.pslg;
const ids = from.x.map((x, i) => pslg.addPoint(x, from.z[i], from.y[i], from.tag[i]));
from.kinds.forEach((kind, e) =>
pslg.addEdge(ids[from.edges[e * 2]], ids[from.edges[e * 2 + 1]], kind),
);
(Object.keys(stats) as Array<keyof typeof stats>).forEach((key) => {
stats[key] += design.stats[key];
});
}
stats.splits += pslg.splitCrossings();
return {
pslg,
outer: designs[0].outer,
holes: designs.flatMap((design) => design.holes),
road: designs[0].road,
outers: designs.map((design) => design.outer),
roads: designs.map((design) => design.road),
stats,
};
}
/** 여러 벌 제안 ③ 지형 — 벌마다 `loftDesign` → 합침 → TIN 한 번. */
function loftTerrainMany(
split: TerrainBandSplit,
builds: CorridorBuildResult[],
bounds: ModelBounds,
heightIndex: TerrainHeightIndex,
): ReturnType<typeof loftTerrain> {
const usable = builds.filter((build) => build.sections?.length);
if (!usable.length) return null;
const designer: TinDesigner = (_build, groundAt, box) =>
mergeDesigns(
usable.flatMap((build) => {
const design = loftDesign(build, groundAt, box);
return design ? [design] : [];
}),
);
return tinTerrain(split, usable[0], bounds, heightIndex, designer, (mesh) =>
smoothSlopeNormals(mesh),
);
}
export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
const { scene } = deps;
let corridorBuild: CorridorBuildResult | null = null;
let corridorBuilds: CorridorBuildResult[] = [];
let corridorGroup: THREE.Group | null = null;
let clippedTerrain: THREE.Object3D | null = null;
/** 지형 near/far 분할본 — 편집마다 노선 주변만 재트림하려고 1회만 만든다. */
@@ -110,7 +166,7 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값.
let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합).
/** 코리도 그룹에 그린 빌드(노면부만). */
let drawnBuild: CorridorBuildResult | null = null;
let drawnBuilds: CorridorBuildResult[] = [];
/** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */
function applyCorridorVisibility(): void {
@@ -133,8 +189,8 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
clippedTerrain = null;
}
/** 코리도를 넉넉히 감싼 밴드(씬 좌표) — 이 안쪽만 편집마다 다시 트림한다. */
function corridorBand(build: CorridorBuildResult): SceneBox | null {
/** 코리도를 넉넉히 감싼 밴드(씬 좌표) — 이 안쪽만 편집마다 다시 트림한다(여러 벌 = 모두 감쌈). */
function corridorBand(builds: CorridorBuildResult[]): SceneBox | null {
const bounds = deps.bounds();
if (!bounds) return null;
const ox = (bounds.x[0] + bounds.x[1]) / 2;
@@ -143,23 +199,25 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
[...build.outline.left, ...build.outline.right].forEach(([mx, my]) => {
const x = mx - ox;
const z = -(my - oy);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
});
builds
.flatMap((build) => [...build.outline.left, ...build.outline.right])
.forEach(([mx, my]) => {
const x = mx - ox;
const z = -(my - oy);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
});
if (!Number.isFinite(minX)) return null;
return { minX, maxX, minZ, maxZ };
}
/** 밴드 분할본 확보 — 코리도가 밴드를 벗어났을 때만 다시 가른다. */
function ensureBandSplit(build: CorridorBuildResult): TerrainBandSplit | null {
function ensureBandSplit(builds: CorridorBuildResult[]): TerrainBandSplit | null {
const terrain = deps.terrain();
if (!terrain) return null;
const box = corridorBand(build);
const box = corridorBand(builds);
if (!box) return null;
if (bandSplit?.covers(box)) return bandSplit;
bandSplit?.dispose();
@@ -182,21 +240,26 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
/** 클리핑본 재생성 — 무거우므로 코리도 표시 후 다음 프레임에 수행(비동기 스왑). */
function scheduleTerrainClip(): void {
disposeClippedTerrain();
if (!corridorBuild || !deps.terrain() || !deps.bounds()) {
if (!corridorBuilds.length || !deps.terrain() || !deps.bounds()) {
applyCorridorVisibility();
return;
}
const buildAtSchedule = corridorBuild;
const buildsAtSchedule = corridorBuilds;
requestAnimationFrame(() => {
const terrain = deps.terrain();
const bounds = deps.bounds();
// 예약 사이에 코리도가 교체·제거됐으면 이 클립은 폐기한다.
if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return;
const drawn = drawnBuild ?? buildAtSchedule;
if (corridorBuilds !== buildsAtSchedule || !terrain || !bounds) return;
const [buildAtSchedule] = buildsAtSchedule;
const drawn = drawnBuilds.length ? drawnBuilds : buildsAtSchedule;
const split = ensureBandSplit(drawn);
if (!split) return;
const heightIndex = deps.heightIndex();
const shaped = heightIndex ? loftTerrain(split, buildAtSchedule, bounds, heightIndex) : null;
const shaped = !heightIndex
? null
: buildsAtSchedule.length === 1
? loftTerrain(split, buildAtSchedule, bounds, heightIndex)
: loftTerrainMany(split, buildsAtSchedule, bounds, heightIndex);
(window as unknown as { __corridorDeform?: unknown }).__corridorDeform =
shaped?.stats ?? null;
disposeClippedTerrain();
@@ -221,14 +284,18 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
applyCorridorVisibility();
}
function set(build: CorridorBuildResult | null): void {
function setMany(builds: CorridorBuildResult[]): void {
disposeCorridorGroup();
corridorBuild = build;
corridorBuilds = builds;
const bounds = deps.bounds();
drawnBuild = build && bounds ? roadOnly(build) : null;
setCorridorBuildSummary(drawnBuild ?? build);
if (drawnBuild && bounds) {
corridorGroup = createCorridorGroup(drawnBuild, bounds);
drawnBuilds = bounds ? builds.map(roadOnly) : [];
setCorridorBuildSummary(drawnBuilds[0] ?? builds[0] ?? null);
if (drawnBuilds.length && bounds) {
if (drawnBuilds.length === 1) corridorGroup = createCorridorGroup(drawnBuilds[0], bounds);
else {
corridorGroup = new THREE.Group();
drawnBuilds.forEach((drawn) => corridorGroup!.add(createCorridorGroup(drawn, bounds)));
}
scene.add(corridorGroup);
deps.onGroup();
}
@@ -236,7 +303,8 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
}
return {
set,
set: (build) => setMany(build ? [build] : []),
setMany,
setVisible(visible) {
corridorOn = visible;
applyCorridorVisibility();
@@ -250,12 +318,12 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
bandSplit = null;
},
rebuild() {
if (corridorBuild) set(corridorBuild);
if (corridorBuilds.length) setMany(corridorBuilds);
},
group: () => corridorGroup,
clipped: () => clippedTerrain,
dispose() {
corridorBuild = null; // 예약된 클립 콜백 무효화.
corridorBuilds = []; // 예약된 클립 콜백 무효화.
disposeCorridorGroup();
disposeClippedTerrain();
bandSplit?.dispose();
@@ -24,6 +24,9 @@ export interface TinDesign {
holes: Array<Array<[number, number]>>;
/** 노면부 띠 행(씬 평면) — 좌 · 우 노면 끝. */
road: { left: Array<[number, number]>; right: Array<[number, number]> };
/** 여러 벌(D03 노선망 · PLAN 601-2) — 있으면 `outer` · `road` 대신 이 둘레 · 띠 전부(합집합). */
outers?: Array<Array<[number, number]>>;
roads?: Array<TinDesign["road"]>;
stats: {
rows: number;
sections: number;
+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;
});
}
/** 선 차례 — 갈래는 부모 뒤(서버 셈이 앞에서부터 부모를 찾음) · 이미 맞으면 그대로. */
@@ -26,7 +26,8 @@ async def get_plan_project(project_id: str) -> dict[str, Any] | None:
def line_names(lines: list[dict[str, Any]]) -> list[str]:
"""선 이름 — 갈래 없는 선 = 본선(둘 이상이면 본선 1 · 2 …) · 갈래 = 갈래 1 · 2 …(선 차례).
"""선 이름 — 붙인 이름(594-1) · 없으면 차례 이름: 갈래 없는 선 = 본선(둘 이상이면
본선 1 · 2 …) · 갈래 = 갈래 1 · 2 …(선 차례 · 이름 붙은 선도 수를 셈).
⚠ 짝 = TS `D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts` `lineNames` — 거울 시험 530-3."""
roots = sum(1 for line in lines if not line.get("branch"))
@@ -34,10 +35,11 @@ def line_names(lines: list[dict[str, Any]]) -> list[str]:
for line in lines:
if line.get("branch"):
branch += 1
out.append(f"갈래 {branch}")
auto = f"갈래 {branch}"
else:
root += 1
out.append(f"본선 {root}" if roots > 1 else "본선")
auto = f"본선 {root}" if roots > 1 else "본선"
out.append((line.get("name") or "").strip() or auto)
return out
+36 -1
View File
@@ -2,16 +2,19 @@
GET /api/map/kakao-key {key} — 공개용(도메인 등록으로 막힘) · .env KAKAO_JS_KEY · 없으면 null
GET /api/map/address {address, region, source} — VWorld(B08 과 같은 길) · 지번 먼저 · 없음 null
GET /api/map/search?q= {items: [{kind address|place, title, address, lon, lat}], source} — 594-4 ·
등록(D01) · 예정노선 위치 칸이 같이 씀 · VWorld 주소 + 장소
"""
from __future__ import annotations
import asyncio
import os
from typing import Any
from fastapi import APIRouter, Query
from B01_Dashboard.B01_Dashboard_Map import reverse_place
from B01_Dashboard.B01_Dashboard_Map import reverse_place, search_address, search_place
router = APIRouter(prefix="/api/map", tags=["map"])
@@ -31,3 +34,35 @@ async def map_address(
) -> dict[str, Any]:
"""좌표 → 주소 · 「시군구 읍면」(사업 지역 채움) · 출처."""
return {**await reverse_place(lon, lat), "source": ADDRESS_SOURCE}
@router.get("/search")
async def map_search(
q: str = Query(..., min_length=1, max_length=100), size: int = Query(10, ge=1, le=30)
) -> dict[str, Any]:
"""주소 · 장소 찾기(594-4) — 주소(도로명 → 지번) 먼저 · 장소 뒤 · 같은 줄 하나 · 키는 서버."""
query = q.strip()
if not query:
return {"items": [], "source": ADDRESS_SOURCE}
addresses, places = await asyncio.gather(search_address(query, size), search_place(query, size))
items = [
{
"kind": "address",
"title": hit[hit["category"]] or hit["road"] or hit["parcel"],
"address": hit["parcel"] if hit["category"] == "road" else hit["road"],
"lon": hit["lon"],
"lat": hit["lat"],
}
for hit in addresses
] + [
{
"kind": "place",
"title": hit["title"],
"address": hit["road"] or hit["parcel"],
"lon": hit["lon"],
"lat": hit["lat"],
}
for hit in places
]
unique = {(item["title"], item["address"]): item for item in items} # 같은 줄 하나(차례 그대로)
return {"items": list(unique.values()), "source": ADDRESS_SOURCE}
+29 -2
View File
@@ -3,7 +3,8 @@
조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 ·
서버 꼴 읽기 · 화면 꼴(선 · 마크 · 조각)을 서버 셈(`derive`)이 그대로 받음 ·
580 = 종점에서 그은 선 뒤집어 잇기 · 갈래로 붙음 · 꺾임점 옮김 · 지움(마크 비율) ·
선 지움 · 방향 바꿈 · 선 통째 연도 · 마크 옮김 · 선 이름 거울(D04 `line_names`).
선 지움 · 방향 바꿈 · 선 통째 연도 · 마크 옮김 · 선 이름 거울(D04 `line_names` · 붙인 이름 594-1) ·
이름 규칙 거울(`nameError` ↔ `name_error` · 594-1).
./venv/Scripts/python.exe -m pytest resources/tester/test_530_3_plan_logic.py -q
"""
@@ -101,12 +102,22 @@ const names = [
plan.lineNames([{ branch: null }]),
plan.lineNames([{ branch: null }, { branch: null }, { branch: { line: "main", vertex: 1 } }]),
plan.lineNames([{ branch: null }, { branch: { line: "main", vertex: 1 } }]),
plan.lineNames([
{ branch: null, name: " 임도 A " },
{ branch: null },
{ branch: { line: "main", vertex: 1 }, name: "" },
]),
];
const B = String.fromCharCode(92);
const NAME_CASES = ["가".repeat(20), "가".repeat(21), " 임도 ", "", "a/b", "a" + B + "b",
"a:b", "a*b", "a?b", 'a"b', "a<b", "a>b", "a|b", "a" + String.fromCharCode(9) + "b",
"용화-1_(2027)"];
const nameErrors = NAME_CASES.map((name) => [name, plan.nameError(name)]);
writeFileSync(process.argv[2], JSON.stringify({
one, split, tooClose, branched, inserted, blocked, dropped, guard, removed, grown, read,
marks: plan.marksOf(branched),
empty, joined, sameYear, asBranch, m, movedV, removedV, removeBlocked, lineBlocked, lineGone,
reversed, branchFlip, lineYear, markMoved, markClamp, names,
reversed, branchFlip, lineYear, markMoved, markClamp, names, nameErrors, NAME_MAX: plan.NAME_MAX,
}));
"""
@@ -260,7 +271,23 @@ def test_line_names_mirror(tmp_path: Path) -> None:
[{"branch": None}],
[{"branch": None}, {"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
[{"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
[
{"branch": None, "name": " 임도 A "},
{"branch": None},
{"branch": {"line": "main", "vertex": 1}, "name": ""},
],
]
assert got["names"] == [line_names(c) for c in cases]
assert got["names"][3] == ["임도 A", "본선 2", "갈래 1"] # 붙인 이름 · 빈 이름 = 차례 이름
assert got["names"][1] == ["본선 1", "본선 2", "갈래 1"] # 시점 둘 = 둘째도 본선
assert got["names"][2] == ["본선", "갈래 1"] # 옛 꼴 이름 그대로
def test_name_rule_mirror(tmp_path: Path) -> None:
"""594-1 이름 규칙 — TS `nameError` ↔ 서버 `name_error` 같은 판정 · 20 자 · 금지 글자."""
got = _run_ts(tmp_path)
assert got["NAME_MAX"] == server.NAME_MAX == 20
for name, error in got["nameErrors"]:
assert (error is None) == (server.name_error(name) is None), name
ok = {name for name, error in got["nameErrors"] if error is None}
assert ok == {"가" * 20, " 임도 ", "", "용화-1_(2027)"}
+212
View File
@@ -0,0 +1,212 @@
"""D03 예상 횡단(PLAN 544-1 · 601-1) — TS `logic/D03_PlanAnalysis_Expect.ts` 값 = 파이썬 `compute_cross_design`.
기본 = 리핑암 · 산쪽 절토(모르면 좌) · 일반 측구 · 암 경계 = config 기본 · 계획고 = 측점 중심 지반고.
미교차 = 격자로 넓힌 표본 · 넓힌 표본으로 파이썬이 다시 셈한 값과 같음 · 코리도 측점 꼴.
노선망 3D(601) = 선마다 측점 · 갈래 머리 = 리핑암 차도 반 + 노견만큼 잘라 냄.
./venv/Scripts/python.exe -m pytest resources/tester/test_544_1_plan_expect.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
from config.config_system_design import ( # noqa: E402
STANDARD_CROSS_SECTION,
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Expect.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import * as expect from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.js";
const job = JSON.parse(readFileSync(process.argv[2], "utf8"));
const std = { standard: job.standard, rockBoundaryM: job.rock };
// 평면 격자 — z = 100 + slope * y(좌 축 = +y) · 1 m · ±100 m
const n = 201;
const axis = Float64Array.from({ length: n }, (_, i) => i - 100);
const z = new Float64Array(n * n);
for (let iy = 0; iy < n; iy += 1)
for (let ix = 0; ix < n; ix += 1) z[iy * n + ix] = 100 + job.wideSlope * axis[iy];
const grid = { x: axis, y: axis, z, validBits: new Uint8Array(Math.ceil((n * n) / 8)).fill(255) };
const cross = job.stations.map((s) => expect.expectCross(s, std));
const wide = expect.expectCross(job.wide, std, grid);
const sections = expect.expectSections([...job.stations, job.wide], std, grid);
const trim = expect.branchTrimM(std);
const lines = [
expect.expectLine([[0, 0], [50, 0]], 0, 20),
expect.expectLine([[0, 0], [30, 0], [30, 20]], trim, 20),
expect.expectLine([[0, 0], [1.5, 0]], trim, 20),
];
writeFileSync(process.argv[3], JSON.stringify({ cross, wide, sections, trim, lines }));
"""
def _station(slope: float, uphill: str | None, *, half: float = 20.0) -> dict:
samples = []
count = int(half * 2 / 0.5) + 1
for i in range(count):
offset = -half + i * 0.5
z = round(100.0 + slope * offset, 6)
samples.append(
{"offset_m": offset, "x": 0.0, "y": offset, "z": z, "elevation_m": z, "valid": True}
)
return {
"chainage_m": 40.0,
"center_x": 0.0,
"center_y": 0.0,
"center_z": 100.0,
"tangent_xy": [1.0, 0.0],
"left_xy": [0.0, 1.0],
"uphill_side": uphill,
"samples": samples,
}
_STATIONS = [
_station(0.30, "left"),
_station(-0.45, "right"),
_station(0.05, None),
{**_station(0.2, "left"), "center_z": None},
]
_WIDE_SLOPE = 0.8
def _run_ts(tmp_path: Path) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--rootDir",
str(ROOT),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
job = tmp_path / "job.json"
result = tmp_path / "out.json"
job.write_text(
json.dumps(
{
"standard": STANDARD_CROSS_SECTION,
"rock": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
"stations": _STATIONS,
"wide": _station(_WIDE_SLOPE, "left"),
"wideSlope": _WIDE_SLOPE,
}
),
encoding="utf-8",
)
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(job), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def _python(samples: list[dict], elevation: float, uphill: str | None) -> dict:
return compute_cross_design(
samples,
elevation,
ground_type="ripping_rock",
section_mode=f"{uphill or 'left'}_cut",
ditch_type="standard",
standard=STANDARD_CROSS_SECTION,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
def _same(ts: dict, py: dict) -> None:
assert ts["section_mode"] == py["section_mode"]
assert ts["ground_type"] == "ripping_rock" and ts["ditch_type"] == "standard"
assert abs(ts["cut_area_m2"] - py["cut_area_m2"]) < 1e-9
assert abs(ts["fill_area_m2"] - py["fill_area_m2"]) < 1e-9
assert len(ts["design_line"]) == len(py["design_line"])
for a, b in zip(ts["design_line"], py["design_line"]):
assert abs(a["offset_m"] - b["offset_m"]) < 1e-9
assert abs(a["elevation_m"] - b["elevation_m"]) < 1e-9
def test_expect_matches_python(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
for station, ts in zip(_STATIONS, got["cross"]):
if station["center_z"] is None:
assert ts is None # 지반고 없음 = 못 세움
continue
py = _python(station["samples"], station["center_z"], station["uphill_side"])
_same(ts["design"], py)
assert ts["design"]["design_elevation_m"] == station["center_z"]
# 산쪽 절토 · 모르면 좌
assert [c and c["design"]["section_mode"] for c in got["cross"]] == [
"left_cut",
"right_cut",
"left_cut",
None,
]
# 비탈 표본 — 절토 · 성토 둘 다 섬
assert got["cross"][0]["design"]["cut_area_m2"] > 0
assert got["cross"][0]["design"]["fill_area_m2"] > 0
def test_unclosed_extends_and_sections(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
wide = got["wide"]
narrow = _python(_station(_WIDE_SLOPE, "left")["samples"], 100.0, "left")
assert narrow["slope_unclosed"] is True # ±20 m 안에선 안 닫힘
assert wide["design"]["slope_unclosed"] is False
assert min(s["offset_m"] for s in wide["samples"]) < -20.0
_same(wide["design"], _python(wide["samples"], 100.0, "left"))
# 코리도 측점 = 세운 측점만(지반고 없는 측점 뺌) · 좌 축 · 설계 실음
sections = got["sections"]
assert len(sections) == 4
assert all(s["frame"]["left_xy"] == [0.0, 1.0] and s["design"]["design_line"] for s in sections)
assert sections[-1]["samples"] == wide["samples"]
def test_network_lines_trim_branch_head(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
rock = STANDARD_CROSS_SECTION["rock"]
trim = rock["road_width_m"] / 2 + max(rock["shoulder_left_m"], rock["shoulder_right_m"])
assert abs(got["trim"] - trim) < 1e-12 # 3.0 / 2 + 0.5 = 2.0
main, branch, short = got["lines"]
# 본선 — 그대로 · 측점 0 · 20 · 40 · 끝 50
assert main["offsetM"] == 0 and main["line"] == [[0, 0], [50, 0]]
assert main["stations"] == [0, 20, 40, 50]
# 갈래 — 머리 2 m 잘라 냄 · 원래 누가 20 · 40 · 50 → 18 · 38 · 48 · 꺾임점 남음
assert branch["offsetM"] == trim
assert branch["line"][0] == [trim, 0] and branch["line"][-1] == [30, 20]
assert [30, 0] in branch["line"]
assert [round(m + trim, 9) for m in branch["stations"]] == [trim, 20, 40, 50]
# 남는 길이 1 m 안 됨 = 못 그림
assert short is None
+23 -1
View File
@@ -34,7 +34,7 @@ DRAW_READS = [
("B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts", '"--draw-text-secondary"'),
("B05_Profile/B05_Profile_UI_Viewer.ts", '"--draw-surface-raised"'),
("B04_PreProcess/B04_PreProcess_UI_Camera.ts", '"--draw-surface-raised"'),
("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts", '"--draw-primary"'),
("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts", '"--draw-map-sketch"'),
("B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts", '"--draw-warning"'),
("B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts", '"--draw-canvas"'),
("B07_DesignDetail/openwebcad/src/App.css", "--cad-canvas: var(--draw-canvas);"),
@@ -161,3 +161,25 @@ def test_changing_screen_tokens_keeps_drawing_colors():
a = resolve(value, before[mode])
assert "var(" not in a, (mode, rel, value)
assert a == resolve(value, after[mode]), (mode, rel, value)
def _lum(hex_color: str) -> float:
rgb = [int(hex_color[i : i + 2], 16) / 255 for i in (1, 3, 5)]
lin = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in rgb]
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
def test_map_drawing_colors_fixed_and_readable_on_dark_map():
"""600-1 지도 위 그림 색 — 테마 안 따름(어두움 블록에 없음) · 어두운 바탕 `#1f2937` · 검은 테 둘 다 ≥ 3 : 1."""
blocks = _blocks(THEME.read_text(encoding="utf-8"))
names = [
k for k in blocks["light"] if re.fullmatch(r"--draw-map-(sketch|measure|warn|route-\d)", k)
]
assert len(names) == 8, names
ground = _lum("#1f2937")
for name in names:
for mode in ("dark", "auto"):
assert name not in blocks[mode], (mode, name)
lum = _lum(blocks["light"][name])
assert (lum + 0.05) / (ground + 0.05) >= 3, name
assert (lum + 0.05) / 0.05 >= 3, name
+12 -4
View File
@@ -30,12 +30,13 @@ def test_d03_parts():
three = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts")
css = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css")
draw = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts")
draw_panel = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts")
tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts
assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클
# 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품
for src in (ts, draw, tools, layers):
for src in (ts, draw, draw_panel, tools, layers):
for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'):
assert raw not in src, raw
# 568-3 — 도구줄 = 공용 지도 도구줄 · 아홉([?] · 되돌리기 · 다시하기 · 3D · 돌리기 둘 · 측점 · 기울기 · 재기)
@@ -77,12 +78,19 @@ def test_d03_parts():
("place", "false"),
], keys
assert ".d03-plan__layers" in css and ".d03-plan__legend" in css
assert "createSegment({" in draw and draw.count("createInputField(") == 2
# 599-1 — 방식 칸 = 마디 고름 2 × 2 · 길이 · 기울기 두 열 · 노드 판(598-4)과 같은 함수
assert "createSegment({" in draw_panel and draw_panel.count("createInputField(") == 2
assert "columns: 2" in draw_panel and "export function createDrawKindFields(" in draw_panel
assert ".d03-plan__draw-fields" in css and "createDrawKindFields(" in draw
assert '"길이·기울기 고정"' in draw_panel and '"둘 다"' not in draw_panel
assert "d03-plan__hint" not in draw # 칸 설명 글 걷음(요구 463-6)
# 599-4 — 연도 구역 마커 = 그리기 칸 단추(끌어 놓기) · 지도 도구줄에서 뺌
assert "YEAR_MARK_DRAG" in draw_panel and '"year-mark"' not in tools
props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판
assert "createSelectField(" in props and "createFloatPanel(" in props
assert 'createElement("button")' not in props and 'createElement("select")' not in props
assert 'createElement("select")' not in three and "createSelectField" in three
assert "createCheckbox" not in three and "buildCorridor" not in three # 예상 코리도 걷음
assert 'createElement("select")' not in three and "createSelectField" not in three # 601 선 고름 걷음
assert "createCheckbox" not in three and "setCorridors(" in three # 601 노선망 예상 코리도 한 번에
for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"):
assert dead not in css, dead
assert "b05-route__3d-modal" not in three and "b05-route__3d-body" not in three # 빌림 끊음
@@ -3,6 +3,7 @@
견본 = 같은 연도 조각 둘(본선 p1 · 갈래 p3, 2026) + 본선 p2(2027) · 업체 둘(p1 = 회사 5 · p3 = 회사 6).
- D04 요약 = 조각 줄 · 이름 「본선 0+0~12+10」 · 지정 회사 = 자기 조각 · 자기 행만
- 594-2 붙인 선 이름 = 조각 이름표 · zip 폴더 · 좌표표 xlsx 「선」 칸
- 내보내기 = 조각마다 폴더 · 전체 = 선마다 레코드 · 지정 회사 = 확정된 자기 조각만
- 확정 메일 = 업체마다 자기 조각 zip(남의 조각 안 샘)
- 업체 저장 = 조각 id 검사 · 연도는 조각에서 · 같은 업체 두 조각 = 두 행
@@ -21,6 +22,7 @@ from pathlib import Path
from uuid import uuid4
import geopandas as gpd
import openpyxl
import pytest
from fastapi import HTTPException
@@ -280,3 +282,27 @@ def test_assigned_list_piece_label_length(plan, monkeypatch):
assert [(r["piece_id"], r["year"], r["piece_label"], r["length_m"]) for r in out] == [
("p3", 2026, "갈래 1 0+0~6+10", 130.0),
]
def test_line_name_flows_to_labels_zip_xlsx(plan):
"""594-2 붙인 선 이름 = 조각 이름표 · zip 폴더 · 좌표표 xlsx 「선」 칸(빈칸 뺌)."""
named = [{**LINES[0], "name": "용화 본선"}, LINES[1]]
plan["file"]["plan"] = plan_of(named, MARKS, PIECES)
rows = asyncio.run(d04.get_plan_summary(PLAN_ID, FOREST))["pieces"]
assert [r["label"] for r in rows] == [
"용화 본선 0+0~12+10",
"갈래 1 0+0~6+10",
"용화 본선 12+10~25+19.3",
]
body = asyncio.run(d04.export_plan(PLAN_ID, FOREST)).body
with zipfile.ZipFile(io.BytesIO(body)) as z:
assert {n.split("/")[0] for n in z.namelist()} == {
"2026_용화본선_p1",
"2026_갈래1_p3",
"2027_용화본선_p2",
"전체",
}
book = next(n for n in z.namelist() if n.startswith("전체/") and n.endswith(".xlsx"))
sheet = openpyxl.load_workbook(io.BytesIO(z.read(book))).active
lines = [row[14] for row in sheet.iter_rows(min_row=3, values_only=True)]
assert sorted(lines) == ["갈래1", "용화본선", "용화본선"]
-1
View File
@@ -55,7 +55,6 @@ def test_단추_목록_아이콘_차례():
"footprint",
"measure",
"station-snap",
"year-mark",
]
names = _icon_names()
assert all(icon in names for _, icon, _ in rows)
@@ -6,6 +6,8 @@
- 옛 지점 칸(시점 · 종점 목록 · 한 점 꼴)은 열 때 옮겨 보임.
- 핀 끌기 = 그 자리 노선 노드 같이(TS `moveNodesAt` · 붙은 갈래 첫 점 · 다른 점은 그대로).
- D02 길 · 단계 · 폴더가 남지 않음.
- 594-1 지점 · 선 이름 · 노드 기록 왕복 · 옛 파일 읽힘 · 21 자 · 금지 글자 400 ·
594-3 [저장] 몸 기준점 → 등록 칸(바뀔 때만 · 주소 = 서버 다시 찾음).
./venv/Scripts/python.exe -m pytest resources/tester/test_591_2_plan_points.py -q
"""
@@ -158,6 +160,103 @@ def test_epsg_stays_once_saved(api) -> None:
assert got.status_code == 400 # 파일 머리 좌표계 한 곳
# --- 594-1 지점 · 선 이름 · 노드 기록 ---
NODES = [None, {"kind": "both", "length_m": 20.0, "slope_pct": 8.5}]
def test_names_nodes_round_trip(api) -> None:
# 옛 파일(이름 · 기록 칸 없음) 그대로 읽힘
store.save_plan(
api.state["root"],
{**store.empty_plan(5186), "points": [{"x": A[0], "y": A[1]}], "lines": [LINE]},
)
old = api.get(URL).json()
assert "name" not in old["base"]["points"][0] and "name" not in old["network"]["lines"][0]
line = {**LINE, "name": " 용화 본선 ", "nodes": NODES}
branch = {
"id": "branch-1",
"vertices": [list(B), [B[0] + 50, B[1]]],
"branch": {"line": "main", "vertex": 1},
}
body = {
"epsg": 5186,
"points": [list(A), list(B)],
"point_names": ["시점", None],
"lines": [line, branch],
}
got = api.put(URL, json=body)
assert got.status_code == 200, got.text
view = api.get(URL).json()
assert [p.get("name") for p in view["base"]["points"]] == ["시점", None]
main, side = view["network"]["lines"]
assert main["name"] == "용화 본선" and main["nodes"] == NODES
assert "name" not in side and "nodes" not in side # 빈 칸 = 옛 꼴 그대로
saved = _saved(api)
assert saved["points"][1] == {"x": B[0], "y": B[1]}
# 이름만 다시 [저장] = 같은 값
again = api.put(URL, json={**body, "lines": view["network"]["lines"]}).json()
assert again["network"]["lines"] == view["network"]["lines"]
@pytest.mark.parametrize(
"patch",
[
{"lines": [{**LINE, "name": "가" * 21}]},
{"lines": [{**LINE, "name": "a/b"}]},
{"lines": [{**LINE, "name": 'a"b'}]},
{"point_names": ["시:점"]},
{"point_names": ["가" * 21]},
{"point_names": ["a", "b"]}, # 지점보다 많음
{"lines": [{**LINE, "nodes": [None]}]}, # 꼭짓점 둘 · 기록 하나
],
)
def test_bad_names_nodes_400(api, patch) -> None:
body = {"epsg": 5186, "points": [list(A)], "lines": [LINE], **patch}
assert api.put(URL, json=body).status_code == 400
assert not store.plan_path(api.state["root"]).exists() # 안 씀
def test_name_20_ok_and_bad_kind_422(api) -> None:
body = {"epsg": 5186, "points": [list(A)], "point_names": ["가" * 20], "lines": [LINE]}
assert api.put(URL, json=body).status_code == 200
bad = {**LINE, "nodes": [None, {"kind": "grade", "length_m": 1, "slope_pct": 1}]}
assert api.put(URL, json={"epsg": 5186, "lines": [bad]}).status_code == 422
# --- 594-3 [저장] 몸 기준점 → 등록 칸 ---
def test_site_saved_with_network(api, monkeypatch) -> None:
written = []
async def store_site(connection, project_id, site):
written.append(site)
async def place(lon, lat):
return {"address": f"주소 {lon:.3f}", "region": "어딘가"}
monkeypatch.setattr(net, "_store_site", store_site)
monkeypatch.setattr(net, "reverse_place", place)
same = {"lon": SITE["lon"], "lat": SITE["lat"]}
api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": same})
api.put(URL, json={"epsg": 5186, "lines": [LINE]})
assert written == [] # 그대로 · 빈 값 = 안 씀
moved = {"lon": 127.2, "lat": 37.6, "address": "몸 주소"}
got = api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": moved}).json()
assert written == [{"lon": 127.2, "lat": 37.6, "address": "주소 127.200"}] # 서버가 다시 찾음
assert got["base"]["site"] == written[0]
async def no_place(lon, lat):
return {"address": None, "region": None}
monkeypatch.setattr(net, "reverse_place", no_place)
api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": {**moved, "lon": 127.3}})
assert written[-1]["address"] == "몸 주소" # 못 찾으면 몸 주소
bad = api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": {"lon": 200, "lat": 37}})
assert bad.status_code == 422
# --- D02 걷음 ---
+82
View File
@@ -0,0 +1,82 @@
"""594-4 공용 주소 · 장소 찾기 길(`GET /api/map/search`) — VWorld 가짜 응답.
- 주소(도로명 → 지번) 먼저 · 장소 뒤 · 같은 이름 · 주소 줄 하나 · 키는 서버(응답에 없음).
- 빈 값 = 빈 목록 · 100 자 넘음 = 422 · VWorld 실패 = 빈 목록.
./venv/Scripts/python.exe -m pytest resources/tester/test_594_4_map_search.py -q
"""
from __future__ import annotations
import json
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from B01_Dashboard import B01_Dashboard_Map as vmap
from common_util import common_util_map_router as map_router
ADDRESS = {
"address": {"zipcode": "36509", "road": "", "parcel": "경상북도 영양군 일월면 용화리 1"},
"point": {"x": "129.165", "y": "36.815"},
}
PLACE = {
"title": "용화보건진료소",
"address": {"road": "경상북도 영양군 일월면 영양로 3773", "parcel": "… 용화리 225-1"},
"point": {"x": "129.150", "y": "36.789"},
}
@pytest.fixture
def api(monkeypatch):
asked = []
def fetch(url, timeout=5):
query = {k: v[0] for k, v in parse_qs(urlparse(url).query).items()}
asked.append(query)
if query.get("query") == "고장":
raise OSError("down")
if query["type"] == "address":
items = [ADDRESS] if query["category"] == "parcel" else []
else:
items = [PLACE, PLACE] # VWorld 가 같은 장소를 두 번 줌
return json.dumps({"response": {"status": "OK", "result": {"items": items}}}).encode()
monkeypatch.setattr(vmap, "_fetch", fetch)
app = FastAPI()
app.include_router(map_router.router)
client = TestClient(app)
client.asked = asked
return client
def test_address_then_place(api) -> None:
got = api.get("/api/map/search", params={"q": " 용화리 "})
assert got.status_code == 200
items = got.json()["items"]
assert items == [
{
"kind": "address",
"title": "경상북도 영양군 일월면 용화리 1",
"address": "",
"lon": 129.165,
"lat": 36.815,
},
{
"kind": "place",
"title": "용화보건진료소",
"address": "경상북도 영양군 일월면 영양로 3773",
"lon": 129.15,
"lat": 36.789,
},
]
assert {q["query"] for q in api.asked} == {"용화리"} # 앞뒤 빈칸 뺌
assert "key" not in got.text # 키는 서버만
def test_empty_long_and_failure(api) -> None:
assert api.get("/api/map/search", params={"q": " "}).json()["items"] == []
assert api.get("/api/map/search", params={"q": "가" * 101}).status_code == 422
assert api.get("/api/map/search", params={"q": "고장"}).json()["items"] == []
-1
View File
@@ -31,7 +31,6 @@ export const MAP_TOOLS = {
footprint: { icon: "road", title: "절/성토선", toggle: true },
measure: { icon: "ruler", title: "거리 재기", toggle: true },
"station-snap": { icon: "magnet", title: "측점 스냅", toggle: true },
"year-mark": { icon: "flag", title: "연도 구역 마커 — 선 위로 끌어 놓기" },
} satisfies Record<string, { icon: IconName; title: string; toggle?: boolean }>;
export type MapToolAct = keyof typeof MAP_TOOLS;
+13
View File
@@ -50,3 +50,16 @@ export function pointColor(index: number): string {
const [name, fallback] = POINT_COLORS[index % POINT_COLORS.length];
return themeColor(name, fallback);
}
/** 600-1 예정노선 선마다 차례 색 — 다섯 차례 돌림 · 지도 전용 값(테마 안 따름 · 겹 색과 가름) · 지도 선 · 구역 목록 색 띠가 같은 번호 같은 색 */
const ROUTE_COLORS = [
["--draw-map-route-0", "#3df5c4"],
["--draw-map-route-1", "#5aa9ff"],
["--draw-map-route-2", "#c58bff"],
["--draw-map-route-3", "#ff6ea8"],
["--draw-map-route-4", "#9dff4d"],
] as const;
export function routeColor(index: number): string {
const [name, fallback] = ROUTE_COLORS[((index % 5) + 5) % 5];
return themeColor(name, fallback);
}
+13
View File
@@ -48,6 +48,19 @@
cursor: not-allowed;
}
/* 여러 줄(columns) — 칸 사이 1px 틈에 틀 색 바탕이 보여 줄 · 칸 나눔선 */
.ui-segment[data-columns] {
display: grid;
grid-template-columns: repeat(var(--ui-segment-columns), 1fr);
gap: 1px;
height: auto;
background: var(--color-border);
}
.ui-segment[data-columns] > .ui-segment__item {
height: var(--ui-size-h);
border-left: 0;
}
/* 숨김 — display 가 [hidden] 을 덮지 않게(공용 기본 꼴이 안 실린 곳 · 페이지 꾸밈 위에서도) */
:is(.ui-segment, .ui-segment__item)[hidden] {
display: none !important;
+7
View File
@@ -4,6 +4,7 @@
*
* 꼴 = 글 · 아이콘(+ title) · 글 + 아이콘
* 크기 = data-size 20 · 24 · 28 · 32(기본 28)
* 줄 = 한 줄(기본) · `columns: 2` = 2 × 2 처럼 여러 줄 칸(긴 이름 넷 · 599-1 D03 긋기 방식)
* 상태 = 마우스 `:hover` · 눌림 `:active` · 막힘 `disabled` · 고름 = aria-checked — CSS 는 `[data-state]` 로도 받음
* 쓰기 = `createSegment({ options: [{ value: "list", label: "목록" }, { value: "card", label: "카드" }], value: "list", onChange })`
* 대신 = B01 `.b01-sys-seg` · `.b01-home__seg` · `.b01-proj-seg` · B04 `b04-surface__sheet-method` · B05 `b05-routerec__pills` ·
@@ -37,12 +38,18 @@ export function createSegment(opts: {
/** 20 · 24 · 28 · 32 권함 */
size?: UiSize;
ariaLabel?: string;
/** 한 줄 칸 수 — 넘으면 다음 줄(없음 = 한 줄) */
columns?: number;
}): SegmentHandle {
const root = document.createElement("div");
root.className = "ui-segment";
root.setAttribute("role", "radiogroup");
if (opts.ariaLabel) root.setAttribute("aria-label", opts.ariaLabel);
setUiSize(root, opts.size ?? 28);
if (opts.columns) {
root.dataset.columns = String(opts.columns);
root.style.setProperty("--ui-segment-columns", String(opts.columns));
}
let current = opts.value;
const items = opts.options.map((o) => {
const b = document.createElement("button");
+8
View File
@@ -585,6 +585,14 @@ html.is-bm-route .m01lab-src__body * {
--draw-map-zero-order: #7dd3fc; /* 0차곡(지형 추정 골짜기 머리 · 점선) */
--draw-map-forest-road-file: #ea580c; /* 올린 임도망도(실선 · 짐작 = 주황 점선) */
--draw-map-landslide-zone: #e11d48; /* 산사태취약지역 점 · 필지 테두리 */
--draw-map-sketch: #ffe94d; /* 지도 위 그림(테마 안 따름 · 검은 테와 함께) — 긋기 미리보기 · 그리는 선 · 고름 강조 · 마커 */
--draw-map-measure: #ffffff; /* 거리재기 */
--draw-map-warn: #ff5a4d; /* 긋기 막힘(경고) */
--draw-map-route-0: #3df5c4; /* 예정노선 선마다 차례 색(개발제한 · 산사태 주황과 가름) */
--draw-map-route-1: #5aa9ff;
--draw-map-route-2: #c58bff;
--draw-map-route-3: #ff6ea8;
--draw-map-route-4: #9dff4d;
}
:root[data-theme="dark"] {