Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -179,13 +179,14 @@ export async function fetchPlanSurface(projectId: string): Promise<PlanSurface |
|
||||
return { modelId: model.id, grid: parseDtmGrid(await fetchCachedBytes(projectId, url)) };
|
||||
}
|
||||
|
||||
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북) · 단계 색 · 넓이 비율. */
|
||||
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북). */
|
||||
export interface PlanRaster {
|
||||
box_m: [number, number, number, number];
|
||||
/** 경위도 네 귀퉁이(왼쪽 아래 · 오른쪽 아래 · 오른쪽 위 · 왼쪽 위) — GL 바탕(586-1). */
|
||||
box?: Array<[number, number]>;
|
||||
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<GeoJsonCollection> =>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<LineLayerKey, PreparedLines> & {
|
||||
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<LineLayerKey, PreparedLines> & {
|
||||
|
||||
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<Prepared>,
|
||||
): 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<Prepared>): Record<string, number> =>
|
||||
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) => {
|
||||
|
||||
@@ -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<void> {
|
||||
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 };
|
||||
|
||||
@@ -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<Blob>((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<HTMLElement>(".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,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<SlopeBand>) => ({
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user