diff --git a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts index 83cc164b6..dad0a66eb 100644 --- a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts +++ b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts @@ -5,6 +5,7 @@ * · 사업연도 끝 · 임도 종류 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본) · 설계속도 없음(591-1). * 기준점(591-1 · 요구 452 · 453) = 카카오 지도(공용 editMap · 마우스 설정 따름) 누름 · 끌기로 한 점 → * GET /api/map/address(VWorld · 출처 표기) → 주소 글 · 사업 지역 「시군구 읍면」 채움(손으로 고친 뒤엔 안 덮음) + * 지도 머리 [위성] 오른쪽 = 주소 · 장소 찾기(596-5 · 예정노선 위치 칸과 같은 `createPlaceFind` · 서버 VWorld) → 고름 = 그 자리로 기준점. * [등록] = POST /api/d01/project(site = 경위도 · 주소는 서버가 다시 찾음) → 현재 프로젝트 · D03 으로. * ========================================================================== */ @@ -20,6 +21,7 @@ import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import { createGroupProject } from "../A00_Common/group_workflow"; import { navigateTo } from "../A00_Common/router"; import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name"; +import { createPlaceFind } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Site"; const KOREA_CENTER: [number, number] = [127.8, 36.3]; const DIGITS = 6; // 경위도 소수 자리(약 0.1 m) @@ -85,12 +87,17 @@ function sitePicker(onRegion: (region: string) => void): { } draw(); + // 찾기 = 칸은 머리 줄([위성] 오른쪽 · 좁으면 줄바꿈) · 알림 · 후보 줄은 머리 아래 + const find = createPlaceFind((hit) => void moveTo(hit.lon, hit.lat)); + const field = find.search.parentElement!; + field.style.flex = "1 1 12rem"; const head = document.createElement("div"); head.className = "ui-general-block__actions"; - head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root); + head.style.flexWrap = "wrap"; + head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root, field); const root = document.createElement("div"); root.dataset.d01Site = ""; - root.append(head, map.root, where); + root.append(head, find.root, map.root, where); return { root, value: () => site }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 9937062db..d353e00ba 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -179,13 +179,14 @@ export async function fetchPlanSurface(projectId: string): Promise; image: string; - bands: Array<{ min_deg: number; max_deg: number; color: string; ratio: number }>; + /** 급경사만(603) — `counts[v]` = 기울기 % 내림 v(0~250) 칸 수 · 그림 = 회색 칸 값(255 = 빈 칸). */ + counts?: number[]; } export const fetchPlanCadastral = (projectId: string): Promise => diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py index 1d365152e..3cc402d0e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py @@ -5,7 +5,8 @@ 한 번 1,000 필지 한도 → 꽉 차면 넷으로 다시 쪼갬 · `pnu` 로 겹친 필지 거름 · 저장 = `processed/연속지적도_bounds.geojson`(B04 `/geojson?layer=지적도` 자리) + 곁 열쇠 - 급경사 = 평활 안 한 사업 DEM(`models_plan/dtm_sheet_<방법>.npz`) · E07 `slope_rad` · - 15° · 35° · 40° 단계 PNG · 파생 저장본(열쇠 = 격자 지문) + 기울기 % 값 그림 한 장(1 % 단계 · 회색) + 1 % 칸 수 · 범위 색칠 · 비율 = 화면(PLAN 603) · + 파생 저장본(열쇠 = 격자 지문) - 음영기복 = 같은 DEM · B07 `hillshade_png`(PLAN 581-1) - 보호 · 규제 구역 = 브이월드 WFS 여덟 종(겹 이름 = `_분석_440-C` 표 1) · 받은 범위로 잘라 종류 · 이름 · 넓이만 · 저장 = `processed/보호구역_bounds.geojson` + 곁 열쇠 · @@ -64,13 +65,9 @@ SLOPE_KIND = "plan_slope" HILLSHADE_KIND = "plan_hillshade" HILLSHADE_VERSION = 2 # 빈 칸 투명 RASTER_KINDS = ("slope", "hillshade") -SLOPE_VERSION = 1 -# (아래 °, 위 °, 색 RGBA) — 임도망_편성 완 · 중 · 급경사지 · 타당성평가 35° · 노선_배치 40° 연속 -SLOPE_BANDS = ( - (15.0, 35.0, (250, 204, 21, 110)), - (35.0, 40.0, (249, 115, 22, 140)), - (40.0, 90.0, (220, 38, 38, 160)), -) +SLOPE_VERSION = 2 # 값 그림 + 1 % 칸 수(PLAN 603) +SLOPE_MAX_PCT = 250 # 칸 값 0~250 = 기울기 % 내림(250 = 250 % 이상) +SLOPE_EMPTY = 255 # 빈 칸 MAX_SIDE_PX = 4096 # ponytail: 한 변 넘으면 솎음 · 넓은 사업은 조각 그림으로 @@ -215,39 +212,28 @@ def ensure_cadastral(processed: Path, box: Box, epsg: int) -> dict[str, Any]: # ── 급경사 ────────────────────────────────────────────── -def slope_bands_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]: - """격자(행 = 남 → 북) → 단계 PNG(위 = 북 · 15° 미만 · 빈 칸 투명) + 단계 칸 비율.""" +def slope_values_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]: + """격자(행 = 남 → 북) → 기울기 % 값 그림(회색 · 위 = 북 · 칸 값 = % 내림 0~250 · 빈 칸 255) + + 1 % 칸 수(`counts[v]` = 값 v 칸 수 · 합 = 유효 칸).""" from PIL import Image from E07_StabArea.E07_StabArea_Engine import slope_rad stride = max(1, math.ceil(max(z.shape) / MAX_SIDE_PX)) grid = np.where(valid, np.asarray(z, float), np.nan)[::stride, ::stride] - deg = np.degrees(slope_rad(grid, cell_m * stride)) - known = np.isfinite(deg) - index = np.zeros(deg.shape, np.uint8) - for i, (low, high, _) in enumerate(SLOPE_BANDS, start=1): - index[known & (deg >= low) & (deg < high)] = i - total = max(1, int(known.sum())) - image = Image.fromarray(np.flipud(index), mode="P") - palette = [0, 0, 0] + [c for _, _, rgba in SLOPE_BANDS for c in rgba[:3]] - image.putpalette(palette + [0] * (768 - len(palette))) - alpha = bytes([0] + [rgba[3] for _, _, rgba in SLOPE_BANDS]) + pct = np.tan(slope_rad(grid, cell_m * stride)) * 100.0 + known = np.isfinite(pct) + value = np.full(pct.shape, SLOPE_EMPTY, np.uint8) + value[known] = np.minimum(np.floor(pct[known]), SLOPE_MAX_PCT).astype(np.uint8) + image = Image.fromarray(np.flipud(value), mode="L") buffer = io.BytesIO() - image.save(buffer, format="PNG", optimize=True, transparency=alpha) + image.save(buffer, format="PNG", optimize=True) + counts = np.bincount(value[known], minlength=SLOPE_MAX_PCT + 1) return { "width": image.width, "height": image.height, "image": "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii"), - "bands": [ - { - "min_deg": low, - "max_deg": high, - "color": "#{:02x}{:02x}{:02x}".format(*rgba[:3]), - "ratio": round(int((index == i).sum()) / total, 4), - } - for i, (low, high, rgba) in enumerate(SLOPE_BANDS, start=1) - ], + "counts": [int(n) for n in counts], } @@ -269,17 +255,17 @@ def hillshade_layer_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict def slope_layer( project_root: Path, models_dir: Path, epsg: int, kind: str = "slope" ) -> dict[str, Any]: - """급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 단계 · 판) 같으면 저장본.""" + """급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 판) 같으면 저장본.""" npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz" stamp = file_stamp(npz) if stamp is None: raise FileNotFoundError("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.") store_kind, version, build = ( - (SLOPE_KIND, SLOPE_VERSION, slope_bands_png) + (SLOPE_KIND, SLOPE_VERSION, slope_values_png) if kind == "slope" else (HILLSHADE_KIND, HILLSHADE_VERSION, hillshade_layer_png) ) - key = derived_key(store_kind, version, npz.name, stamp, int(epsg), SLOPE_BANDS, MAX_SIDE_PX) + key = derived_key(store_kind, version, npz.name, stamp, int(epsg), MAX_SIDE_PX) saved = read_derived(project_root, store_kind, key) if saved is not None: return saved["value"] diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 3b430b71b..e1e7f351a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -320,7 +320,7 @@ async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response: @router.get("/{project_id}/plan/layers/raster", response_model=None) async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any]: - """급경사 단계 그림(15° · 35° · 40°) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 · + """급경사 기울기 % 값 그림 + 1 % 칸 수(603) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 · 산사태위험지도(`landslide` · 행안부 WMS) — 받은 범위를 덮으면 저장본.""" if kind == "landslide": root, epsg, box = await _layer_root(project_id) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index 2e3a0a29a..87f532158 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -6,7 +6,7 @@ * 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`. * ========================================================================== */ -import { pointColor } from "@ui/ui_template_palette"; +import { pointColor, routeColor } from "@ui/ui_template_palette"; import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import { distanceAlong, @@ -18,6 +18,7 @@ import { type HeightAt, } from "./logic/D03_PlanAnalysis_Draw"; import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit"; import { nodeOff } from "./logic/D03_PlanAnalysis_Span"; import { planColors, @@ -128,7 +129,7 @@ export function paintNetwork( context.lineJoin = "round"; network.lines.forEach((line, index) => { if (line.vertices.length < 2) return; - context.strokeStyle = index === active ? colors.active : colors.line; + context.strokeStyle = index === active ? colors.active : routeColor(index); context.lineWidth = line.branch ? 2.5 : 3; const path = (vertices: ReadonlyArray): void => { context.beginPath(); @@ -181,7 +182,7 @@ export function paintNetwork( } /** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */ -export function pointLayer(points: () => Vertex[]): PlanMapLayer { +export function pointLayer(points: () => Vertex[], names: () => string[] = () => []): PlanMapLayer { return { draw(context, map) { points().forEach((point, i) => { @@ -206,7 +207,7 @@ export function pointLayer(points: () => Vertex[]): PlanMapLayer { context.fillStyle = "#ffffff"; context.fill(); }); - planLabel(context, map, sx, sy - 38, `지점 ${i + 1}`, "bold 12px"); + planLabel(context, map, sx, sy - 38, pointLabel(names(), i), "bold 12px"); }); }, }; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts index 485d07c72..259eb0f29 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -44,7 +44,14 @@ import { type PlanNode, type PlanSegment, } from "./logic/D03_PlanAnalysis_Select"; -import { lineAt, paintMark, planLabel, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; +import { + lineAt, + paintMark, + planLabel, + screenGap, + SNAP_PX, + strokeCased, +} from "./D03_PlanAnalysis_UI_DrawPaint"; import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; import { createPlanSelect, type PlanScope, type PlanSelection } from "./D03_PlanAnalysis_UI_Select"; @@ -305,9 +312,6 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { const picked = sel.get(); context.lineCap = "round"; context.lineJoin = "round"; - context.strokeStyle = colors.active; - context.globalAlpha = 0.8; - context.lineWidth = 8; for (const points of sel.paths()) { context.beginPath(); points.forEach((point, i) => { @@ -315,9 +319,8 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { if (i) context.lineTo(sx, sy); else context.moveTo(sx, sy); }); - context.stroke(); + strokeCased(context, colors.active, 8); // 밝은 바탕 대비 = 검은 테(600-1) } - context.globalAlpha = 1; // 꺾임점 손잡이(고른 것이 든 선) const lines = pickedLines(network); context.lineWidth = 2; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts index 9075b136a..eb9f09827 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -55,7 +55,7 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [ { key: "satellite", icon: "satellite", title: "위성사진", on: true }, { key: "hillshade", icon: "sun", title: "음영기복(2 m 지형 · 산줄기 · 골)", on: false }, { key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true }, - { key: "slope", icon: "angle", title: "급경사 색(15° · 35° · 40° 이상)", on: false }, + { key: "slope", icon: "angle", title: "급경사 색(기울기 % 범위 · 범례 ⚙ 로 바꿈)", on: false }, ], [ { key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true }, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts index 54256e16e..d07d5131a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts @@ -1,6 +1,7 @@ /* ============================================================================= * D03_PlanAnalysis_UI_LayerLegend.ts * D03 바탕 겹 받은 자료 꼴 · 범례 줄 · 겹별 수(PLAN 569-2 · 581 · 584-1 · 584-3) — 켠 겹의 받은 범위 안 수 · 넓이. + * 급경사 = 고른 범위(603)마다 % · ° · 비율(1 % 칸 수로 바로 셈). * 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기). * ========================================================================== */ @@ -23,6 +24,12 @@ import { type LineLayerKey, type PlanLayerKey, } from "./D03_PlanAnalysis_UI_LayerDefs"; +import { + SLOPE_MAX_PCT, + bandRatios, + pctToDeg, + type SlopeBand, +} from "./logic/D03_PlanAnalysis_SlopeBands"; /** 선 겹 — 그릴 선 · 받은 범위 안 가닥 · 길이 m. */ export interface PreparedLines { @@ -39,7 +46,15 @@ export type Prepared = Record & { road: { all: PreparedLayer; small: PreparedLayer }; /** 올린 임도망도(584-3). */ forestRoadFile: { layer: PreparedLayer; info: PlanForestRoads }; - slope: { raster: PlanRaster; image: HTMLImageElement }; + /** 급경사(603) — 값 그림 칸 · 고른 범위 · 칠한 그림(blob 주소) · 칠하기 ms. */ + slope: { + raster: PlanRaster; + image: HTMLImageElement; + values: ImageData; + bands: SlopeBand[]; + url: string; + paintMs: number; + }; hillshade: { raster: PlanRaster; image: HTMLImageElement }; landslide: { raster: PlanRaster; image: HTMLImageElement }; /** 산사태취약지역(584-3) — 점(경위도) · 지번 맞는 필지 테두리. */ @@ -55,6 +70,19 @@ export type Prepared = Record & { const km = (m: number): string => `${(m / 1000).toFixed(2)} km`; +/** 급경사 범례 — 범위마다 「아래~위 %(°) · 비율」(위 = 끝 = 「↑」). */ +function slopeRow({ raster, bands }: Prepared["slope"]): LegendItem[] { + const ratios = bandRatios(raster.counts ?? [], bands); + const deg = (pct: number): string => pctToDeg(pct).toFixed(0); + return bands.map((band, i) => { + const open = band.high >= SLOPE_MAX_PCT; + const range = open + ? `${band.low} %↑(${deg(band.low)}°↑)` + : `${band.low}~${band.high} %(${deg(band.low)}~${deg(band.high)}°)`; + return { label: `${range} · ${(ratios[i] * 100).toFixed(1)} %`, color: `var(${band.color})` }; + }); +} + /** 산사태취약지역 범례 — 점 · 필지 · 위치 없는 시도 · 표 없는 시도 · 지적 못 받음. */ function landslideZoneRow({ info }: Prepared["landslideZones"]): LegendItem[] { const color = "var(--draw-map-landslide-zone)"; @@ -86,13 +114,7 @@ export function legendRows( data: Partial, ): LegendItem[][] { const rows: LegendItem[][] = []; - if (on.slope && data.slope) - rows.push( - data.slope.raster.bands.map((band) => ({ - label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`, - color: band.color, - })), - ); + if (on.slope && data.slope) rows.push(slopeRow(data.slope)); if (on.landslide && data.landslide) rows.push(LANDSLIDE_LEGEND); if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones)); if (on.zones && data.zones) { @@ -211,6 +233,17 @@ export const layerCounts = (data: Partial): Record => band_road_m2: data.bands.info.road.area_m2, }), ...(data.hillshade && { hillshade_px: data.hillshade.image.naturalWidth }), + ...(data.slope && { + slope_px: data.slope.image.naturalWidth, + slope_cells: (data.slope.raster.counts ?? []).reduce((sum, n) => sum + n, 0), + slope_paint_ms: data.slope.paintMs, + ...Object.fromEntries( + bandRatios(data.slope.raster.counts ?? [], data.slope.bands).map((r, i) => [ + `slope_band_${i}`, + r, + ]), + ), + }), ...(data.landslide && { landslide_px: data.landslide.image.naturalWidth }), ...Object.fromEntries( LINE_KEYS.flatMap((key) => { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index fd78809cc..8bc357afe 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -16,6 +16,7 @@ * 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선. * 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. * 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`). + * 급경사 = 서버 값 그림을 고른 범위대로 칠함 · 범례 ⚙ → 범위 모달(603 · `D03_PlanAnalysis_UI_SlopeBands`). * GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을 * MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 · * WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈). @@ -72,6 +73,14 @@ import { import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend"; import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint"; import { createGlBase, GL_FONT, type GlImage } from "./D03_PlanAnalysis_UI_MapGl"; +import { + openSlopeBands, + paintSlope, + readSlopeBands, + slopeGear, + slopeValues, + writeSlopeBands, +} from "./D03_PlanAnalysis_UI_SlopeBands"; import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; @@ -200,9 +209,38 @@ export function createPlanLayers(opts: { gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain)); const rows = meta ? legendRows(on, data) : []; legend.replaceChildren(...rows.map((row) => createLegend(row))); + if (meta && on.slope && data.slope) legend.firstElementChild?.append(gear); legend.hidden = !rows.length; } + /** 급경사 범위(603) — 바꾸면 범례 바로 · 그림은 다시 칠함(늦은 칠하기는 버림). */ + let slopeBands = readSlopeBands(); + let slopeTurn = 0; + const gear = slopeGear(() => + openSlopeBands(slopeBands, (bands) => { + slopeBands = bands; + writeSlopeBands(bands); + if (data.slope) data.slope.bands = bands; + sync(); + void repaintSlope(); + }), + ); + async function repaintSlope(): Promise { + const got = data.slope; + if (!got) return; + const turn = ++slopeTurn; + const started = performance.now(); + const painted = await paintSlope(got.values, got.bands); + if (turn !== slopeTurn || data.slope !== got) return URL.revokeObjectURL(painted.url); + const old = got.url; + Object.assign(got, { image: painted.image, url: painted.url }); + got.paintMs = Math.round(performance.now() - started); + gl.setData("slope", glImage(got.raster, painted.url)); + if (old) URL.revokeObjectURL(old); + sync(); + map.redrawBase(); + } + sync(); useGl(glOn); @@ -353,7 +391,22 @@ export function createPlanLayers(opts: { } as GeoJsonCollection); if (info.missing.length) showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info"); - } else if (key === "slope" || key === "hillshade" || key === "landslide") { + } else if (key === "slope") { + const raster = await fetchPlanRaster(projectId, key); + const values = slopeValues(await loadImage(raster.image)); + const bands = slopeBands; + const started = performance.now(); + const painted = await paintSlope(values, bands); + if (data.slope?.url) URL.revokeObjectURL(data.slope.url); + data.slope = { raster, values, bands, ...painted, paintMs: 0 }; + data.slope.paintMs = Math.round(performance.now() - started); + gl.setData("slope", glImage(raster, painted.url)); + // 칠하는 동안 범위가 바뀜 = 새 범위로 다시 + if (bands !== slopeBands) { + data.slope.bands = slopeBands; + void repaintSlope(); + } + } else if (key === "hillshade" || key === "landslide") { const raster = await fetchPlanRaster(projectId, key); const image = await loadImage(raster.image); data[key] = { raster, image }; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 5dc63870f..fad2723d0 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -19,10 +19,8 @@ import "./D03_PlanAnalysis_UI_Style.css"; import { ROUTES } from "@config/config_frontend"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; -import { pickFiles } from "@ui/ui_template_drop"; import { askConfirm } from "@ui/ui_template_modal"; import { createProgressCircle } from "@ui/ui_template_progress"; -import { createText } from "@ui/ui_template_text"; import { createButton, hideLoadingOverlay, @@ -42,9 +40,12 @@ import { fetchTerrainExtent, savePlanNetwork, startTerrain, + type PlaceHit, type PlanJob, type PlanNetworkView, type PlanSurface, + type TerrainAim, + type TerrainStart, } from "./D03_PlanAnalysis_Api"; import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw"; @@ -60,6 +61,7 @@ import { createPlanMenu } from "./D03_PlanAnalysis_UI_Menu"; import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points"; import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces"; import { createPlanProps } from "./D03_PlanAnalysis_UI_Props"; +import { createPlanSite } from "./D03_PlanAnalysis_UI_Site"; import { emptyNetwork, fromServer, @@ -76,16 +78,26 @@ const POLL_MS = 1000; const INTERRUPTED = "끊김(서버 다시 켜짐 등)"; /** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */ const SITE_RADIUS_M = 1000; +/** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */ +const COVER_MAX_M = 10000; /** 되돌리기 쌓는 수. */ const UNDO_MAX = 100; -/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름)은 안 읽음. */ -const DRAFT_VERSION = 4; +/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름 · 위치 없음)은 안 읽음. */ +const DRAFT_VERSION = 5; -/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선(되돌리기 · 캐시 · 고침 판정). */ +/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */ +interface Site { + lon: number; + lat: number; + address: string | null; +} + +/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선 + 위치(되돌리기 · 캐시 · 고침 판정). */ interface Snap { network: PlanNetwork; points: Vertex[]; pointNames: string[]; + site: Site | null; } interface Draft extends Snap { @@ -101,10 +113,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { let editable = false; let points: Vertex[] = []; // 지점(긋기에 붙는 점 · 591-2) let pointNames: string[] = []; // 지점 이름(596 · 597-2) + let site: Site | null = null; // 위치(캐시 · [저장] 때 정본 596-2) + let savedSite: Site | null = null; let epsg = 0; let yearStart = new Date().getFullYear(); // 새 선 처음 해 let network: PlanNetwork = emptyNetwork(); - let savedJson = JSON.stringify({ network, points, pointNames }); // 못 읽은 채 떠남 = 고침 없음 + let savedJson = JSON.stringify({ network, points, pointNames, site }); // 못 읽은 채 떠남 = 고침 없음 let undoStack: Snap[] = []; let redoStack: Snap[] = []; let surface: PlanSurface | null = null; @@ -114,6 +128,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음) let busy = false; let failed: string | null = null; + let note = ""; // 위치 칸 알림(기준점 없음 · 못 불러옴) let measuring = false; let lastSaveMs: number | null = null; @@ -128,6 +143,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { refresh(); }, }); + const stepPoints = (nextPoints: Vertex[], next: PlanNetwork, nextNames = pointNames): void => + step({ network: next, points: nextPoints, pointNames: nextNames, site }); // 지점 한 걸음 — 위치 그대로 const edit = createPlanEdit({ network: () => network, base: () => points, @@ -136,7 +153,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { network = normalizePieces(next, yearStart); map.redraw(); }, - commitPoints: (nextPoints, next) => step({ network: next, points: nextPoints, pointNames }), + commitPoints: stepPoints, showPoints: (nextPoints, next) => { [points, network] = [nextPoints, next]; map.redraw(); @@ -154,11 +171,17 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { heightAt: () => z, epsg: () => epsg, points: () => points, - addPoint: (at) => step({ network, points: [...points, at], pointNames: [...pointNames, ""] }), - commitPoints: (nextPoints, next) => step({ network: next, points: nextPoints, pointNames }), + addPoint: (at) => stepPoints([...points, at], network, [...pointNames, ""]), + commitPoints: stepPoints, respan: draw.respan, }); - const pieces = createPlanPieces({ network: () => network, edit }); + const pieces = createPlanPieces({ + network: () => network, + edit, + editable: () => editable, + yearOptions: props.yearOptions, + commit, + }); const measure = createPlanMeasure( () => z, () => network, @@ -168,8 +191,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { network: () => network, names: () => pointNames, epsg: () => epsg, - commit: (nextPoints, nextNames, next) => - step({ network: next, points: nextPoints, pointNames: nextNames }), + commit: (nextPoints, nextNames, next) => stepPoints(nextPoints, next, nextNames), center: () => viewMid, show: (nextPoints, next) => { [points, network] = [nextPoints, next]; @@ -214,30 +236,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { main.className = "d03-plan"; main.append(map.root); - const terrainText = createText("", "muted"); - terrainText.classList.add("d03-plan__hint"); - const retryButton = createButton({ - label: "다시 받기", - variant: "filled", - size: 28, - onClick: () => { + const siteBox = createPlanSite({ + onPick: (hit) => void pickSite(hit), + onRetry: () => { [failed, lastTried, ready] = [null, "", true]; - void ensureCovered(); + void ensureCovered(true); }, + onUpload: (files) => void uploadRoads(files), }); - const terrainCard = createPanelCard("지형", false); - const roadButton = createButton({ - label: "임도망 올리기", - variant: "ghost", - size: 28, - onClick: () => - pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, (files) => { - void backdrop.uploadRoads(files); - }), - }); - roadButton.title = - "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)"; - terrainCard.append(terrainText, retryButton, roadButton); + const siteCard = createPanelCard("위치", false); + siteCard.append(...siteBox.parts); const pointCard = createPanelCard("지점", false); pointCard.append(marks.panel); const drawCard = createPanelCard("그리기", false); @@ -255,7 +263,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { actions.append(saveButton, resetButton); const panel = document.createElement("div"); panel.className = "ui-sidebar-panel d03-plan__panel"; - panel.append(terrainCard, pointCard, drawCard, pieceCard, actions); + panel.append(siteCard, pointCard, drawCard, pieceCard, actions); attachCollapsible(panel); // 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3) mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { @@ -268,7 +276,27 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout"); const allVertices = (): Vertex[] => network.lines.flatMap((line) => line.vertices); - const snap = (): Snap => ({ network, points, pointNames }); + const snap = (): Snap => ({ network, points, pointNames, site }); + /** 위치를 옮기고 [저장] 전 — 받기 · 범위 물음에 새 기준점을 실음. */ + const moved = (): TerrainAim["center"] => + site && JSON.stringify(site) !== JSON.stringify(savedSite) + ? { lon: site.lon, lat: site.lat } + : null; + /** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */ + const around = (at: Site | null): Vertex[] => { + const tm = tmParams(epsg); + if (!at || !tm) return []; + const [x, y] = lonLatToTm(tm, at.lon, at.lat); + return [ + [x - SITE_RADIUS_M, y - SITE_RADIUS_M], + [x + SITE_RADIUS_M, y + SITE_RADIUS_M], + ]; + }; + const fitTo = (box: Vertex[]): void => { + if (!box.length) return; + map.update({ fit: box }); + map.fit(); + }; const dirty = (): boolean => JSON.stringify(snap()) !== savedJson; const keepDraft = (): void => dirty() @@ -279,10 +307,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { function step(next: Snap): void { undoStack = [...undoStack.slice(1 - UNDO_MAX), snap()]; redoStack = []; - [network, points, pointNames] = [ + [network, points, pointNames, site] = [ normalizePieces(next.network, yearStart), next.points, next.pointNames, + next.site, ]; keepDraft(); refresh(); @@ -290,14 +319,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { } function commit(next: PlanNetwork): void { - step({ network: next, points, pointNames }); + step({ network: next, points, pointNames, site }); } function undo(): void { const last = undoStack.pop(); if (!last) return; redoStack.push(snap()); - ({ network, points, pointNames } = last); + ({ network, points, pointNames, site } = last); keepDraft(); refresh(); } @@ -306,7 +335,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const next = redoStack.pop(); if (!next) return; undoStack.push(snap()); - ({ network, points, pointNames } = next); + ({ network, points, pointNames, site } = next); keepDraft(); refresh(); } @@ -336,14 +365,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const inside = (list: Vertex[]): boolean => !!have && list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]); - const outside = (): boolean => !!have && !inside([...points, ...allVertices()]); /** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) · * 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */ - async function ensureCovered(): Promise { + async function ensureCovered(again = false): Promise { if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return; - const extra = [...points, ...allVertices()]; - if (inside(extra)) return; // 받을 때 서버가 여유를 더함 + const [lo, hi] = around(site); + const near = ([x, y]: Vertex): boolean => + !lo || Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M; + const extra = [...points, ...allVertices()].filter(near); + if (!again && inside(extra)) return; // 받을 때 서버가 여유를 더함 const key = JSON.stringify([have, extra]); if (key === lastTried) return; lastTried = key; @@ -364,34 +395,89 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { props.render(); pieces.render(); marks.render(); - retryButton.hidden = !editable || busy || !failed; - roadButton.hidden = !editable; - if (!busy) - terrainText.textContent = failed - ? `지형 받기 실패 — ${failed}` - : !surface - ? editable - ? "받은 지형 없음 — 기준점 둘레를 받습니다(길이 · 기울기는 지형을 받은 뒤)." - : "받은 지형 없음" - : outside() - ? "지점 또는 그은 선이 받은 지형 밖 — 이어 받습니다." - : "받은 지형 안"; + siteBox.render({ + address: site?.address ?? null, + note: failed ? `지형 받기 실패 — ${failed}` : note, + retry: !busy && !!failed, + editable, + }); saveButton.hidden = resetButton.hidden = !editable; saveButton.disabled = busy || !dirty(); } - async function download(extra: Vertex[]): Promise { + /** 받기 — 멂(받은 범위에서 5 km 넘음) · 다른 TM 띠 = 물음 한 번 뒤 옛 범위 버리고 새로(`reset`) · + * 아니오 = 안 받음(거짓). 이미 도는 일(다른 창)이면 그것을 지켜봄. */ + async function download(extra: Vertex[]): Promise { busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음) + const where: TerrainAim = { center: moved() }; try { - await startTerrain(projectId, null, extra); + let answer: TerrainStart = await startTerrain(projectId, null, extra, where); + if (answer.status === "far" || answer.status === "band") { + const band = answer.status === "band"; + const ok = await askConfirm({ + title: band ? "다른 좌표 띠로 옮김" : "멀리 옮김", + message: band + ? `${answer.message} 그은 선 · 지점을 지우고 새 자리 둘레를 받습니다.` + : `${answer.message} 받은 지형을 버리고 새 자리 둘레를 받습니다.`, + okLabel: "새로 받기", + danger: band, + }); + if (!ok) { + busy = false; + refresh(); + return false; + } + if (answer.status === "band") clearForBand(answer.center_epsg); + answer = await startTerrain(projectId, null, [], { ...where, reset: true }); + } } catch (error) { - // 이미 도는 일(다른 창)이면 그것을 지켜봄 if (!/이미 돌고/.test(errorText(error))) { busy = false; - return void showToast(errorText(error), "error"); + refresh(); + showToast(errorText(error), "error"); + return false; } } await follow(); + return true; + } + + /** 다른 TM 띠 — 작업 좌표가 바뀜 · 선 · 지점을 걷고 되돌리기를 비움(옛 좌표라 못 되돌림). */ + function clearForBand(next: number): void { + epsg = next; + [network, points, pointNames] = [emptyNetwork(), [], []]; + [undoStack, redoStack, have] = [[], [], null]; + map.update({ epsg }); + keepDraft(); + } + + /** 위치 칸 고름 — 기준점 캐시(한 걸음 · [저장] 때 정본) → 지도 이동 → 둘레 받기(멂 · 띠 = 물음 · 아니오 = 되돌림). */ + async function pickSite(hit: PlaceHit): Promise { + if (busy) return void showToast("지형을 받는 중 — 끝난 뒤 다시 고르세요.", "info"); + epsg ||= hit.lon < 126 ? 5185 : hit.lon < 128 ? 5186 : 5187; // 기준점 없던 프로젝트(서버 띠 규칙) + [note, failed, lastTried, ready] = ["", null, "", false]; // 걸음의 조용히 받기는 막고 아래에서 한 번 + step({ ...snap(), site: { lon: hit.lon, lat: hit.lat, address: hit.address || hit.title } }); + ready = true; + fitTo(around(site)); + if (await download([])) return; // 새 자리 둘레(center) — 지점 · 선은 끝난 뒤 조용히 받기가 + undo(); // 물음에 아니오 · 받기 못 함 = 위치 그대로 + redoStack.pop(); + fitTo(around(site)); + } + + /** 임도망 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */ + async function uploadRoads(files: File[]): Promise { + const info = await backdrop.uploadRoads(files); + const tm = tmParams(epsg); + if (!info) return; + const roads: Vertex[] = + tm && info.bounds + ? [ + lonLatToTm(tm, info.bounds[0], info.bounds[1]), + lonLatToTm(tm, info.bounds[2], info.bounds[3]), + ] + : []; + fitTo([...around(site), ...roads]); } /** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */ @@ -453,14 +539,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { ? fromServer(server.lines, server.marks, server.pieces, yearStart) : emptyNetwork(); points = view.base.points.map((p): Vertex => [p.x, p.y]); - pointNames = view.base.points.map(() => ""); // 594-1 이름 칸이 들어오면 `p.name ?? ""` + pointNames = view.base.points.map((p) => p.name ?? ""); + site = savedSite = view.base.site; if (view.base.epsg) epsg = view.base.epsg; savedJson = JSON.stringify(snap()); } function serverBody(): Parameters[1] { const { lines, pieces } = network; - return { epsg, points, lines, marks: marksOf(network), pieces }; + const body = { epsg, points, point_names: pointNames, lines, marks: marksOf(network), pieces }; + return moved() && site ? { ...body, site } : body; } /** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */ @@ -497,7 +585,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { draw.setMode("idle"); showLoadingOverlay(); try { - const body = { epsg, points, lines: [], marks: [], pieces: [] }; + const body = { epsg, points, point_names: pointNames, lines: [], marks: [], pieces: [] }; takeServer(await savePlanNetwork(projectId, body)); clearState("d03-plan", projectId); undoStack = []; @@ -530,6 +618,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { base: () => points, have: () => have, busy: () => busy, + site: () => site, + pickSite, lineNames: () => lineNames(network.lines), screenOf: map.screenOf, screenOfLonLat: map.screenOfLonLat, @@ -576,26 +666,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { editable = !!(await fetchSessionUser())?.is_forest_service; draw.setEditable(editable); const view = await fetchPlanNetwork(projectId); - const { epsg: workEpsg, site } = view.base; - if (!workEpsg) { - terrainText.textContent = "기준점이 없습니다 — 예정노선 등록에서 정하세요."; - return; + if (!view.base.epsg) { + note = "기준점이 없습니다 — 주소를 찾아 정하세요."; + ready = true; + return refresh(); } - epsg = workEpsg; + epsg = view.base.epsg; takeServer(view); const draft = readState("d03-plan", projectId); if (draft?.version === DRAFT_VERSION && draft.network && Array.isArray(draft.points)) - ({ network, points, pointNames } = draft); - const tm = tmParams(epsg); - const siteXy = site && tm ? lonLatToTm(tm, site.lon, site.lat) : null; - const around: Vertex[] = siteXy - ? [ - [siteXy[0] - SITE_RADIUS_M, siteXy[1] - SITE_RADIUS_M], - [siteXy[0] + SITE_RADIUS_M, siteXy[1] + SITE_RADIUS_M], - ] - : []; + ({ network, points, pointNames, site } = draft); const drawn = [...points, ...allVertices()]; - map.update({ fit: drawn.length ? drawn : around, epsg }); + map.update({ fit: drawn.length && !moved() ? drawn : around(site), epsg }); refresh(); await loadSurface(); refresh(); @@ -606,12 +688,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { await follow(); } else if (editable && !job.error) { // 들어올 때 받은 범위가 기준점 둘레 · 지점을 못 덮으면 바로 받기(서버가 셈 · 실패 뒤엔 [다시 받기]) - const extent = await fetchTerrainExtent(projectId, null, []); + const extent = await fetchTerrainExtent(projectId, null, [], moved()); ready = true; if (!extent.covered) await download([]); else await ensureCovered(); } } catch (error) { - terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`; + note = `불러오지 못했습니다 — ${errorText(error)}`; + refresh(); } } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts index 9cca359f9..c0824ac84 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts @@ -1,13 +1,26 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Pieces.ts - * D03 좌측 「구역 · 연도」 목록 몸(PLAN 597) — 해 줄(같은 해) · 선 머리 줄(선) · 구간 줄(구간). - * 속성 판(`UI_Props.ts`)에서 떼어냄 — 목록 = 보기 + 고르기. + * D03 좌측 「구역 · 연도」 목록 몸(PLAN 597 · 요구 463 14) — 구역 = 그은 꺾은선 하나(본선 · 갈래). + * 해 줄(같은 해) · 구역 줄(색 띠 = 지도 선과 같은 `routeColor(차례)` · 이름 칸 · 연도 고름 · 지움) · + * 구간 줄(측점 글 작게 · 연도 고름). 누름 = 고름 · 해 고침 · 이름 · 지움은 한 걸음(되돌리기 · 캐시 = 페이지). + * 연도 고름은 속성 판과 같은 셈(`setLineYear` · `setPiecesYear`) · 보기만 계정 = 값만. * ========================================================================== */ -import { createText } from "@ui/ui_template_text"; +import { createIconButton } from "@ui/ui_template_button"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { createListRow } from "@ui/ui_template_list_row"; +import { askConfirm } from "@ui/ui_template_modal"; +import { routeColor } from "@ui/ui_template_palette"; 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 { + lineNames, + nameError, + setLineYear, + setPiecesYear, + type PlanNetwork, +} from "./logic/D03_PlanAnalysis_Plan"; +import { setLineName } from "./logic/D03_PlanAnalysis_PointEdit"; import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit"; import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; @@ -17,6 +30,10 @@ const meters = (m: number): string => `${m.toFixed(1)} m`; export interface PlanPiecesParams { network: () => PlanNetwork; edit: PlanEdit; + editable: () => boolean; + /** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */ + yearOptions: () => Array<{ value: string; text: string }>; + commit: (next: PlanNetwork) => void; } export interface PlanPieces { @@ -25,79 +42,190 @@ export interface PlanPieces { 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"; + let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기) + let syncs: Array<() => void> = []; + + /** 줄 안 칸 눌림은 줄 고름으로 안 번지게. */ + const isControl = (event: Event): boolean => + !!(event.target as Element).closest("input, select, button, .ui-list-row__trailing"); + + /** 줄 하나 — 누름 = 고름(열쇠 Enter 도) · 줄 안 칸은 제외. */ + const pickable = (row: HTMLElement, onPick: () => void): HTMLElement => { + row.tabIndex = 0; + row.addEventListener("click", (event) => isControl(event) || onPick()); + row.addEventListener("keydown", (event) => { + if (event.key === "Enter" && event.target === row) onPick(); + }); + return row; + }; + + /** 연도 고름 칸 — 값이 섞이면 「여러 해」. */ + function yearSelect( + years: () => number[], + label: string, + pick: (year: number) => void, + ): { root: HTMLElement; sync: () => void } { + const field = createSelectField({ + options: [], + size: 24, + ariaLabel: label, + disabled: !params.editable(), + onChange: (value) => value && pick(Number(value)), + }); + const sync = (): void => { + const set = new Set(years()); + const options = params.yearOptions(); + field.setOptions( + set.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options, + set.size > 1 ? "" : String([...set][0]), + ); + }; + sync(); + return { root: field.root, sync }; + } 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 editable = params.editable(); + const key = JSON.stringify([ + network.lines.map((line) => line.id), + network.pieces.map((piece) => [piece.id, piece.line]), + [...picked], + scope, + editable, + params.yearOptions().length, + ]); + if (key === shown) { + syncs.forEach((sync) => sync()); + return; + } + shown = key; + syncs = []; 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, () => { + const row = createListRow({ + label: `${year}년`, + sub: `구간 ${pieces.length} · ${meters(length)}`, + size: 28, + active: scope === "year" && pieces.every((p) => picked.has(p.id)), + onClick: () => { edit.select([pieces[0].id]); edit.setScope("year"); - }), - ); + }, + }); + row.dataset.year = String(year); + rows.push(row); } + 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)), + const lineNow = (): (typeof network.lines)[number] => + params.network().lines.find((entry) => entry.id === line.id) ?? line; + const piecesNow = (): typeof pieces => + params.network().pieces.filter((piece) => piece.line === line.id); + + const nameField = createInputField({ size: 24, placeholder: names[index] }); + nameField.input.value = line.name ?? ""; + nameField.input.setAttribute("aria-label", "구역 이름"); + nameField.input.dataset.role = "line-name"; + nameField.input.disabled = !editable; + nameField.input.addEventListener("input", () => + nameField.setError(nameError(nameField.input.value) ?? undefined), ); - head.append(createText(`${names[index]} · ${meters(lineLength(line.vertices))}`, "muted")); + nameField.input.addEventListener("change", () => { + const error = nameError(nameField.input.value); + nameField.setError(error ?? undefined); + const text = nameField.input.value.trim(); + if (!error && text !== (lineNow().name ?? "")) + params.commit(setLineName(params.network(), line.id, text)); + }); + + const year = yearSelect( + () => piecesNow().map((piece) => piece.year), + "구역 연도", + (value) => params.commit(setLineYear(params.network(), line.id, value)), + ); + year.root.dataset.role = "line-year"; + const drop = createIconButton({ + icon: "trash", + title: "구역 지움", + size: 24, + onClick: async () => { + const ok = await askConfirm({ + message: `「${lineNames(params.network().lines)[index]}」 구역을 지울까요?`, + okLabel: "지움", + danger: true, + }); + if (!ok) return; + edit.dropRegion(line.id); + }, + }); + drop.dataset.role = "line-drop"; + drop.hidden = !editable; + const tail = document.createElement("span"); + tail.className = "d03-plan__line-tail"; + tail.append(year.root, drop); + + const head = createListRow({ + label: "", + sub: `구간 ${pieces.length} · ${meters(lineLength(line.vertices))}`, + stripe: routeColor(index), + size: 28, + trailing: tail, + active: scope === "line" && pieces.every((p) => picked.has(p.id)), + }); + head.querySelector(".ui-list-row__label")?.replaceChildren(nameField.root); + head.dataset.line = line.id; rows.push( - pickRow(head, () => { + pickable(head, () => { edit.select([pieces[0].id]); edit.setScope("line"); }), ); + syncs.push(() => { + year.sync(); + nameField.input.placeholder = lineNames(params.network().lines)[index] ?? ""; + if (document.activeElement !== nameField.input) + nameField.input.value = lineNow().name ?? ""; + const sub = head.querySelector(".ui-list-row__sub"); + if (sub) + sub.textContent = `구간 ${piecesNow().length} · ${meters(lineLength(lineNow().vertices))}`; + }); + 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"), + const pieceNow = (): (typeof pieces)[number] => + params.network().pieces.find((entry) => entry.id === piece.id) ?? piece; + const pieceYear = yearSelect( + () => [pieceNow().year], + "구간 연도", + (value) => params.commit(setPiecesYear(params.network(), [piece.id], value)), ); - rows.push(pickRow(row, () => edit.select([piece.id]))); + const row = createListRow({ + label: `${station(piece.from_m)} ~ ${station(piece.to_m)}`, + sub: meters(piece.to_m - piece.from_m), + size: 28, + trailing: pieceYear.root, + active: picked.has(piece.id), + }); + row.classList.add("d03-plan__piece-row"); + row.dataset.piece = piece.id; + rows.push(pickable(row, () => edit.select([piece.id]))); + syncs.push(pieceYear.sync); } }); - if (!rows.length) rows.push(createText("선을 그으면 구역이 생깁니다.", "muted")); list.replaceChildren(...rows); } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts index bd52f069b..18e0b57d1 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts @@ -1,7 +1,8 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Points.ts * D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로. - * 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`). + * 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 이름 · 🗑) · 핀 = 늘 맨 위(`pointLayer`). + * [추가] = 지도 보기 가운데에 바로 놓음(597-2) · 줄 누름 = 그 줄만 펼침(이름 · 경도 · 위도 — 칸 떠남 · Enter 가 한 걸음). * 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 노드 12 px 안에 놓으면 그 노드에 붙음(598-3) · * 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지). * 지도 우클릭 메뉴 = `D03_PlanAnalysis_UI_Menu`(핀 집기 · 놓기 · 지우기를 여기서 빌림). @@ -9,21 +10,30 @@ * ========================================================================== */ import { createIconButton } from "@ui/ui_template_button"; +import { createButton } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; import { createListRow } from "@ui/ui_template_list_row"; import { pointColor } from "@ui/ui_template_palette"; -import { createText } from "@ui/ui_template_text"; import { moveNodesAt, + nameError, samePoint, type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; +import { + addPoint, + MAX_POINTS, + movePointTo, + parseLonLat, + pointLabel, + renamePoint, + type PointsState, +} from "./logic/D03_PlanAnalysis_PointEdit"; import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm"; import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; -/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */ -const MAX_POINTS = 40; /** 핀 머리 = 꼭지 위 px(`pointLayer` 그림과 같음). */ const PIN_HEAD_PX = 18; @@ -61,10 +71,22 @@ export interface PlanPoints { export function createPlanPoints(params: PlanPointsParams): PlanPoints { const list = document.createElement("div"); list.className = "d03-plan__points"; - const hint = createText("", "muted"); - hint.classList.add("d03-plan__hint"); + const addButton = createButton({ + label: "추가", + iconStart: "plus", + variant: "ghost", + size: 24, + onClick: addAtCenter, + }); + addButton.dataset.role = "points-add"; + const head = document.createElement("div"); + head.className = "d03-plan__points-head"; + head.append(addButton); const panel = document.createElement("div"); - panel.append(list, hint); + panel.append(head, list); + let openIndex: number | null = null; // 펼친 줄(하나만) + let syncEditor: (() => void) | null = null; + let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기) let drag: { index: number; points: Vertex[]; @@ -72,7 +94,14 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { last: [Vertex[], PlanNetwork] | null; } | null = null; - const name = (i: number): string => `지점 ${i + 1}`; + const name = (i: number): string => pointLabel(params.names(), i); + const state = (): PointsState => ({ + points: params.points(), + names: params.names(), + network: params.network(), + }); + const commitState = (next: PointsState): void => + params.commit(next.points, next.names, next.network); /** 핀 집기 — 꼭지 · 머리(보이는 화면 위쪽) 둘레 · 뒤 지점(위에 그려짐) 먼저. */ function pinAt(px: number, py: number, map: ScreenMap): number | null { @@ -91,6 +120,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { } function remove(i: number): void { + openIndex = null; params.commit( params.points().filter((_, k) => k !== i), params.names().filter((_, k) => k !== i), @@ -117,7 +147,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { params.commit([...params.points(), [at[0], at[1]]], [...params.names(), ""], params.network()); } - const pins = pointLayer(params.points); + const pins = pointLayer(params.points, params.names); const input: PlanMapLayer = { draw: () => undefined, @@ -145,12 +175,101 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { }, }; + /** [추가] — 지도 보기 가운데에 바로 놓고 그 줄을 펼침. */ + function addAtCenter(): void { + const at = params.center(); + if (!at || !params.editable() || params.points().length >= MAX_POINTS) return; + openIndex = params.points().length; + commitState(addPoint(state(), at)); + } + + /** 펼친 줄 — 이름 · 경도 · 위도 칸(Enter · 칸 떠남 = 한 걸음 · 잘못 쓴 값은 까닭만 보이고 안 받음). */ + function editorOf(i: number): { root: HTMLElement; sync: () => void } { + const tm = tmParams(params.epsg()); + const editable = params.editable(); + const field = (label: string, role: string, placeholder?: string) => { + const f = createInputField({ label, size: 24, placeholder }); + f.input.dataset.role = role; + f.input.disabled = !editable || (role !== "point-name" && !tm); + return f; + }; + const nameField = field("이름", "point-name", `지점 ${i + 1}`); + const lonField = field("경도", "point-lon"); + const latField = field("위도", "point-lat"); + const lonLatNow = (): [number, number] | null => { + const p = params.points()[i]; + return tm && p ? tmToLonLat(tm, p[0], p[1]) : null; + }; + const sync = (): void => { + const set = (f: ReturnType, text: string): void => { + if (document.activeElement !== f.input) f.input.value = text; + }; + set(nameField, params.names()[i] ?? ""); + const ll = lonLatNow(); + set(lonField, ll ? ll[0].toFixed(6) : ""); + set(latField, ll ? ll[1].toFixed(6) : ""); + }; + nameField.input.addEventListener("input", () => + nameField.setError(nameError(nameField.input.value.trim()) ?? undefined), + ); + nameField.input.addEventListener("change", () => { + const text = nameField.input.value.trim(); + const error = nameError(text); + nameField.setError(error ?? undefined); + if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text)); + }); + const moved = (): void => { + const parsed = parseLonLat(lonField.input.value, latField.input.value); + const bad = typeof parsed === "string"; + lonField.setError(bad && parsed.startsWith("경도") ? parsed : undefined); + latField.setError(bad && parsed.startsWith("위도") ? parsed : undefined); + if (bad || !tm) return; + const now = lonLatNow(); + if ( + now && + now[0].toFixed(6) === parsed[0].toFixed(6) && + now[1].toFixed(6) === parsed[1].toFixed(6) + ) + return; + commitState(movePointTo(state(), i, tm, parsed[0], parsed[1])); + }; + lonField.input.addEventListener("change", moved); + latField.input.addEventListener("change", moved); + const root = document.createElement("div"); + root.className = "d03-plan__point-edit"; + root.append(nameField.root, lonField.root, latField.root); + sync(); + return { root, sync }; + } + function render(): void { const tm = tmParams(params.epsg()); const editable = params.editable(); + const points = params.points(); + if (openIndex !== null && openIndex >= points.length) openIndex = null; + addButton.hidden = !editable; + addButton.disabled = points.length >= MAX_POINTS || !params.center(); + const key = `${points.length}|${openIndex}|${editable}|${tm ? 1 : 0}`; + const lonLatOf = (i: number): string | undefined => { + const ll = tm ? tmToLonLat(tm, points[i][0], points[i][1]) : null; + return ll ? `${ll[0].toFixed(6)}, ${ll[1].toFixed(6)}` : undefined; + }; + if (key === shown) { + // 틀 그대로 — 이름 · 좌표 글과 펼친 칸 값만 갈아 끼움 + points.forEach((_, i) => { + const row = list.children[i]?.querySelector(".ui-list-row"); + const label = row?.querySelector(".ui-list-row__label"); + const sub = row?.querySelector(".ui-list-row__sub"); + if (label) label.textContent = name(i); + if (sub) sub.textContent = lonLatOf(i) ?? ""; + }); + syncEditor?.(); + return; + } + shown = key; + syncEditor = null; list.replaceChildren( - ...params.points().map((point, i) => { - const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null; + ...points.map((_, i) => { const drop = createIconButton({ icon: "trash", title: `${name(i)} 지우기`, @@ -158,22 +277,36 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { onClick: () => remove(i), }); drop.hidden = !editable; - return createListRow({ + const row = createListRow({ label: name(i), - sub: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : undefined, + sub: lonLatOf(i), stripe: pointColor(i), size: 28, trailing: drop, + active: i === openIndex, }); + row.dataset.point = String(i); + row.tabIndex = 0; + const toggle = (): void => { + openIndex = openIndex === i ? null : i; + render(); + }; + row.addEventListener("click", (event) => { + if (!(event.target as Element).closest(".ui-list-row__trailing")) toggle(); + }); + row.addEventListener("keydown", (event) => { + if (event.key === "Enter" && event.target === row) toggle(); + }); + const item = document.createElement("div"); + item.append(row); + if (i === openIndex) { + const editor = editorOf(i); + syncEditor = editor.sync; + item.append(editor.root); + } + return item; }), ); - hint.textContent = !editable - ? params.points().length - ? "보기만 — 지점은 산림청이 놓습니다." - : "지점 없음" - : params.points().length - ? "핀을 끌면 그 자리 노선 점도 같이 옮깁니다 · 지도 우클릭 「지점 놓기」로 더함." - : "지도 우클릭 「지점 놓기」 — 긋기가 붙는 점(없어도 우클릭으로 긋기 시작)."; } return { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts new file mode 100644 index 000000000..1670ccf37 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts @@ -0,0 +1,114 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Site.ts + * D03 「위치」 칸(PLAN 596-2 · 요구 463-3~6 · 19) — 지금 기준점 주소 한 줄 · 주소 · 장소 찾기 + * (서버 VWorld `searchPlaces` · 부품 `createFindList` · Enter = 찾기 · ↑ ↓ Enter = 고름) · + * 알림 한 줄(받기 실패 · 끊김 · 기준점 없음 · 못 불러옴만) + [다시 받기] · [임도망 올리기]. + * 고름 = 페이지가 받기 물음(멂 · 다른 TM 띠) → 기준점 캐시 → 지도 이동 → 둘레 받기 · [저장] 때 정본(등록과 한 칸). + * ========================================================================== */ + +import { pickFiles } from "@ui/ui_template_drop"; +import { createButton } from "@ui/ui_template_elements"; +import { createFindList, type FindList } from "@ui/ui_template_find_list"; +import { t } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; +import { searchPlaces, type PlaceHit } from "./D03_PlanAnalysis_Api"; + +/** 찾기 글 최소 길이(서버 VWorld 한 글자 = 쓸모없는 후보). */ +const MIN_QUERY = 2; + +export interface PlanSiteParams { + /** 후보 고름 — 받기 · 캐시 · 이동은 페이지. */ + onPick: (hit: PlaceHit) => void; + onRetry: () => void; + onUpload: (files: File[]) => void; +} + +export interface PlanSiteState { + address: string | null; + /** 알림(실패 · 끊김 · 기준점 없음 · 오류) — 빈 값 = 숨김. */ + note: string; + retry: boolean; + editable: boolean; +} + +export interface PlanSite { + /** 칸 몸(카드에 바로 붙임 · 카드 간격을 그대로 씀). */ + parts: HTMLElement[]; + render: (state: PlanSiteState) => void; +} + +/** 주소 · 장소 찾기 칸(부품 `createFindList`) — Enter = 찾기 · ↑ ↓ Enter · 누름 = 고름 · + * 등록(D01) 지도 머리 · 이 「위치」 칸이 같이 씀. */ +export function createPlaceFind(onPick: (hit: PlaceHit) => void): FindList { + const find = createFindList({ placeholder: t("DE_Reg_Find") }); + let asked = 0; // 옛 찾기 답 버림 + + async function run(): Promise { + const query = find.search.value.trim(); + find.setRows([]); + if (query.length < MIN_QUERY) return void (find.hint.textContent = t("DE_Reg_FindShort")); + const at = ++asked; + find.hint.textContent = t("DE_Reg_FindBusy"); + try { + const hits = await searchPlaces(query); + if (at !== asked) return; + find.hint.textContent = hits.length ? "" : t("DE_Reg_FindNone"); + find.setRows( + hits.map((hit, i) => ({ + key: String(i), + tag: t(hit.kind === "place" ? "DE_Reg_FindPlace" : "DE_Reg_FindAddress"), + name: hit.title, + sub: hit.address !== hit.title ? hit.address : undefined, + onPick: () => { + find.setRows([]); + find.search.value = hit.title; + onPick(hit); + }, + })), + ); + } catch (error) { + if (at === asked) + find.hint.textContent = `${t("DE_Reg_FindFail")} — ${error instanceof Error ? error.message : error}`; + } + } + // 고를 줄이 있으면 부품이 Enter 를 먼저 씀(고름) · 없으면 찾기 · 글을 고치면 옛 줄 걷음 + find.search.addEventListener("input", () => find.setRows([])); + find.search.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || event.defaultPrevented || event.isComposing) return; + event.preventDefault(); + void run(); + }); + return find; +} + +export function createPlanSite(params: PlanSiteParams): PlanSite { + const address = createText("", "muted", 14); + const note = createText("", "error"); + const find = createPlaceFind(params.onPick); + + const retry = createButton({ + label: "다시 받기", + variant: "filled", + size: 28, + onClick: params.onRetry, + }); + const upload = createButton({ + label: "임도망 올리기", + variant: "ghost", + size: 28, + onClick: () => + pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, params.onUpload), + }); + upload.title = "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)"; + return { + parts: [address, find.root, note, retry, upload], + render(state) { + address.textContent = state.address ?? ""; + address.hidden = !state.address; + note.textContent = state.note; + note.hidden = !state.note; + find.root.hidden = upload.hidden = !state.editable; + retry.hidden = !state.editable || !state.retry; + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts new file mode 100644 index 000000000..fb0cb92f1 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts @@ -0,0 +1,236 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_SlopeBands.ts + * D03 급경사 범위 옵션(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달 + * (범위 줄 = 아래 % · 위 % · 색 · 지움 · 칸 옆 ° 환산 · [범위 추가] · [기본값] · [적용] · 빈 값 · 겹침 막음). + * 기억 = 화면 취향(`aislo:pref:d03.slope-bands` · 계정에 붙음 · 프로젝트 무관). + * 칠하기 = 서버 값 그림(회색 · 칸 = 기울기 % 내림) → 범위 색 그림(서버 왕복 없음 · CLAUDE.md 5장). + * 범위 · 비율 셈 = `logic/D03_PlanAnalysis_SlopeBands.ts` · 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`. + * ========================================================================== */ + +import { createButton, createIconButton } from "@ui/ui_template_button"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { createLegend } from "@ui/ui_template_flow_legend"; +import { openModal } from "@ui/ui_template_modal"; +import { themeColor } from "@ui/ui_template_palette"; +import { createText } from "@ui/ui_template_text"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; +import { loadImage } from "./D03_PlanAnalysis_UI_LayerDefs"; +import { + DEFAULT_SLOPE_BANDS, + SLOPE_COLORS, + SLOPE_MAX_BANDS, + SLOPE_MAX_PCT, + bandOf, + checkBands, + parseBands, + pctToDeg, + type SlopeBand, +} from "./logic/D03_PlanAnalysis_SlopeBands"; + +const PREF_KEY = "aislo:pref:d03.slope-bands"; +/** 칠한 칸 투명도(겹 투명도 0.55 는 따로). */ +const ALPHA = 150; + +export const readSlopeBands = (): SlopeBand[] => parseBands(readByKey(PREF_KEY)); +export const writeSlopeBands = (bands: SlopeBand[]): void => + writeByKey(PREF_KEY, JSON.stringify(bands)); + +/** 값 그림 → 칸 값(빨강 자리 = 회색 값). */ +export function slopeValues(image: HTMLImageElement): ImageData { + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d", { willReadFrequently: true })!; + context.drawImage(image, 0, 0); + return context.getImageData(0, 0, canvas.width, canvas.height); +} + +/** 범위대로 칠한 그림(주소 = blob · 다 쓰면 부르는 쪽이 `URL.revokeObjectURL`). */ +export async function paintSlope( + values: ImageData, + bands: SlopeBand[], +): Promise<{ url: string; image: HTMLImageElement }> { + const rgb = bands.map((band) => hexRgb(themeColor(band.color, "#facc15"))); + const lut = new Uint32Array(256); + const view = new DataView(lut.buffer); + for (let v = 0; v < 256; v++) { + const i = bandOf(v, bands); + if (i >= 0) view.setUint32(v * 4, ((rgb[i] << 8) | ALPHA) >>> 0, false); + } + const out = new ImageData(values.width, values.height); + const src = values.data; + const dst = new Uint32Array(out.data.buffer); + for (let p = 0; p < dst.length; p++) dst[p] = lut[src[p * 4]]; + const canvas = document.createElement("canvas"); + canvas.width = values.width; + canvas.height = values.height; + canvas.getContext("2d")!.putImageData(out, 0, 0); + const blob = await new Promise((done, fail) => + canvas.toBlob( + (got) => (got ? done(got) : fail(new Error("그림을 만들지 못했습니다"))), + "image/png", + ), + ); + const url = URL.createObjectURL(blob); + return { url, image: await loadImage(url) }; +} + +/** `#rrggbb` → 0xrrggbb(못 읽으면 노랑). */ +function hexRgb(color: string): number { + const hex = /^#?([0-9a-f]{6})$/i.exec(color.trim())?.[1]; + return hex ? parseInt(hex, 16) : 0xfacc15; +} + +/** 범례 급경사 줄 ⚙. */ +export const slopeGear = (onClick: () => void): HTMLButtonElement => { + const button = createIconButton({ + icon: "settings", + title: "급경사 범위 바꾸기(기울기 %)", + size: 20, + onClick, + }); + button.dataset.role = "slope-bands"; + return button; +}; + +const degText = (value: string): string => { + const pct = Number(value); + return value.trim() && Number.isFinite(pct) ? `≈ ${pctToDeg(pct).toFixed(1)}°` : "—"; +}; + +/** 옵션 모달 — [적용] = 막을 것 없으면 `onApply` 후 닫음. */ +export function openSlopeBands(current: SlopeBand[], onApply: (bands: SlopeBand[]) => void): void { + let rows: Array<{ root: HTMLElement; read: () => SlopeBand }> = []; + const list = document.createElement("div"); + const error = createText("", "error"); + error.hidden = true; + const addButton = createButton({ + label: "범위 추가", + variant: "ghost", + size: 28, + iconStart: "plus", + onClick: () => { + const last = rows.at(-1)?.read(); + const low = last && Number.isFinite(last.high) ? Math.min(last.high, SLOPE_MAX_PCT - 1) : 0; + const color = SLOPE_COLORS[rows.length % SLOPE_COLORS.length].color; + add({ low, high: SLOPE_MAX_PCT, color }); + }, + }); + addButton.dataset.role = "slope-band-add"; + + function refresh(): void { + addButton.disabled = rows.length >= SLOPE_MAX_BANDS; + error.hidden = true; + } + + function add(band: SlopeBand): void { + const field = (label: string, value: number) => { + const got = createInputField({ + label, + type: "number", + unit: "%", + min: 0, + max: SLOPE_MAX_PCT, + size: 28, + value: Number.isFinite(value) ? String(value) : "", + }); + const deg = createText(degText(got.input.value), "muted"); + got.input.addEventListener("input", () => { + deg.textContent = degText(got.input.value); + refresh(); + }); + got.root.append(deg); + return got; + }; + const low = field("아래", band.low); + const high = field("위", band.high); + const color = createSelectField({ + label: "색", + size: 28, + options: SLOPE_COLORS.map((c) => ({ value: c.color, text: c.name })), + value: band.color, + }); + const chip = createLegend([{ label: "", color: `var(${band.color})` }]); + color.select.addEventListener("change", () => + chip + .querySelector(".ui-legend__mark")! + .style.setProperty("--legend-color", `var(${color.select.value})`), + ); + const root = document.createElement("div"); + root.className = "d03-plan__row"; + root.dataset.role = "slope-band"; + const entry = { + root, + read: (): SlopeBand => ({ + low: low.input.value.trim() === "" ? NaN : Number(low.input.value), + high: high.input.value.trim() === "" ? NaN : Number(high.input.value), + color: color.select.value, + }), + }; + const remove = createIconButton({ + icon: "trash", + title: "이 범위 지움", + size: 28, + onClick: () => { + rows = rows.filter((row) => row !== entry); + root.remove(); + refresh(); + }, + }); + root.append(low.root, high.root, color.root, chip, remove); + rows.push(entry); + list.append(root); + refresh(); + } + + function fill(bands: SlopeBand[]): void { + rows = []; + list.replaceChildren(); + bands.forEach(add); + } + + openModal({ + title: "급경사 범위(기울기 %)", + closeLabel: "닫기", + width: "40rem", + mount: (body, close, actions) => { + fill(current); + const reset = createButton({ + label: "기본값", + variant: "ghost", + size: 28, + onClick: () => fill(DEFAULT_SLOPE_BANDS), + }); + reset.dataset.role = "slope-band-default"; + const apply = createButton({ + label: "적용", + size: 28, + onClick: () => { + const bands = rows.map((row) => row.read()); + const why = checkBands(bands); + if (why) { + error.lastChild!.textContent = why; + error.hidden = false; + return; + } + onApply(bands.sort((a, b) => a.low - b.low)); + close(); + }, + }); + apply.dataset.role = "slope-band-apply"; + actions.append(apply); + const foot = document.createElement("div"); + foot.className = "d03-plan__row"; + foot.append(addButton, reset); + body.append( + createText( + `0~${SLOPE_MAX_PCT} % · 아래 포함 · 위 뺌 · 겹치면 안 됨 · 기본 = 15 · 35 · 40°(지식DB)`, + "hint", + ), + list, + foot, + error, + ); + }, + }); +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index ed11d04f3..617aaca50 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -106,28 +106,6 @@ gap: 4px; } -.d03-plan__piece-head { - margin: 6px 0 0; - font-weight: 600; - color: var(--color-text); -} - -.d03-plan__piece { - display: flex; - gap: var(--spacing-8); - align-items: center; - justify-content: space-between; -} - -.d03-plan__piece > .ui-text { - margin: 0; -} - -.d03-plan__piece > .ui-field { - flex: 0 0 6.5rem; - margin: 0; -} - /* 지형 받기 범위 미리보기 — 카카오 지도 */ .d03-plan__extent > .ui-text { margin: 0 0 6px; @@ -152,12 +130,6 @@ margin-bottom: 6px; } -/* 580-3 고른 선 · 조각 줄 밝힘 */ -.d03-plan__piece.is-selected { - background: var(--color-accent-soft); - border-radius: var(--radius-md); -} - /* 585-2 고른 구간 속성 판 — 도구줄 아래 오른쪽 · 몸 스크롤 · 구간 줄 = 고스트 단추 */ .d03-plan__props { top: calc(var(--spacing-8) * 6); @@ -188,12 +160,22 @@ justify-content: flex-start; } -.d03-plan__piece[role="button"] { - cursor: pointer; +/* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */ +.d03-plan__points-head { + display: flex; + justify-content: flex-end; + margin-bottom: var(--spacing-8); } -.d03-plan__piece-year { - font-weight: 600; +.d03-plan__point-edit { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-8) var(--spacing-8) calc(var(--spacing-8) * 2); +} + +.d03-plan__point-edit > .ui-field { + margin: 0; } /* 599 그리기 칸 — 방식 칸(마디 고름 2 × 2 · 길이 · 기울기 두 열) */ @@ -213,3 +195,22 @@ .d03-plan__draw-fields .ui-field { margin: 0; } + +/* 597 구역 · 연도 목록 — 구역 줄(이름 칸 · 연도 고름 · 지움) · 구간 줄(측점 글 작게) */ +.d03-plan__line-tail { + display: flex; + gap: var(--spacing-4); + align-items: center; +} + +.d03-plan__pieces .ui-list-row__label > .ui-field { + margin: 0; +} + +.d03-plan__piece-row { + padding-left: calc(var(--spacing-8) * 2); +} + +.d03-plan__piece-row .ui-list-row__label { + font-size: var(--text-caption); +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts new file mode 100644 index 000000000..6cb3c8aad --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts @@ -0,0 +1,77 @@ +/* ============================================================================= + * D03_PlanAnalysis_PointEdit.ts + * D03 지점 칸 손 고침 계산(PLAN 597-2 · 요구 463 7 · 8) — 화면 · fetch 없음(logic/). + * 이름 규칙 = `Plan.ts` 의 `nameError`(20 자 · 파일 이름 못 쓰는 글자 · 서버 400 짝) · 빈 값 = 「지점 N」 보임. + * 좌표 = 경위도 손 고침 → 작업 좌표(TM) · 그 자리 노선 노드도 같이(`moveNodesAt`). + * ========================================================================== */ + +import { moveNodesAt, type PlanNetwork, type Vertex } from "./D03_PlanAnalysis_Plan"; +import { lonLatToTm, type TmParams } from "./D03_PlanAnalysis_Tm"; + +/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */ +export const MAX_POINTS = 40; + +/** 보이는 이름 — 빈 값 = 「지점 N」. */ +export function pointLabel(names: string[], index: number): string { + return names[index]?.trim() || `지점 ${index + 1}`; +} + +/** 경위도 글 → 값 · 못 읽거나 범위 밖이면 까닭 글. */ +export function parseLonLat(lonText: string, latText: string): [number, number] | string { + const lon = Number(lonText.trim()); + const lat = Number(latText.trim()); + if (!lonText.trim() || !Number.isFinite(lon) || Math.abs(lon) > 180) return "경도는 −180 ~ 180"; + if (!latText.trim() || !Number.isFinite(lat) || Math.abs(lat) > 90) return "위도는 −90 ~ 90"; + return [lon, lat]; +} + +export interface PointsState { + points: Vertex[]; + names: string[]; + network: PlanNetwork; +} + +/** 지점 더함 — 이름 비움. 상한이면 그대로. */ +export function addPoint(state: PointsState, at: Vertex): PointsState { + if (state.points.length >= MAX_POINTS) return state; + return { + ...state, + points: [...state.points, [at[0], at[1]]], + names: [...state.names, ""], + }; +} + +/** 이름 바꿈 — 빈 값 허용. */ +export function renamePoint(state: PointsState, index: number, name: string): PointsState { + const names = state.points.map((_, k) => state.names[k] ?? ""); + names[index] = name.trim(); + return { ...state, names }; +} + +/** 경위도로 옮김 — 그 자리 노선 노드도 같이. */ +export function movePointTo( + state: PointsState, + index: number, + tm: TmParams, + lon: number, + lat: number, +): PointsState { + const to = lonLatToTm(tm, lon, lat); + const from = state.points[index]; + return { + ...state, + points: state.points.map((p, k): Vertex => (k === index ? to : p)), + network: moveNodesAt(state.network, from, to), + }; +} + +/** 선 이름 바꿈(597-3) — 빈 값 = 차례 이름으로 돌아감(`name` 비움). */ +export function setLineName(network: PlanNetwork, lineId: string, name: string): PlanNetwork { + const text = name.trim(); + return { + ...network, + lines: network.lines.map((line) => + line.id === lineId ? { ...line, name: text || null } : line, + ), + }; +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts new file mode 100644 index 000000000..2fa0ab653 --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts @@ -0,0 +1,98 @@ +/* ============================================================================= + * D03_PlanAnalysis_SlopeBands.ts + * D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 기울기 % 범위 · 색 · 비율 셈(화면 · 서버 없이). + * 서버 값 그림 칸 = 기울기 % 내림(0~250 · 250 = 250 % 이상 · `D03_PlanAnalysis_Layers.slope_values_png`) · + * 칸 v 는 가운데(v + 0.5)가 [아래, 위) 안이면 그 범위(위 = 250 이면 250 % 이상도) · 앞 범위 먼저. + * 기본 = 지식DB 15 · 35 · 40° 의 % — `임도망_편성.md:20` · `타당성평가.md:59` · `노선_배치.md:29`. + * 부르는 곳 = `D03_PlanAnalysis_UI_SlopeBands.ts` · `D03_PlanAnalysis_UI_LayerLegend.ts`. + * ========================================================================== */ + +export interface SlopeBand { + /** 아래 % (포함) */ + low: number; + /** 위 % (뺌 · 250 = 끝까지) */ + high: number; + /** 그림 색 이름(`--draw-map-*`) — `SLOPE_COLORS` 중 하나 */ + color: string; +} + +export const SLOPE_MAX_PCT = 250; +export const SLOPE_EMPTY = 255; +export const SLOPE_MAX_BANDS = 8; + +/** 고를 색(이름 · 그림 색 값). */ +export const SLOPE_COLORS: Array<{ name: string; color: string }> = [ + { name: "노랑", color: "--draw-map-landslide-3" }, + { name: "주황", color: "--draw-map-landslide-2" }, + { name: "빨강", color: "--draw-map-landslide-1" }, + { name: "연두", color: "--draw-map-landslide-4" }, + { name: "초록", color: "--draw-map-landslide-5" }, + { name: "하늘", color: "--draw-map-stream" }, + { name: "파랑", color: "--draw-map-zone-water" }, + { name: "보라", color: "--draw-map-zone-heritage" }, + { name: "분홍", color: "--draw-map-parcel" }, +]; + +export const DEFAULT_SLOPE_BANDS: SlopeBand[] = [ + { low: 26.8, high: 70.0, color: "--draw-map-landslide-3" }, + { low: 70.0, high: 83.9, color: "--draw-map-landslide-2" }, + { low: 83.9, high: SLOPE_MAX_PCT, color: "--draw-map-landslide-1" }, +]; + +export const pctToDeg = (pct: number): number => (Math.atan(pct / 100) * 180) / Math.PI; + +/** 범위 고칠 거리 — 없으면 null(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음 · 너무 많음). */ +export function checkBands(bands: SlopeBand[]): string | null { + if (!bands.length) return "범위가 하나는 있어야 합니다"; + if (bands.length > SLOPE_MAX_BANDS) return `범위는 ${SLOPE_MAX_BANDS}개까지입니다`; + for (const [i, band] of bands.entries()) { + const at = `${i + 1}번째 범위`; + if (!Number.isFinite(band.low) || !Number.isFinite(band.high)) return `${at} — 값이 비었습니다`; + if (band.low < 0 || band.high > SLOPE_MAX_PCT) return `${at} — 0~${SLOPE_MAX_PCT} % 안으로`; + if (band.low >= band.high) return `${at} — 아래가 위보다 작아야 합니다`; + if (!SLOPE_COLORS.some((c) => c.color === band.color)) return `${at} — 색을 고르세요`; + } + const sorted = [...bands].sort((a, b) => a.low - b.low); + for (let i = 1; i < sorted.length; i++) + if (sorted[i].low < sorted[i - 1].high) + return `범위가 겹칩니다 — ${sorted[i - 1].low}~${sorted[i - 1].high} % 와 ${sorted[i].low}~${sorted[i].high} %`; + return null; +} + +/** 저장 글 → 범위(깨졌거나 맞지 않으면 기본). */ +export function parseBands(raw: string | null): SlopeBand[] { + try { + const got = JSON.parse(raw ?? "null") as unknown; + if (Array.isArray(got)) { + const bands = got.map((b: Partial) => ({ + low: Number(b?.low), + high: Number(b?.high), + color: String(b?.color), + })); + if (!checkBands(bands)) return bands; + } + } catch { + // 깨진 값 = 기본 + } + return DEFAULT_SLOPE_BANDS.map((b) => ({ ...b })); +} + +/** 칸 값 v(0~255) → 범위 차례(없으면 −1). */ +export function bandOf(v: number, bands: SlopeBand[]): number { + if (v > SLOPE_MAX_PCT) return -1; + const mid = v + 0.5; + return bands.findIndex( + (b) => b.low <= mid && (mid < b.high || (b.high >= SLOPE_MAX_PCT && v === SLOPE_MAX_PCT)), + ); +} + +/** 1 % 칸 수 → 범위별 비율(유효 칸 대비). */ +export function bandRatios(counts: number[], bands: SlopeBand[]): number[] { + const total = counts.reduce((sum, n) => sum + n, 0) || 1; + const out = bands.map(() => 0); + counts.forEach((n, v) => { + const i = bandOf(v, bands); + if (i >= 0) out[i] += n; + }); + return out.map((n) => n / total); +} diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index e44a2e426..7090fc893 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -36,6 +36,13 @@ def test_d03_parts(): 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 받기 = 지도 귀퉁이 서클 + site = _src( + "D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts" + ) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품 + assert "createFindList(" in site and "searchPlaces(" in site and "createPlanSite(" in ts + assert "createPlaceFind(" in _src("D01_PlanRegister/D01_PlanRegister_UI_Page.ts") + for raw in ('createElement("button")', 'createElement("input")'): + assert raw not in site, raw # 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품 for src in (ts, draw, draw_panel, tools, layers): for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'): @@ -90,8 +97,12 @@ def test_d03_parts(): props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판 assert "createSelectField(" in props and "createFloatPanel(" in props and "createKv(" in props assert 'createElement("button")' not in props and 'createElement("select")' not in props - assert 'createElement("select")' not in three and "createSelectField" not in three # 601 선 고름 걷음 - assert "createCheckbox" not in three and "setCorridors(" in three # 601 노선망 예상 코리도 한 번에 + 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_560_4_leave_save.py b/resources/tester/test_560_4_leave_save.py index 20fc22a06..b585c2d61 100644 --- a/resources/tester/test_560_4_leave_save.py +++ b/resources/tester/test_560_4_leave_save.py @@ -113,7 +113,7 @@ def test_de_pages_guard_leave(): if name: assert f'discard: () => clearState("{name}", projectId)' in call, path # D03 — 못 읽은 채(프로젝트 없음 · 불러오기 실패) 떠나면 고침 없음 - assert "let savedJson = JSON.stringify({ network, points });" in _src( + assert "let savedJson = JSON.stringify({ network, points, pointNames, site });" in _src( "D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts" ) e03 = _src("E03_StabSection/E03_StabSection_UI_Page.ts") diff --git a/resources/tester/test_569_1_plan_layers.py b/resources/tester/test_569_1_plan_layers.py index 60c745a83..7ee16ea06 100644 --- a/resources/tester/test_569_1_plan_layers.py +++ b/resources/tester/test_569_1_plan_layers.py @@ -1,5 +1,5 @@ """D 노선분석 지도 겹(PLAN 569-1 · 595-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 · -겹친 필지 거름 · 실패 조각만 다시 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈. +겹친 필지 거름 · 실패 조각만 다시 · 급경사 % 값 그림 · 1 % 칸 수(603) · 격자 지문 같으면 다시 안 셈. ./venv/Scripts/python.exe -m pytest resources/tester/test_569_1_plan_layers.py -q """ @@ -169,20 +169,22 @@ def _indices(value) -> np.ndarray: return np.asarray(Image.open(io.BytesIO(raw))) -def test_slope_bands_and_north_up() -> None: +def test_slope_values_and_north_up() -> None: + """값 그림(603) — 칸 = 기울기 % 내림 · 위 = 북 · 빈 칸 · 끝 줄 = 255 · 칸 수 합 = 유효 칸.""" z = _plane(10.0, 37.0) - value = layers.slope_bands_png(z, np.ones(z.shape, bool), 2.0) - ratios = [b["ratio"] for b in value["bands"]] - assert [b["min_deg"] for b in value["bands"]] == [15.0, 35.0, 40.0] - assert ratios[0] < 0.1 and 0.4 < ratios[1] < 0.6 and ratios[2] < 0.1 # 경계 줄만 + value = layers.slope_values_png(z, np.ones(z.shape, bool), 2.0) index = _indices(value) - assert value["width"] == value["height"] == 40 - assert (index[5, 5:35] == 2).all() and (index[-5, 5:35] == 0).all() # 위 = 북 = 37° - steep = layers.slope_bands_png(_plane(45.0, 20.0), np.ones((40, 40), bool), 2.0) - assert steep["bands"][2]["ratio"] > 0.4 and steep["bands"][0]["ratio"] > 0.4 + assert value["width"] == value["height"] == 40 and "bands" not in value + assert (index[5, 5:35] == 75).all() and (index[-5, 5:35] == 17).all() # 위 = 북 = 37° = 75 % + assert (index[0] == layers.SLOPE_EMPTY).all() # 격자 끝 = 셈 못 함 + assert sum(value["counts"]) == int((index != layers.SLOPE_EMPTY).sum()) == 38 * 38 + assert len(value["counts"]) == layers.SLOPE_MAX_PCT + 1 + cliff = layers.slope_values_png(_plane(80.0, 80.0), np.ones((40, 40), bool), 2.0) + assert cliff["counts"][layers.SLOPE_MAX_PCT] == 38 * 38 # 567 % → 250 % 이상 칸 hole = np.ones(z.shape, bool) hole[25:30, 10:20] = False - assert (_indices(layers.slope_bands_png(z, hole, 2.0))[-29:-24, 10:20] == 0).all() + holed = _indices(layers.slope_values_png(z, hole, 2.0)) + assert (holed[-29:-24, 10:20] == layers.SLOPE_EMPTY).all() def test_slope_layer_fingerprint(tmp_path, monkeypatch) -> None: @@ -194,8 +196,8 @@ def test_slope_layer_fingerprint(tmp_path, monkeypatch) -> None: y = 450000.0 + np.arange(40) * 2.0 np.savez(npz, x=x, y=y, z=z, valid_mask=np.ones(z.shape, bool), resolution=[2.0]) counted = [] - real = layers.slope_bands_png - monkeypatch.setattr(layers, "slope_bands_png", lambda *a: counted.append(1) or real(*a)) + real = layers.slope_values_png + monkeypatch.setattr(layers, "slope_values_png", lambda *a: counted.append(1) or real(*a)) first = layers.slope_layer(tmp_path, models, EPSG) assert first["box_m"] == [199999.0, 449999.0, 200079.0, 450079.0] and len(first["box"]) == 4 assert layers.slope_layer(tmp_path, models, EPSG) == first and len(counted) == 1 diff --git a/resources/tester/test_581_1_plan_gis_layers.py b/resources/tester/test_581_1_plan_gis_layers.py index 6ac60270a..375896db7 100644 --- a/resources/tester/test_581_1_plan_gis_layers.py +++ b/resources/tester/test_581_1_plan_gis_layers.py @@ -200,7 +200,7 @@ def test_hillshade_saved_apart_from_slope(tmp_path) -> None: shade = layers.slope_layer(tmp_path, models, EPSG, "hillshade") slope = layers.slope_layer(tmp_path, models, EPSG, "slope") assert shade["image"].startswith("data:image/png;base64,") and shade["bands"] == [] - assert slope["bands"] and shade["box_m"] == slope["box_m"] + assert slope["counts"] and shade["box_m"] == slope["box_m"] # 603 = 값 그림 + 칸 수 assert layers.slope_layer(tmp_path, models, EPSG, "hillshade") == shade diff --git a/resources/tester/test_584_3_plan_forest_road_zones.py b/resources/tester/test_584_3_plan_forest_road_zones.py index f5e43748c..103d1c14e 100644 --- a/resources/tester/test_584_3_plan_forest_road_zones.py +++ b/resources/tester/test_584_3_plan_forest_road_zones.py @@ -276,7 +276,9 @@ def test_screen_defs() -> None: assert entry and entry.group(2) == "false", key # 처음 값 끔 assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M), key assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in page - assert re.search(r"^\s+upload:", icons, re.M) and "pickFiles(" in page # 부품 상자 고르기 창 + site = (folder / "D03_PlanAnalysis_UI_Site.ts").read_text("utf-8") # 596-2 올리기 = 위치 칸 + assert "pickFiles(" in site and "pickFiles(" not in page # 부품 상자 고르기 창 + assert "uploadRoads" in page and "uploadPlanForestRoads(" in page assert "") # 겹 모두 바탕 assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분 diff --git a/resources/tester/test_597_point_edit.py b/resources/tester/test_597_point_edit.py new file mode 100644 index 000000000..b4f90f33a --- /dev/null +++ b/resources/tester/test_597_point_edit.py @@ -0,0 +1,120 @@ +"""597-2 예정노선 지점 칸 손 고침 계산 — 이름 규칙 · 보이는 이름 · 경위도 읽기 · 더함 · 이름 바꿈 · 경위도로 옮김 +(요구 463 7 · 8) — TS `logic/D03_PlanAnalysis_PointEdit.ts`. + +./venv/Scripts/python.exe -m pytest resources/tester/test_597_point_edit.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_PointEdit.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; +import * as pe from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.js"; +import * as tmx from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Tm.js"; +const Y = 2026; +const tm = tmx.tmParams(5187); +// 지점 (100,0) 에서 시작하는 선 · 지점 둘 +let n = plan.newLine(plan.emptyNetwork(), [100, 0]); +n = plan.normalizePieces(plan.appendVertex(n, 0, [200, 0]), Y); +const state = { points: [[100, 0], [500, 500]], names: ["", "가"], network: n }; +const named = pe.setLineName(pe.setLineName(n, n.lines[0].id, ' 본선A '), 'none', 'x'); +const cleared = pe.setLineName(named, n.lines[0].id, ' '); +const added = pe.addPoint(state, [7, 8]); +const full = { points: Array.from({ length: 40 }, (_, i) => [i, 0]), names: [], network: n }; +const [lon, lat] = tmx.tmToLonLat(tm, 100, 0); +const moved = pe.movePointTo(state, 0, tm, lon + 0.001, lat); +const back = tmx.tmToLonLat(tm, ...moved.points[0]); +writeFileSync(process.argv[2], JSON.stringify({ + errors: ["", "가나다", "a/b", "a" + String.fromCharCode(92) + "b", "a:b", "a*b", "a?b", 'a"b', "ab", "a|b", "x".repeat(20), "x".repeat(21)] + .map((s) => plan.nameError(s)), + labels: [pe.pointLabel(["", " ", "가"], 0), pe.pointLabel(["", " ", "가"], 1), pe.pointLabel(["", " ", "가"], 2), pe.pointLabel([], 3)], + parse: [pe.parseLonLat("128.5", "36.9"), pe.parseLonLat("", "36.9"), pe.parseLonLat("128.5", "abc"), pe.parseLonLat("181", "0"), pe.parseLonLat("0", "-91")], + added: [added.points, added.names, added.network === n], + lineName: [named.lines[0].name, plan.lineNames(named.lines), cleared.lines[0].name, plan.lineNames(cleared.lines)], + full: pe.addPoint(full, [1, 1]) === full, + renamed: [pe.renamePoint(state, 1, " 나 ").names, pe.renamePoint({ ...state, names: [] }, 1, "다").names], + moved: { points: moved.points, lines: moved.network.lines.map((l) => l.vertices), back, expect: [lon + 0.001, lat], untouched: moved.points[1], names: moved.names }, +})); +""" + + +@pytest.fixture(scope="module") +def got(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp_path = tmp_path_factory.mktemp("pe") + 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, + ) # fmt: skip + 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") + result = tmp_path / "out.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_name_rule(got: dict) -> None: + ok, bad = got["errors"][:2], got["errors"][2:11] + assert ok == [None, None] # 빈 이름 · 한글 = 됨 + assert all(msg and "쓸 수 없습니다" in msg for msg in bad) # 파일 이름 못 쓰는 글자 아홉 + assert got["errors"][11] is None # 20 자 + assert "20 자" in got["errors"][12] # 21 자 = 막음 + + +def test_label_blank_falls_back(got: dict) -> None: + assert got["labels"] == ["지점 1", "지점 2", "가", "지점 4"] + + +def test_parse_lonlat(got: dict) -> None: + ok, no_lon, bad_lat, big_lon, big_lat = got["parse"] + assert ok == [128.5, 36.9] + assert no_lon.startswith("경도") and bad_lat.startswith("위도") + assert big_lon.startswith("경도") and big_lat.startswith("위도") + + +def test_add_and_limit(got: dict) -> None: + points, names, network_same = got["added"] + assert points == [[100, 0], [500, 500], [7, 8]] and names == ["", "가", ""] + assert network_same # 더해도 노선 그대로 + assert got["full"] # 40 개 = 그대로 + + +def test_rename(got: dict) -> None: + assert got["renamed"][0] == ["", "나"] # 앞뒤 빈칸 뺌 + assert got["renamed"][1] == ["", "다"] # 이름 목록이 짧아도 차례 맞춤 + + +def test_move_by_lonlat_drags_node(got: dict) -> None: + moved = got["moved"] + assert moved["back"] == pytest.approx(moved["expect"], abs=1e-7) # 경위도 왕복 + assert moved["lines"][0][0] == moved["points"][0] # 그 자리 노선 노드도 같이 + assert moved["lines"][0][1] == [200, 0] # 다른 노드는 그대로 + assert moved["untouched"] == [500, 500] and moved["names"] == ["", "가"] + + +def test_line_name(got: dict) -> None: + named, names, cleared, back = got["lineName"] + assert named == "본선A" and names == ["본선A"] # 앞뒤 빈칸 뺌 · 이름 우선 + assert cleared is None and back == ["본선"] # 빈 값 = 차례 이름으로 diff --git a/resources/tester/test_603_plan_slope_bands.py b/resources/tester/test_603_plan_slope_bands.py new file mode 100644 index 000000000..f6d1e0935 --- /dev/null +++ b/resources/tester/test_603_plan_slope_bands.py @@ -0,0 +1,172 @@ +"""D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 서버 1 % 칸 수 + +`logic/D03_PlanAnalysis_SlopeBands.ts`. + +1 % 칸 수 합 = 유효 칸 · 기본 범위(26.8 · 70.0 · 83.9 %) 비율 = 옛 15 · 35 · 40° 비율 · +범위 막음(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음) · 깨진 취향 = 기본 · 250 % 이상 칸 = 끝 범위. +TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이). + +./venv/Scripts/python.exe -m pytest resources/tester/test_603_plan_slope_bands.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import numpy as np +import pytest + +from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers +from E07_StabArea.E07_StabArea_Engine import slope_rad + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_SlopeBands.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +CELL = 2.0 +OLD_DEG = ((15.0, 35.0), (35.0, 40.0), (40.0, 90.0)) # 옛 SLOPE_BANDS + + +def _hills(n: int = 300) -> np.ndarray: + """0 ~ 60° 남짓 고루 섞인 언덕(구멍 하나).""" + y, x = np.mgrid[0:n, 0:n] * CELL + return 40 * np.sin(x / 37) * np.cos(y / 53) + 25 * np.sin((x + 2 * y) / 19) + 0.3 * x + + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as bands from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = { DEFAULT: bands.DEFAULT_SLOPE_BANDS }; +// 인자 null = 기본 범위 +for (const [key, [name, ...args]] of Object.entries(cases)) + out[key] = bands[name](...args.map((a) => (a === null ? bands.DEFAULT_SLOPE_BANDS : a))); +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + +C = "--draw-map-landslide-3" + + +@pytest.fixture(scope="module") +def hills() -> dict: + z = _hills() + valid = np.ones(z.shape, bool) + valid[100:140, 100:180] = False + value = layers.slope_values_png(z, valid, CELL) + deg = np.degrees(slope_rad(np.where(valid, z, np.nan), CELL)) + known = np.isfinite(deg) + old = [float(((deg >= lo) & (deg < hi) & known).sum() / known.sum()) for lo, hi in OLD_DEG] + return {"counts": value["counts"], "known": int(known.sum()), "old": old} + + +@pytest.fixture(scope="module") +def r(tmp_path_factory: pytest.TempPathFactory, hills: dict) -> dict: + tmp = tmp_path_factory.mktemp("slope_bands") + out = tmp / "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") + band = lambda lo, hi: {"low": lo, "high": hi, "color": C} # noqa: E731 + top = [0] * 250 + [7] + cases = { + "default_ratios": ["bandRatios", hills["counts"], None], + "user_ratios": [ + "bandRatios", + hills["counts"], + [band(10, 15), band(15, 20), band(20, 250), band(0, 10)], + ], + "top": ["bandRatios", top, [band(83.9, 250)]], + "top_closed": ["bandRatios", top, [band(83.9, 249)]], + "ok": ["checkBands", [band(10, 15), band(15, 20), band(20, 250), band(0, 10)]], + "overlap": ["checkBands", [band(10, 20), band(15, 30)]], + "empty_value": ["checkBands", [band(None, 20)]], + "out_of_range": ["checkBands", [band(10, 260)]], + "upside_down": ["checkBands", [band(30, 20)]], + "none": ["checkBands", []], + "bad_color": ["checkBands", [{"low": 1, "high": 2, "color": "red"}]], + "parse_broken": ["parseBands", "{oops"], + "parse_overlap": ["parseBands", json.dumps([band(10, 20), band(15, 30)])], + "parse_ok": ["parseBands", json.dumps([band(10, 20)])], + "deg": ["pctToDeg", 100], + } + job, result = tmp / "job.json", tmp / "out.json" + job.write_text(json.dumps(cases), 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 test_칸_수_합은_유효_칸(hills): + assert sum(hills["counts"]) == hills["known"] + assert len(hills["counts"]) == layers.SLOPE_MAX_PCT + 1 + + +def test_기본은_지식DB_15_35_40도(r): + assert [(b["low"], b["high"]) for b in r["DEFAULT"]] == [ + (26.8, 70.0), + (70.0, 83.9), + (83.9, 250), + ] + + +def test_기본_비율은_옛_15_35_40도_비율과_같음(r, hills): + """1 % 칸 경계(26.8 → 27 · 83.9 → 84 %) 차이만 — 범례 0.1 % 자리 반 칸 안.""" + for new, old in zip(r["default_ratios"], hills["old"], strict=True): + assert old > 0.02 + assert new == pytest.approx(old, abs=0.002) + + +def test_사용자_범위_넷(r): + ratios = r["user_ratios"] + assert len(ratios) == 4 and sum(ratios) == pytest.approx(1.0) + + +def test_250_퍼센트_이상은_끝_범위(r): + assert r["top"] == [1.0] + assert r["top_closed"] == [0.0] + + +def test_범위_막음(r): + assert r["ok"] is None + assert "겹칩니다" in r["overlap"] + assert "비었습니다" in r["empty_value"] + assert "0~250" in r["out_of_range"] + assert "아래가 위보다" in r["upside_down"] + assert "하나는" in r["none"] + assert "색" in r["bad_color"] + + +def test_깨진_취향은_기본(r): + assert r["parse_broken"] == r["DEFAULT"] + assert r["parse_overlap"] == r["DEFAULT"] + assert r["parse_ok"] == [{"low": 10, "high": 20, "color": C}] + assert r["deg"] == pytest.approx(45.0) diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index d9f51ecfe..1f6689cbe 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -102,6 +102,13 @@ export const ui_locales_de = { DE_Reg_SiteNoAddress: ["주소를 찾지 못했습니다", "Address not found"], DE_Reg_SiteSource: ["출처", "Source"], DE_Reg_SiteRequired: ["지도에서 기준점을 찍으세요.", "Set the reference point on the map."], + DE_Reg_Find: ["주소 · 장소 찾기 (Enter)", "Find address or place (Enter)"], + DE_Reg_FindShort: ["두 글자 이상", "Two or more characters"], + DE_Reg_FindBusy: ["찾는 중…", "Searching…"], + DE_Reg_FindNone: ["찾은 곳 없음", "Nothing found"], + DE_Reg_FindFail: ["찾지 못함", "Search failed"], + DE_Reg_FindAddress: ["주소", "Address"], + DE_Reg_FindPlace: ["장소", "Place"], DE_Reg_MapType: ["바탕 지도", "Base map"], DE_Reg_MapNormal: ["일반", "Map"], DE_Reg_MapSatellite: ["위성", "Satellite"],