feat(D03 겹): 노선분석 지도 새 겹 일곱 — 음영기복 · 물 면 · 기존 임도 짐작 · 보호 · 규제 구역 · 계류 · 도로 띠 · 건물 · 지명 (PLAN 581-1 · 요구 440-8 · 442)
- 겹 단추 = 공용 지도 도구줄(createMapTools) 한 줄 · 묶음 셋(바탕 · 물 · 길 · 구역) · 넘치면 가로 밀기 · 오른쪽 도구줄 자리 = 겹 줄 폭(--d03-layers-w) - 새 겹 처음 값 = 끔 · 켬끔 기억(aislo:pref:d03.layers) · 받기 실패 = 그 겹만 꺼짐 + 알림 - 범례 = 켠 겹마다 한 줄(급경사 비율 · 보호구역 종류별 곳 · ha · 띠 ha · % · 물 면 · 건물 · 임도 짐작 수) - 면 겹 채움 · 겹 정의 = D03_PlanAnalysis_UI_LayerDefs.ts(700줄 한도로 나눔) - 부품 상자: 아이콘 shield · borderOuter · 그림 색 --draw-map-* 열넷 - 음영기복 빈 칸 투명(HILLSHADE_VERSION 2) - 시험 test_553_4_de_parts 의 D03 겹 단추 대조 고침 — 겹 단추가 createToolbar 에서 공용 지도 도구줄로 · 겹 열셋으로 바뀌어 옛 대조가 낡음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* D03_PlanAnalysis_Api.ts
|
||||
* D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 ·
|
||||
* 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 응답에 D02 시점 · 종점) ·
|
||||
* 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림).
|
||||
* 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림 · 581 — 음영기복 · 보호구역 · 띠).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -149,5 +149,27 @@ export const fetchPlanCadastral = (projectId: string): Promise<GeoJsonCollection
|
||||
export const fetchPlanSheetLayer = (projectId: string, key: string): Promise<GeoJsonCollection> =>
|
||||
request(`${base(projectId)}/layers/sheet?key=${encodeURIComponent(key)}`);
|
||||
|
||||
export const fetchPlanRaster = (projectId: string, kind: "slope"): Promise<PlanRaster> =>
|
||||
request(`${base(projectId)}/layers/raster?kind=${kind}`);
|
||||
export const fetchPlanRaster = (
|
||||
projectId: string,
|
||||
kind: "slope" | "hillshade",
|
||||
): Promise<PlanRaster> => request(`${base(projectId)}/layers/raster?kind=${kind}`);
|
||||
|
||||
/** 보호 · 규제 구역(581-1) — 지물 `kind` · `name` · `area_m2` + 종류별 개수 · 넓이(실패 = `failed`). */
|
||||
export interface PlanZones extends GeoJsonCollection {
|
||||
summary: Record<string, { label: string; count: number; area_m2: number; failed?: boolean }>;
|
||||
}
|
||||
|
||||
export const fetchPlanZones = (projectId: string): Promise<PlanZones> =>
|
||||
request(`${base(projectId)}/layers/zones`);
|
||||
|
||||
/** 계류 100 m · 도로 300 m 띠(581-1) — 받은 범위 안 넓이 · 비율. */
|
||||
export interface PlanBand {
|
||||
dist_m: number;
|
||||
lines: number;
|
||||
area_m2: number;
|
||||
ratio: number;
|
||||
collection: GeoJsonCollection;
|
||||
}
|
||||
|
||||
export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> =>
|
||||
request(`${base(projectId)}/layers/bands`);
|
||||
|
||||
@@ -56,6 +56,7 @@ WFS_WORKERS = 4
|
||||
|
||||
SLOPE_KIND = "plan_slope"
|
||||
HILLSHADE_KIND = "plan_hillshade"
|
||||
HILLSHADE_VERSION = 2 # 빈 칸 투명
|
||||
RASTER_KINDS = ("slope", "hillshade")
|
||||
SLOPE_VERSION = 1
|
||||
# (아래 °, 위 °, 색 RGBA) — 임도망_편성 완 · 중 · 급경사지 · 타당성평가 35° · 노선_배치 40° 연속
|
||||
@@ -239,11 +240,18 @@ def slope_bands_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str
|
||||
|
||||
|
||||
def hillshade_layer_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]:
|
||||
"""음영기복 PNG(B07 그대로 · 빈 칸 흰색 = 화면이 곱하기로 겹쳐 투명)."""
|
||||
"""음영기복 PNG(B07 그대로 · 빈 칸 흰색 255 → 투명 · 값 칸은 90~250)."""
|
||||
from PIL import Image
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import hillshade_png
|
||||
|
||||
image, width, height = hillshade_png(z, valid, cell_m)
|
||||
return {"width": width, "height": height, "image": image, "bands": []}
|
||||
gray = Image.open(io.BytesIO(base64.b64decode(image.split(",", 1)[1]))).convert("L")
|
||||
gray.putalpha(gray.point(lambda v: 0 if v == 255 else 255))
|
||||
buffer = io.BytesIO()
|
||||
gray.save(buffer, format="PNG", optimize=True)
|
||||
data = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
return {"width": width, "height": height, "image": data, "bands": []}
|
||||
|
||||
|
||||
def slope_layer(
|
||||
@@ -256,12 +264,12 @@ def slope_layer(
|
||||
stamp = file_stamp(npz)
|
||||
if stamp is None:
|
||||
raise FileNotFoundError("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.")
|
||||
store_kind, build = (
|
||||
(SLOPE_KIND, slope_bands_png) if kind == "slope" else (HILLSHADE_KIND, hillshade_layer_png)
|
||||
)
|
||||
key = derived_key(
|
||||
store_kind, SLOPE_VERSION, npz.name, stamp, int(epsg), SLOPE_BANDS, MAX_SIDE_PX
|
||||
store_kind, version, build = (
|
||||
(SLOPE_KIND, SLOPE_VERSION, slope_bands_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)
|
||||
saved = read_derived(project_root, store_kind, key)
|
||||
if saved is not None:
|
||||
return saved["value"]
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_LayerDefs.ts
|
||||
* D03 바탕 겹 정의(PLAN 569-2 · 581-1) — 겹 단추 묶음 · 처음 값 · 보호구역 색 · 면 겹 채우기.
|
||||
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기 차례 · 범례).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { IconName } from "@ui/ui_template_icons";
|
||||
import {
|
||||
normalizedToScreen,
|
||||
type PreparedLayer,
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
|
||||
export type PlanLayerKey =
|
||||
| "satellite"
|
||||
| "hillshade"
|
||||
| "contour"
|
||||
| "slope"
|
||||
| "stream"
|
||||
| "water"
|
||||
| "road"
|
||||
| "forestRoad"
|
||||
| "cadastral"
|
||||
| "zones"
|
||||
| "bands"
|
||||
| "building"
|
||||
| "place";
|
||||
|
||||
export interface LayerDef {
|
||||
key: PlanLayerKey;
|
||||
icon: IconName;
|
||||
title: string;
|
||||
on: boolean;
|
||||
}
|
||||
|
||||
/** 단추 묶음(사이 나눔선) — 바탕 · 물 · 길 · 구역. 「지금」 겹 처음 값 = 끔(`_분석_440-C` 1-4). */
|
||||
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: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true },
|
||||
{ key: "water", icon: "ripple", title: "물 면(도엽 실폭하천 · 저수지 등)", on: false },
|
||||
{ key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false },
|
||||
{
|
||||
key: "forestRoad",
|
||||
icon: "route",
|
||||
title: "기존 임도 짐작(도엽 도로 소로 · 미분류 = 주황 점선 · 정식 임도망도 아님)",
|
||||
on: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{ key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false },
|
||||
{
|
||||
key: "zones",
|
||||
icon: "shield",
|
||||
title: "보호 · 규제 구역(브이월드 여덟 종 · 참고용)",
|
||||
on: false,
|
||||
},
|
||||
{
|
||||
key: "bands",
|
||||
icon: "borderOuter",
|
||||
title: "계류 100 m · 도로 300 m 띠(타당성평가)",
|
||||
on: false,
|
||||
},
|
||||
{ key: "building", icon: "building", title: "건물(도엽 · 하류 민가 · 인가)", on: false },
|
||||
{ key: "place", icon: "mapPin", title: "지명(도엽 계곡 · 령 · 부락)", on: false },
|
||||
],
|
||||
];
|
||||
export const LAYERS: readonly LayerDef[] = GROUPS.flat();
|
||||
/** 보호구역 종류 → 그림 색 값(서버 `ZONE_KINDS` 열쇠). */
|
||||
export const ZONE_COLORS: Record<string, string> = {
|
||||
forest: "--draw-map-zone-forest",
|
||||
baekdu: "--draw-map-zone-baekdu",
|
||||
water: "--draw-map-zone-water",
|
||||
park: "--draw-map-zone-park",
|
||||
heritage: "--draw-map-zone-heritage",
|
||||
wetland: "--draw-map-zone-wetland",
|
||||
wildlife: "--draw-map-zone-wildlife",
|
||||
greenbelt: "--draw-map-zone-greenbelt",
|
||||
};
|
||||
/** 도로 중 기존 임도로 짐작하는 구분. */
|
||||
export const FOREST_ROAD_KINDS = new Set(["소로", "미분류"]);
|
||||
/** 물 면 = 도엽 수계(E) 면 셋. */
|
||||
export const WATER_KEYS = ["실폭하천", "수계면E003", "수계면E005"];
|
||||
|
||||
export const hectare = (m2: number): string => `${(m2 / 10000).toFixed(1)} ha`;
|
||||
|
||||
/** 면 겹 채움 — 지물마다 한 길(evenodd = 구멍) · 화면 밖 지물 거름(돌린 지도 귀퉁이까지 여유). */
|
||||
export function fillLayer(
|
||||
context: CanvasRenderingContext2D,
|
||||
layer: PreparedLayer,
|
||||
view: ViewState,
|
||||
fill: string,
|
||||
alpha: number,
|
||||
edge?: { color: string; width: number; dash?: number[] },
|
||||
): void {
|
||||
const [ox, oy] = normalizedToScreen(view, 0, 0);
|
||||
const [ex, ey] = normalizedToScreen(view, 1, 1);
|
||||
const ax = ex - ox;
|
||||
const ay = ey - oy;
|
||||
const pad = Math.max(view.width, view.height) / 2;
|
||||
context.fillStyle = fill;
|
||||
if (edge) {
|
||||
context.strokeStyle = edge.color;
|
||||
context.lineWidth = edge.width;
|
||||
context.setLineDash(edge.dash ?? []);
|
||||
}
|
||||
for (const feature of layer.features) {
|
||||
if (
|
||||
feature.maxX * ax + ox < -pad ||
|
||||
feature.minX * ax + ox > view.width + pad ||
|
||||
feature.maxY * ay + oy < -pad ||
|
||||
feature.minY * ay + oy > view.height + pad
|
||||
)
|
||||
continue;
|
||||
context.beginPath();
|
||||
for (const { coords } of feature.parts) {
|
||||
context.moveTo(coords[0] * ax + ox, coords[1] * ay + oy);
|
||||
for (let i = 2; i < coords.length; i += 2)
|
||||
context.lineTo(coords[i] * ax + ox, coords[i + 1] * ay + oy);
|
||||
context.closePath();
|
||||
}
|
||||
context.globalAlpha = alpha;
|
||||
context.fill("evenodd");
|
||||
if (edge) {
|
||||
context.globalAlpha = 0.9;
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
context.setLineDash([]);
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Layers.ts
|
||||
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 요구 395-2) — 지도 왼쪽 위 아이콘 한 줄(도구줄과 따로).
|
||||
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 요구 395-2 · 440-8 · 442) — 지도 왼쪽 위 아이콘 한 줄
|
||||
* (공용 지도 도구줄 `createMapTools` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로).
|
||||
*
|
||||
* 겹 = 위성 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 세류선(가는 파랑) · 하천(굵은 파랑) · 지적도 ·
|
||||
* 기존 도로(도엽 도로중심선) · 급경사 색(15° · 35° · 40° · 범례 작은 칸).
|
||||
* 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) ·
|
||||
* 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류)) ·
|
||||
* 구역(지적도 · 보호 · 규제 구역 여덟 종 · 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
||||
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
||||
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음.
|
||||
* 그리기 차례(아래 → 위) = 위성(`scene.image`) → 급경사 → 지적 → 등고선 → 도로 → 세류 → 그은 선.
|
||||
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림.
|
||||
* 그리기 차례(아래 → 위) = 위성(`scene.image`) → 음영기복 → 급경사 → 보호구역 → 띠 → 지적 → 물 면 →
|
||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 세류 → 지명 → 그은 선(모두 `layers` = 바탕 겹 · 582 두 장이면 아래 장).
|
||||
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createIconButton } from "@ui/ui_template_button";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createLegend } from "@ui/ui_template_flow_legend";
|
||||
import type { IconName } from "@ui/ui_template_icons";
|
||||
import { createLegend, type LegendItem } from "@ui/ui_template_flow_legend";
|
||||
import { createIconButton } from "@ui/ui_template_button";
|
||||
import { createMapTools } from "@ui/ui_template_map_tools";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
@@ -26,49 +29,58 @@ import {
|
||||
createNormalizer,
|
||||
drawPreparedLabels,
|
||||
drawPreparedLayer,
|
||||
lonLatToScreen,
|
||||
pickLevelStep,
|
||||
type GeoJsonCollection,
|
||||
type PreparedLayer,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||
import {
|
||||
fetchPlanBands,
|
||||
fetchPlanCadastral,
|
||||
fetchPlanRaster,
|
||||
fetchPlanSheetLayer,
|
||||
fetchPlanZones,
|
||||
type PlanBand,
|
||||
type PlanRaster,
|
||||
type PlanZones,
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import {
|
||||
FOREST_ROAD_KINDS,
|
||||
GROUPS,
|
||||
LAYERS,
|
||||
WATER_KEYS,
|
||||
ZONE_COLORS,
|
||||
fillLayer,
|
||||
hectare,
|
||||
type PlanLayerKey,
|
||||
} from "./D03_PlanAnalysis_UI_LayerDefs";
|
||||
import type { PlanMap, PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
|
||||
export type PlanLayerKey = "satellite" | "contour" | "stream" | "cadastral" | "road" | "slope";
|
||||
|
||||
interface LayerDef {
|
||||
key: PlanLayerKey;
|
||||
icon: IconName;
|
||||
title: string;
|
||||
on: boolean;
|
||||
}
|
||||
|
||||
const LAYERS: readonly LayerDef[] = [
|
||||
{ key: "satellite", icon: "satellite", title: "위성사진", on: true },
|
||||
{ key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true },
|
||||
{ key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true },
|
||||
{ key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false },
|
||||
{ key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false },
|
||||
{ key: "slope", icon: "angle", title: "급경사 색(15° · 35° · 40° 이상)", on: false },
|
||||
];
|
||||
const PREF_KEY = "aislo:pref:d03.layers";
|
||||
/** 등고선을 받아 올 그은 선 둘레(m). */
|
||||
const CONTOUR_PAD_M = 600;
|
||||
|
||||
interface Prepared {
|
||||
contour: PreparedLayer;
|
||||
/** 세류 · 하천(소하천 · 지방하천 · 연결선). */
|
||||
stream: [PreparedLayer, PreparedLayer];
|
||||
cadastral: PreparedLayer;
|
||||
road: PreparedLayer;
|
||||
/** 전체 · 그중 임도 짐작(소로 · 미분류) — `road` · `forestRoad` 가 함께 씀. */
|
||||
road: { all: PreparedLayer; small: PreparedLayer };
|
||||
slope: { raster: PlanRaster; image: HTMLImageElement };
|
||||
hillshade: { raster: PlanRaster; image: HTMLImageElement };
|
||||
water: PreparedLayer;
|
||||
zones: { kinds: Map<string, PreparedLayer>; summary: PlanZones["summary"] };
|
||||
bands: { stream: PreparedLayer; road: PreparedLayer; info: { stream: PlanBand; road: PlanBand } };
|
||||
building: PreparedLayer;
|
||||
place: Array<{ lon: number; lat: number; name: string }>;
|
||||
}
|
||||
type DataKey = keyof Prepared;
|
||||
|
||||
/** 겹 → 받는 자료(임도 짐작 = 도로 자료). */
|
||||
const dataKey = (key: Exclude<PlanLayerKey, "satellite">): DataKey =>
|
||||
key === "forestRoad" ? "road" : key;
|
||||
|
||||
export interface PlanLayers {
|
||||
bar: HTMLElement;
|
||||
@@ -77,8 +89,12 @@ export interface PlanLayers {
|
||||
layers: PlanMapLayer[];
|
||||
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */
|
||||
load: () => Promise<void>;
|
||||
/** 검증 창구 — 켬끔 · 받은 겹. */
|
||||
state: () => { on: Record<PlanLayerKey, boolean>; loaded: PlanLayerKey[] };
|
||||
/** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */
|
||||
state: () => {
|
||||
on: Record<PlanLayerKey, boolean>;
|
||||
loaded: PlanLayerKey[];
|
||||
counts: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
function readPref(): Record<PlanLayerKey, boolean> {
|
||||
@@ -113,31 +129,94 @@ export function createPlanLayers(opts: {
|
||||
const { projectId, map } = opts;
|
||||
const on = readPref();
|
||||
const data: Partial<Prepared> = {};
|
||||
const pending = new Map<PlanLayerKey, Promise<void>>();
|
||||
const pending = new Map<DataKey | "satellite", Promise<void>>();
|
||||
let meta: VWorldMeta | null = null;
|
||||
let satellite: HTMLImageElement | null = null;
|
||||
|
||||
const buttons = new Map<PlanLayerKey, HTMLButtonElement>();
|
||||
for (const def of LAYERS) {
|
||||
const button = createIconButton({
|
||||
icon: def.icon,
|
||||
title: def.title,
|
||||
pressed: on[def.key],
|
||||
onClick: () => toggle(def.key),
|
||||
});
|
||||
button.dataset.layer = def.key;
|
||||
buttons.set(def.key, button);
|
||||
}
|
||||
const bar = createToolbar({
|
||||
glass: true,
|
||||
size: 32,
|
||||
label: "지도 겹",
|
||||
items: [...buttons.values()],
|
||||
});
|
||||
const groups = GROUPS.map((group) =>
|
||||
group.map((def) => {
|
||||
const button = createIconButton({
|
||||
icon: def.icon,
|
||||
title: def.title,
|
||||
size: 32,
|
||||
pressed: on[def.key],
|
||||
onClick: () => toggle(def.key),
|
||||
});
|
||||
button.dataset.layer = def.key;
|
||||
buttons.set(def.key, button);
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
const tools = createMapTools({ label: "지도 겹", groups });
|
||||
const bar = tools.root;
|
||||
bar.classList.add("d03-plan__layers");
|
||||
// 571-1 — 오른쪽 평면 도구줄이 이 줄 오른쪽부터(CSS `--d03-layers-w`)
|
||||
new ResizeObserver(() =>
|
||||
map.root.style.setProperty("--d03-layers-w", `${tools.bar.offsetWidth}px`),
|
||||
).observe(tools.bar);
|
||||
const legend = document.createElement("div");
|
||||
legend.className = "d03-plan__legend";
|
||||
legend.title = "급경사 색 — 단계 · 받은 지형 안 넓이 비율";
|
||||
legend.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이";
|
||||
|
||||
const loadedOf = (key: PlanLayerKey): boolean =>
|
||||
key === "satellite" ? !!satellite : !!data[dataKey(key)];
|
||||
|
||||
function legendRows(): 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.zones && data.zones) {
|
||||
const items = Object.entries(data.zones.summary)
|
||||
.filter(([, kind]) => kind.count || kind.failed)
|
||||
.map(([key, kind]) => ({
|
||||
label: kind.failed
|
||||
? `${kind.label} 받지 못함`
|
||||
: `${kind.label} ${kind.count}곳 ${hectare(kind.area_m2)}`,
|
||||
color: `var(${ZONE_COLORS[key]})`,
|
||||
off: !!kind.failed,
|
||||
}));
|
||||
rows.push(
|
||||
items.length
|
||||
? items
|
||||
: [{ label: "보호 · 규제 구역 없음(받은 범위 안)", color: "transparent", off: true }],
|
||||
);
|
||||
}
|
||||
if (on.bands && data.bands) {
|
||||
const { stream, road } = data.bands.info;
|
||||
rows.push([
|
||||
{
|
||||
label: `계류 ${stream.dist_m} m 띠 ${hectare(stream.area_m2)} (${(stream.ratio * 100).toFixed(1)} %)`,
|
||||
color: "var(--draw-map-band-stream)",
|
||||
},
|
||||
{
|
||||
label: `도로 ${road.dist_m} m 띠 ${hectare(road.area_m2)} (${(road.ratio * 100).toFixed(1)} %)`,
|
||||
color: "var(--draw-map-band-road)",
|
||||
},
|
||||
]);
|
||||
}
|
||||
const tail: LegendItem[] = [];
|
||||
if (on.water && data.water)
|
||||
tail.push({ label: `물 면 ${data.water.features.length}곳`, color: "var(--draw-map-water)" });
|
||||
if (on.building && data.building)
|
||||
tail.push({
|
||||
label: `건물 ${data.building.features.length.toLocaleString()}동`,
|
||||
color: "var(--draw-map-building)",
|
||||
});
|
||||
if (on.forestRoad && data.road)
|
||||
tail.push({
|
||||
label: `임도 짐작 ${data.road.small.features.length}가닥`,
|
||||
color: "var(--draw-map-forest-road)",
|
||||
kind: "dash",
|
||||
});
|
||||
if (tail.length) rows.push(tail);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function sync(): void {
|
||||
for (const def of LAYERS) {
|
||||
@@ -148,7 +227,9 @@ export function createPlanLayers(opts: {
|
||||
button.title = title;
|
||||
button.setAttribute("aria-label", title);
|
||||
}
|
||||
legend.hidden = !meta || !on.slope || !data.slope;
|
||||
const rows = meta ? legendRows() : [];
|
||||
legend.replaceChildren(...rows.map((row) => createLegend(row)));
|
||||
legend.hidden = !rows.length;
|
||||
}
|
||||
|
||||
sync();
|
||||
@@ -166,11 +247,11 @@ export function createPlanLayers(opts: {
|
||||
map.update({ image: on.satellite ? satellite : null });
|
||||
}
|
||||
|
||||
/** 켜진 겹의 자료를 받음(한 번). */
|
||||
/** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */
|
||||
function ensure(key: PlanLayerKey): Promise<void> {
|
||||
if (!meta || !on[key] || (key === "satellite" ? satellite : data[key as keyof Prepared]))
|
||||
return Promise.resolve();
|
||||
const running = pending.get(key);
|
||||
if (!meta || !on[key] || loadedOf(key)) return Promise.resolve();
|
||||
const slot = key === "satellite" ? key : dataKey(key);
|
||||
const running = pending.get(slot);
|
||||
if (running) return running;
|
||||
const at = meta;
|
||||
const job = fetchLayer(key, at)
|
||||
@@ -182,10 +263,15 @@ export function createPlanLayers(opts: {
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
for (const def of LAYERS)
|
||||
if (def.key === key || (def.key !== "satellite" && dataKey(def.key) === slot))
|
||||
on[def.key] = false;
|
||||
sync();
|
||||
map.redraw();
|
||||
showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error");
|
||||
})
|
||||
.finally(() => pending.delete(key));
|
||||
pending.set(key, job);
|
||||
.finally(() => pending.delete(slot));
|
||||
pending.set(slot, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
@@ -193,6 +279,13 @@ export function createPlanLayers(opts: {
|
||||
const normalizer = createNormalizer(at);
|
||||
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
|
||||
prepareLayer(collection, normalizer, labels);
|
||||
const pick = (
|
||||
collection: GeoJsonCollection,
|
||||
test: (props: Record<string, unknown>) => boolean,
|
||||
): GeoJsonCollection => ({
|
||||
...collection,
|
||||
features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})),
|
||||
});
|
||||
if (key === "satellite") {
|
||||
// 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀
|
||||
const v = `${at.x_min}_${at.x_max}_${at.y_min}_${at.y_max}`;
|
||||
@@ -219,29 +312,58 @@ export function createPlanLayers(opts: {
|
||||
projectId,
|
||||
"도엽_하천중심선",
|
||||
);
|
||||
const pick = (small: boolean): GeoJsonCollection => ({
|
||||
...collection,
|
||||
features: (collection.features ?? []).filter(
|
||||
(feature) => (feature.properties?.["구분"] === "세류") === small,
|
||||
),
|
||||
});
|
||||
data.stream = [prepare(pick(true)), prepare(pick(false))];
|
||||
data.stream = [
|
||||
prepare(pick(collection, (props) => props["구분"] === "세류")),
|
||||
prepare(pick(collection, (props) => props["구분"] !== "세류")),
|
||||
];
|
||||
} else if (key === "cadastral") {
|
||||
data.cadastral = prepare(await fetchPlanCadastral(projectId));
|
||||
} else if (key === "road") {
|
||||
data.road = prepare(await fetchPlanSheetLayer(projectId, "도로중심선"));
|
||||
} else {
|
||||
const raster = await fetchPlanRaster(projectId, "slope");
|
||||
data.slope = { raster, image: await loadImage(raster.image) };
|
||||
// 범례 = 서버 단계 색 그대로(색 한 곳)
|
||||
legend.replaceChildren(
|
||||
createLegend(
|
||||
raster.bands.map((band) => ({
|
||||
label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`,
|
||||
color: band.color,
|
||||
})),
|
||||
} else if (key === "road" || key === "forestRoad") {
|
||||
const collection = await fetchPlanSheetLayer(projectId, "도로중심선");
|
||||
data.road = {
|
||||
all: prepare(collection),
|
||||
small: prepare(
|
||||
pick(collection, (props) => FOREST_ROAD_KINDS.has(String(props["도로구분"]))),
|
||||
),
|
||||
};
|
||||
} else if (key === "slope" || key === "hillshade") {
|
||||
const raster = await fetchPlanRaster(projectId, key);
|
||||
data[key] = { raster, image: await loadImage(raster.image) };
|
||||
} else if (key === "water") {
|
||||
const parts = await Promise.all(
|
||||
WATER_KEYS.map((name) => fetchPlanSheetLayer(projectId, name)),
|
||||
);
|
||||
data.water = prepare({ features: parts.flatMap((part) => part.features ?? []) });
|
||||
} else if (key === "building") {
|
||||
data.building = prepare(await fetchPlanSheetLayer(projectId, "건물"));
|
||||
} else if (key === "zones") {
|
||||
const zones = await fetchPlanZones(projectId);
|
||||
const kinds = new Map<string, PreparedLayer>();
|
||||
for (const kind of Object.keys(zones.summary))
|
||||
kinds.set(kind, prepare(pick(zones, (props) => props["kind"] === kind)));
|
||||
data.zones = { kinds, summary: zones.summary };
|
||||
const failed = Object.values(zones.summary).filter((kind) => kind.failed);
|
||||
if (failed.length)
|
||||
showToast(
|
||||
`보호 · 규제 구역 일부 받지 못함 — ${failed.map((kind) => kind.label).join(" · ")}`,
|
||||
"error",
|
||||
);
|
||||
} else if (key === "bands") {
|
||||
const info = await fetchPlanBands(projectId);
|
||||
data.bands = {
|
||||
stream: prepare(info.stream.collection),
|
||||
road: prepare(info.road.collection),
|
||||
info,
|
||||
};
|
||||
} else {
|
||||
const collection = await fetchPlanSheetLayer(projectId, "지명");
|
||||
data.place = (collection.features ?? []).flatMap((feature) => {
|
||||
const geometry = feature.geometry;
|
||||
const name = feature.properties?.["명칭"];
|
||||
if (geometry?.type !== "Point" || typeof name !== "string" || !name) return [];
|
||||
const [lon, lat] = geometry.coordinates as number[];
|
||||
return [{ lon, lat, name }];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,16 +380,60 @@ export function createPlanLayers(opts: {
|
||||
drawPreparedLayer(context, layer, map.view, "dot", everyM);
|
||||
};
|
||||
|
||||
const drawRaster = (
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
raster: { raster: PlanRaster; image: HTMLImageElement },
|
||||
): void => {
|
||||
const [x0, y0, x1, y1] = raster.raster.box_m;
|
||||
const [left, top] = map.toScreen([x0, y1]);
|
||||
const [right, bottom] = map.toScreen([x1, y0]);
|
||||
context.drawImage(raster.image, left, top, right - left, bottom - top);
|
||||
};
|
||||
|
||||
const layers: PlanMapLayer[] = [
|
||||
{
|
||||
draw: (context, map) => {
|
||||
const slope = on.slope && data.slope;
|
||||
if (!slope) return;
|
||||
const [x0, y0, x1, y1] = slope.raster.box_m;
|
||||
const [left, top] = map.toScreen([x0, y1]);
|
||||
const [right, bottom] = map.toScreen([x1, y0]);
|
||||
if (!on.hillshade || !data.hillshade) return;
|
||||
// 위성 위 = 곱하기(밝은 곳은 그대로 · 그늘만 짙게) · 위성 없으면 그대로
|
||||
if (on.satellite && satellite) {
|
||||
context.globalCompositeOperation = "multiply";
|
||||
context.globalAlpha = 0.75;
|
||||
}
|
||||
drawRaster(context, map, data.hillshade);
|
||||
},
|
||||
},
|
||||
{
|
||||
draw: (context, map) => {
|
||||
if (!on.slope || !data.slope) return;
|
||||
context.globalAlpha = 0.55;
|
||||
context.drawImage(slope.image, left, top, right - left, bottom - top);
|
||||
drawRaster(context, map, data.slope);
|
||||
},
|
||||
},
|
||||
{
|
||||
draw: (context, map) => {
|
||||
if (!on.zones || !data.zones) return;
|
||||
for (const [kind, layer] of data.zones.kinds) {
|
||||
const color = themeColor(ZONE_COLORS[kind] ?? "--draw-warning", "#eab308");
|
||||
fillLayer(context, layer, map.view, color, 0.22, { color, width: 1.6 });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
draw: (context, map) => {
|
||||
if (!on.bands || !data.bands) return;
|
||||
const road = themeColor("--draw-map-band-road", "#facc15");
|
||||
const stream = themeColor("--draw-map-band-stream", "#22d3ee");
|
||||
fillLayer(context, data.bands.road, map.view, road, 0.16, {
|
||||
color: road,
|
||||
width: 1.2,
|
||||
dash: [6, 4],
|
||||
});
|
||||
fillLayer(context, data.bands.stream, map.view, stream, 0.18, {
|
||||
color: stream,
|
||||
width: 1.2,
|
||||
dash: [6, 4],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -277,6 +443,23 @@ export function createPlanLayers(opts: {
|
||||
stroke(context, map, data.cadastral, themeColor("--draw-map-parcel", "#f0abfc"), 0.8);
|
||||
},
|
||||
},
|
||||
{
|
||||
draw: (context, map) => {
|
||||
if (!on.water || !data.water) return;
|
||||
const color = themeColor("--draw-map-water", "#0ea5e9");
|
||||
fillLayer(context, data.water, map.view, color, 0.45, { color, width: 1 });
|
||||
},
|
||||
},
|
||||
{
|
||||
draw: (context, map) => {
|
||||
if (!on.building || !data.building) return;
|
||||
const color = themeColor("--draw-map-building", "#fb7185");
|
||||
fillLayer(context, data.building, map.view, color, 0.7, {
|
||||
color: "rgba(0, 0, 0, 0.55)",
|
||||
width: 0.6,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
draw: (context, map) => {
|
||||
const contour = on.contour && data.contour;
|
||||
@@ -303,8 +486,17 @@ export function createPlanLayers(opts: {
|
||||
{
|
||||
draw: (context, map) => {
|
||||
if (!on.road || !data.road) return;
|
||||
stroke(context, map, data.road, "rgba(0, 0, 0, 0.55)", 3.2);
|
||||
stroke(context, map, data.road, themeColor("--draw-map-road", "#f8fafc"), 1.6);
|
||||
stroke(context, map, data.road.all, "rgba(0, 0, 0, 0.55)", 3.2);
|
||||
stroke(context, map, data.road.all, themeColor("--draw-map-road", "#f8fafc"), 1.6);
|
||||
},
|
||||
},
|
||||
{
|
||||
draw: (context, map) => {
|
||||
if (!on.forestRoad || !data.road) return;
|
||||
context.setLineDash([7, 4]);
|
||||
stroke(context, map, data.road.small, "rgba(0, 0, 0, 0.55)", 3.6);
|
||||
stroke(context, map, data.road.small, themeColor("--draw-map-forest-road", "#f59e0b"), 2);
|
||||
context.setLineDash([]);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -315,6 +507,29 @@ export function createPlanLayers(opts: {
|
||||
stroke(context, map, data.stream[1], color, 2.4);
|
||||
},
|
||||
},
|
||||
{
|
||||
draw: (context, map) => {
|
||||
if (!on.place || !data.place || !meta) return;
|
||||
const normalizer = createNormalizer(meta);
|
||||
context.font = "600 11px system-ui, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "bottom";
|
||||
context.lineWidth = 3;
|
||||
context.strokeStyle = "rgba(0, 0, 0, 0.7)";
|
||||
context.fillStyle = themeColor("--draw-map-place", "#f8fafc");
|
||||
for (const place of data.place) {
|
||||
const [x, y] = lonLatToScreen(normalizer, map.view, place.lon, place.lat);
|
||||
if (x < -60 || y < -20 || x > map.view.width + 60 || y > map.view.height + 20) continue;
|
||||
map.upright(context, x, y, () => {
|
||||
context.beginPath();
|
||||
context.arc(x, y, 2.5, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.strokeText(place.name, x, y - 4);
|
||||
context.fillText(place.name, x, y - 4);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function load(): Promise<void> {
|
||||
@@ -325,7 +540,7 @@ export function createPlanLayers(opts: {
|
||||
meta = next;
|
||||
if (changed) {
|
||||
satellite = null;
|
||||
for (const key of Object.keys(data)) delete data[key as keyof Prepared];
|
||||
for (const key of Object.keys(data)) delete data[key as DataKey];
|
||||
}
|
||||
map.update({ meta });
|
||||
showSatellite();
|
||||
@@ -333,6 +548,28 @@ export function createPlanLayers(opts: {
|
||||
await Promise.all(LAYERS.map((def) => ensure(def.key)));
|
||||
}
|
||||
|
||||
const counts = (): Record<string, number> => ({
|
||||
...(data.building && { building: data.building.features.length }),
|
||||
...(data.water && { water: data.water.features.length }),
|
||||
...(data.road && {
|
||||
road: data.road.all.features.length,
|
||||
forestRoad: data.road.small.features.length,
|
||||
}),
|
||||
...(data.place && { place: data.place.length }),
|
||||
...(data.zones &&
|
||||
Object.fromEntries(
|
||||
Object.entries(data.zones.summary).flatMap(([kind, value]) => [
|
||||
[`zone_${kind}`, value.count],
|
||||
[`zone_${kind}_m2`, value.area_m2],
|
||||
]),
|
||||
)),
|
||||
...(data.bands && {
|
||||
band_stream_m2: data.bands.info.stream.area_m2,
|
||||
band_road_m2: data.bands.info.road.area_m2,
|
||||
}),
|
||||
...(data.hillshade && { hillshade_px: data.hillshade.image.naturalWidth }),
|
||||
});
|
||||
|
||||
return {
|
||||
bar,
|
||||
legend,
|
||||
@@ -340,10 +577,8 @@ export function createPlanLayers(opts: {
|
||||
load,
|
||||
state: () => ({
|
||||
on: { ...on },
|
||||
loaded: [
|
||||
...(satellite ? (["satellite"] as PlanLayerKey[]) : []),
|
||||
...(Object.keys(data) as PlanLayerKey[]),
|
||||
],
|
||||
loaded: LAYERS.map((def) => def.key).filter(loadedOf),
|
||||
counts: counts(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,20 +25,21 @@
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* 571-1 — 왼쪽은 겹 단추줄(222px) 오른쪽까지 · 그 사이가 모자라면 도구줄이 가로 밀림 · 오른쪽 붙임 그대로 */
|
||||
/* 571-1 — 왼쪽은 겹 단추줄(폭 = JS `--d03-layers-w`) 오른쪽까지 · 모자라면 도구줄이 가로 밀림 · 오른쪽 붙임 그대로 */
|
||||
.d03-plan__tools {
|
||||
position: absolute;
|
||||
top: var(--spacing-8);
|
||||
right: var(--spacing-8);
|
||||
left: calc(2 * var(--spacing-8) + 222px);
|
||||
left: calc(2 * var(--spacing-8) + var(--d03-layers-w, 222px));
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* 569-2 바탕 겹 단추 — 왼쪽 위 · 급경사 범례 — 왼쪽 아래 */
|
||||
/* 569-2 · 581-1 바탕 겹 단추 — 왼쪽 위 · 지도 반 넘으면 가로 밀림 · 범례 — 왼쪽 아래(켠 겹마다 한 줄) */
|
||||
.d03-plan__layers {
|
||||
position: absolute;
|
||||
top: var(--spacing-8);
|
||||
left: var(--spacing-8);
|
||||
max-width: calc(55% - var(--spacing-8));
|
||||
}
|
||||
|
||||
.d03-plan__legend {
|
||||
@@ -48,6 +49,14 @@
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-width: calc(100% - 2 * var(--spacing-8));
|
||||
}
|
||||
|
||||
.d03-plan__legend[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.d03-plan__hint {
|
||||
|
||||
@@ -48,17 +48,26 @@ def test_d03_parts():
|
||||
for b05_only in ("footprint", "station-snap", "contour-minor"):
|
||||
assert f'"{b05_only}"' not in tools, b05_only
|
||||
assert "map.stations" in draw and "map.slopes" in draw
|
||||
# 569-2 — 바탕 겹 단추(왼쪽 위 아이콘 줄) · 처음 켬 셋 · 취향 기억
|
||||
assert "createToolbar({" in layers and "createIconButton({" in layers
|
||||
# 569-2 · 581-1 — 바탕 겹 단추(공용 지도 도구줄 한 줄 · 묶음 셋) · 처음 켬 셋 · 취향 기억
|
||||
defs = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts")
|
||||
assert "createMapTools({" in layers and "createIconButton({" in layers
|
||||
assert "createToolbar(" not in layers
|
||||
assert '"aislo:pref:d03.layers"' in layers and "writeByKey(PREF_KEY" in layers
|
||||
keys = re.findall(r'\{ key: "(\w+)", icon: "\w+", title: "[^"]+", on: (true|false) \}', layers)
|
||||
keys = re.findall(r'key: "(\w+)",\s+icon: "\w+",\s+title:\s+"[^"]+",\s+on: (true|false)', defs)
|
||||
assert keys == [
|
||||
("satellite", "true"),
|
||||
("hillshade", "false"),
|
||||
("contour", "true"),
|
||||
("stream", "true"),
|
||||
("cadastral", "false"),
|
||||
("road", "false"),
|
||||
("slope", "false"),
|
||||
("stream", "true"),
|
||||
("water", "false"),
|
||||
("road", "false"),
|
||||
("forestRoad", "false"),
|
||||
("cadastral", "false"),
|
||||
("zones", "false"),
|
||||
("bands", "false"),
|
||||
("building", "false"),
|
||||
("place", "false"),
|
||||
], keys
|
||||
assert ".d03-plan__layers" in css and ".d03-plan__legend" in css
|
||||
assert "createSegment({" in draw and draw.count("createInputField(") == 2
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_581_1_plan_gis_layers.py -q
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from shapely.geometry import LineString
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS
|
||||
@@ -199,3 +202,14 @@ def test_hillshade_saved_apart_from_slope(tmp_path) -> None:
|
||||
assert shade["image"].startswith("data:image/png;base64,") and shade["bands"] == []
|
||||
assert slope["bands"] and shade["box_m"] == slope["box_m"]
|
||||
assert layers.slope_layer(tmp_path, models, EPSG, "hillshade") == shade
|
||||
|
||||
|
||||
def test_hillshade_hole_transparent() -> None:
|
||||
z = np.tile(np.arange(20) * 1.0, (20, 1))
|
||||
valid = np.ones(z.shape, bool)
|
||||
valid[:5, :5] = False # 남서 귀퉁이 = 그림 왼쪽 아래
|
||||
value = layers.hillshade_layer_png(z, valid, 2.0)
|
||||
alpha = np.asarray(Image.open(io.BytesIO(base64.b64decode(value["image"].split(",", 1)[1]))))[
|
||||
..., 1
|
||||
]
|
||||
assert (alpha[-5:, :5] == 0).all() and (alpha[:10, 10:] == 255).all()
|
||||
|
||||
@@ -138,6 +138,10 @@ const PATHS = {
|
||||
"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z|M4 12l16 0|M12 4l0 16",
|
||||
angle:
|
||||
"M21 19h-18l9 -15|M20.615 15.171h.015|M19.515 11.771h.015|M17.715 8.671h.015|M15.415 5.971h.015",
|
||||
// 581-1 — 지도 겹(보호 · 규제 구역 · 계류 · 도로 띠)
|
||||
shield: "M12 3a12 12 0 0 0 8.5 3a12 12 0 0 1 -8.5 15a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3",
|
||||
borderOuter:
|
||||
"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z|M12 8l0 .01|M8 12l0 .01|M12 12l0 .01|M16 12l0 .01|M12 16l0 .01",
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
@@ -555,6 +555,20 @@ html.is-bm-route .m01lab-src__body * {
|
||||
--draw-map-stream: #38bdf8; /* 세류 · 하천 */
|
||||
--draw-map-road: #f8fafc; /* 기존 도로 */
|
||||
--draw-map-parcel: #f0abfc; /* 지적 경계 */
|
||||
--draw-map-building: #fb7185; /* 도엽 건물 */
|
||||
--draw-map-water: #0ea5e9; /* 실폭하천 · 저수지 등 물 면 */
|
||||
--draw-map-forest-road: #f59e0b; /* 기존 임도 짐작(소로 · 미분류) */
|
||||
--draw-map-band-stream: #22d3ee; /* 계류 100 m 띠 */
|
||||
--draw-map-band-road: #facc15; /* 도로 300 m 띠 */
|
||||
--draw-map-place: #f8fafc; /* 지명 */
|
||||
--draw-map-zone-forest: #22c55e; /* 보호 · 규제 구역 — 산림보호구역 */
|
||||
--draw-map-zone-baekdu: #84cc16; /* 백두대간보호지역 */
|
||||
--draw-map-zone-water: #3b82f6; /* 상수원보호구역 */
|
||||
--draw-map-zone-park: #10b981; /* 자연공원 */
|
||||
--draw-map-zone-heritage: #c084fc; /* 국가유산 지정 · 보호구역 */
|
||||
--draw-map-zone-wetland: #2dd4bf; /* 습지보호지역 */
|
||||
--draw-map-zone-wildlife: #eab308; /* 야생생물보호구역 */
|
||||
--draw-map-zone-greenbelt: #f97316; /* 개발제한구역 */
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
|
||||
Reference in New Issue
Block a user