From edc20df849aa73a9b7460d68752aa868bf321988 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 15:12:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0):?= =?UTF-8?q?=20=EA=B8=89=EA=B2=BD=EC=82=AC=20=EB=B2=94=EC=9C=84=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EB=AA=A8=EB=8B=AC=20=E2=80=94=20=EA=B8=B0=EC=9A=B8?= =?UTF-8?q?=EA=B8=B0=20%=20=EA=B0=92=20=EA=B7=B8=EB=A6=BC=20=C2=B7=201=20%?= =?UTF-8?q?=20=EC=B9=B8=20=EC=88=98=20=C2=B7=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=B9=A0=ED=95=98=EA=B8=B0=20=C2=B7=20=EB=B2=94=EB=A1=80=20?= =?UTF-8?q?=E2=9A=99=20(PLAN=20603=20=C2=B7=20=EC=9A=94=EA=B5=AC=20466~468?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버: 15 · 35 · 40° 단계 PNG 길을 걷고 기울기 % 값 그림 한 장(칸 = % 내림 0~250 · 빈 칸 255) + 1 % 칸 수 · 파생 저장본 판 2(열쇠 = 격자 지문) - 화면: 고른 범위대로 값 그림을 칠함(서버 왕복 없음) · 범례 비율 = 칸 수로 바로 셈 · 범례 급경사 줄 ⚙ → 모달(아래 % · 위 % · 색 · ° 환산 · 범위 추가 · 지움 · 기본값 · 빈 값 · 겹침 막음) - 기본 = 지식DB 15 · 35 · 40° 의 % 26.8 · 70.0 · 83.9 · 기억 = 화면 취향 aislo:pref:d03.slope-bands(계정) - 시험: 569_1 급경사 시험을 값 그림 · 칸 수로 고침, 581_1 은 bands 대신 counts(옛 단계 응답이 없어짐) · 새 603 시험(칸 수 합 = 유효 칸 · 기본 비율 = 옛 15 · 35 · 40° 비율) Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 5 +- D03_PlanAnalysis/D03_PlanAnalysis_Layers.py | 52 ++-- D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 2 +- .../D03_PlanAnalysis_UI_LayerDefs.ts | 2 +- .../D03_PlanAnalysis_UI_LayerLegend.ts | 49 +++- .../D03_PlanAnalysis_UI_Layers.ts | 55 +++- .../D03_PlanAnalysis_UI_SlopeBands.ts | 236 ++++++++++++++++++ .../logic/D03_PlanAnalysis_SlopeBands.ts | 98 ++++++++ resources/tester/test_569_1_plan_layers.py | 28 ++- .../tester/test_581_1_plan_gis_layers.py | 2 +- resources/tester/test_603_plan_slope_bands.py | 172 +++++++++++++ 11 files changed, 641 insertions(+), 60 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts create mode 100644 resources/tester/test_603_plan_slope_bands.py 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_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_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/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_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_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)