diff --git a/B01_Dashboard/B01_Dashboard_Map.py b/B01_Dashboard/B01_Dashboard_Map.py index a692c3c6c..dbba6349e 100644 --- a/B01_Dashboard/B01_Dashboard_Map.py +++ b/B01_Dashboard/B01_Dashboard_Map.py @@ -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( diff --git a/B05_Profile/B05_Profile_UI_Corridor_Tin.ts b/B05_Profile/B05_Profile_UI_Corridor_Tin.ts index 865a4ffcf..a36be951c 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Tin.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Tin.ts @@ -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) { 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[] = []; diff --git a/B05_Profile/B05_Profile_UI_Markers.ts b/B05_Profile/B05_Profile_UI_Markers.ts index a32733979..cf9458e81 100644 --- a/B05_Profile/B05_Profile_UI_Markers.ts +++ b/B05_Profile/B05_Profile_UI_Markers.ts @@ -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>): 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) { diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 168f35d13..d2dd50162 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts b/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts index 40cb8ea0c..14b64a693 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts @@ -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).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 { + 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(); diff --git a/B05_Profile/logic/B05_Profile_UI_Corridor_Tin_Design.ts b/B05_Profile/logic/B05_Profile_UI_Corridor_Tin_Design.ts index a367d2233..c0ddd4944 100644 --- a/B05_Profile/logic/B05_Profile_UI_Corridor_Tin_Design.ts +++ b/B05_Profile/logic/B05_Profile_UI_Corridor_Tin_Design.ts @@ -24,6 +24,9 @@ export interface TinDesign { holes: Array>; /** 노면부 띠 행(씬 평면) — 좌 · 우 노면 끝. */ road: { left: Array<[number, number]>; right: Array<[number, number]> }; + /** 여러 벌(D03 노선망 · PLAN 601-2) — 있으면 `outer` · `road` 대신 이 둘레 · 띠 전부(합집합). */ + outers?: Array>; + roads?: Array; stats: { rows: number; sections: number; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 211712a90..174a1c55c 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -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; + /** 기준점(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 => 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 => + (await request<{ items: PlaceHit[] }>(`/map/search?q=${encodeURIComponent(query)}`)).items; + /** 사업 전체 지형면 — 모델 행 경로 `…/models_plan/…` · 격자 = 평활본 · 없으면 null. */ export async function fetchPlanSurface(projectId: string): Promise { const { models } = await listSurfaceModels(projectId); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Network.py b/D03_PlanAnalysis/D03_PlanAnalysis_Network.py index da9e9e593..4fc700009 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Network.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Network.py @@ -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: diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts index a8d934226..174db725f 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts @@ -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 { + 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 => { - const item = lines[index] ?? lines[0]; + const load = async (): Promise => { 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 = { line: item.name, done: false }; + const stat: Record = { 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> = []; + 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(); } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 25439f9da..c6bb83bee 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -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 }); }, }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index a5ef6888a..0e81c63f5 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -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)); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts new file mode 100644 index 000000000..18b8214d7 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts @@ -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 = { + free: "자유", + length: "길이 고정", + slope: "기울기 고정", + both: "길이·기울기 고정", +}; + +export interface DrawKindFields { + root: HTMLElement; + get: () => DrawKindValue; + /** 값을 바꿈(onChange 는 안 부름). */ + set: (value: Partial) => 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"; + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index de02713cf..4a3aa2551 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -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, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts index fb4dbe80a..0102c9d31 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts @@ -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) { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 6fa77bc8b..a685c96de 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -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 { 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 { 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 { }); 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 { .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 판정) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts new file mode 100644 index 000000000..9cca359f9 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts @@ -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(); + 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 }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts index 98eaede41..0efadf9c6 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts @@ -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(); - 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, }; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 14f869430..ed11d04f3 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -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; +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.ts new file mode 100644 index 000000000..d4c7cd419 --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.ts @@ -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, + 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 }; +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts index ba5b9eafc..a56e04d06 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts @@ -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 | 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>): string[] { +export function lineNames(lines: ReadonlyArray>): 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; + }); } /** 선 차례 — 갈래는 부모 뒤(서버 셈이 앞에서부터 부모를 찾음) · 이미 맞으면 그대로. */ diff --git a/D04_PlanConfirm/D04_PlanConfirm_Repository.py b/D04_PlanConfirm/D04_PlanConfirm_Repository.py index 1275318f1..f228d4c20 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_Repository.py +++ b/D04_PlanConfirm/D04_PlanConfirm_Repository.py @@ -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 diff --git a/common_util/common_util_map_router.py b/common_util/common_util_map_router.py index 76ef2b420..77bc724ce 100644 --- a/common_util/common_util_map_router.py +++ b/common_util/common_util_map_router.py @@ -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} diff --git a/resources/tester/test_530_3_plan_logic.py b/resources/tester/test_530_3_plan_logic.py index c79652cb1..8e3accb0f 100644 --- a/resources/tester/test_530_3_plan_logic.py +++ b/resources/tester/test_530_3_plan_logic.py @@ -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', "ab", "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)"} diff --git a/resources/tester/test_544_1_plan_expect.py b/resources/tester/test_544_1_plan_expect.py new file mode 100644 index 000000000..425003f08 --- /dev/null +++ b/resources/tester/test_544_1_plan_expect.py @@ -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 diff --git a/resources/tester/test_551_2_draw_tokens.py b/resources/tester/test_551_2_draw_tokens.py index 321e7467d..04accf07d 100644 --- a/resources/tester/test_551_2_draw_tokens.py +++ b/resources/tester/test_551_2_draw_tokens.py @@ -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 diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 56eb893de..542dd501b 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -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 # 빌림 끊음 diff --git a/resources/tester/test_563_2_plan_pieces.py b/resources/tester/test_563_2_plan_pieces.py index 3fd859add..113d2909b 100644 --- a/resources/tester/test_563_2_plan_pieces.py +++ b/resources/tester/test_563_2_plan_pieces.py @@ -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", "용화본선", "용화본선"] diff --git a/resources/tester/test_568_1_map_tools.py b/resources/tester/test_568_1_map_tools.py index c14681ef0..9cfd9653f 100644 --- a/resources/tester/test_568_1_map_tools.py +++ b/resources/tester/test_568_1_map_tools.py @@ -55,7 +55,6 @@ def test_단추_목록_아이콘_차례(): "footprint", "measure", "station-snap", - "year-mark", ] names = _icon_names() assert all(icon in names for _, icon, _ in rows) diff --git a/resources/tester/test_591_2_plan_points.py b/resources/tester/test_591_2_plan_points.py index a83353f9e..5c345bc53 100644 --- a/resources/tester/test_591_2_plan_points.py +++ b/resources/tester/test_591_2_plan_points.py @@ -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 걷음 --- diff --git a/resources/tester/test_594_4_map_search.py b/resources/tester/test_594_4_map_search.py new file mode 100644 index 000000000..f11ef0f90 --- /dev/null +++ b/resources/tester/test_594_4_map_search.py @@ -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"] == [] diff --git a/ui_template/ui_template_map_tools.ts b/ui_template/ui_template_map_tools.ts index afacf9875..47c7e28cd 100644 --- a/ui_template/ui_template_map_tools.ts +++ b/ui_template/ui_template_map_tools.ts @@ -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; export type MapToolAct = keyof typeof MAP_TOOLS; diff --git a/ui_template/ui_template_palette.ts b/ui_template/ui_template_palette.ts index 93420d4dd..034218b25 100644 --- a/ui_template/ui_template_palette.ts +++ b/ui_template/ui_template_palette.ts @@ -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); +} diff --git a/ui_template/ui_template_segment.css b/ui_template/ui_template_segment.css index 984b571b9..0c721748d 100644 --- a/ui_template/ui_template_segment.css +++ b/ui_template/ui_template_segment.css @@ -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; diff --git a/ui_template/ui_template_segment.ts b/ui_template/ui_template_segment.ts index 81a591655..1ba81b868 100644 --- a/ui_template/ui_template_segment.ts +++ b/ui_template/ui_template_segment.ts @@ -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"); diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index ebc6038fa..f5a61f805 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -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"] {