- GL 겹 스물: 위성 조각 · 음영 · 급경사 · 산사태위험(image) · 산림입지 · 보호구역 · 띠 · 물 면 · 건물(fill + 테두리) · 지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 · 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지(line · 점선 px 그대로) · 산사태취약 점 · 지명 점(circle) — 색 · 굵기 · 투명도 · 차례 = 옛 캔버스 그대로 - 음영 = 위성 위 「곱하기 0.75」와 같은 셈의 검은 반투명 그림(밝기 g → 투명도 0.75·(1−g)) · 위성 끔 = 그대로 - 등고 수치 · 지명 = symbol(겹치면 뺌 · 등고 글은 선 따라) · 글꼴 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림 (꼴에 글꼴 주소 없음 = 글꼴 조각 파일 없음 · 받기 전에 그 글자 글꼴 미리 받음) - 카메라 주인 = D03 화면 view · 돌림 하나 — 휠 · 가운데 끌기 · 돌림 · 맞춤 · 단축키 모두 draw() → glCamera - 처음 = GL(aislo:pref:d03.canvas = "1" 이면 옛 캔버스) · 겹 단추줄 끝 단추로 바꿈 · WebGL 못 열거나 잃음 = 저절로 옛 캔버스 + 알림 - GL 이면 캔버스 바탕 겹은 쉼(등고 주곡선 간격 셈만) · GL 이면 위성 한 장 그림 안 받음 - 산사태위험 그림도 경위도 귀퉁이(lonlat_corners 한 곳) · 묶음 꼴 표시 없는 합친 자료도 GL 이 받게 다시 쌈 - 캔버스 그리기 목록 = 새 D03_PlanAnalysis_UI_LayerPaint.ts(700줄 규칙) · 검증 창구 layers().gl = GL 겹별 그린 지물 수 - 잼(headless Edge 실 GPU · 검증_sub2_D · 겹 18 켬 = 자료 있는 겹 전부): 줌아웃 화면 간격 중앙 58.4 → 4.2 ms · 끊김 27 → 0/30 · 끌기 62.5 → 4.2 ms · 끊김 37 → 0/40 · 마우스 4.2 ms 끊김 0 그대로 · 그은 선 대 GL 0.019 px(돌림 30° 0.019 · 확대 0.077) - 시험: 캔버스 그리기를 옮긴 자리로 지킴 시험 넷 고침(581-2 · 582 · 584-1 · 584-3 — 그리기 차례 · 색 값이 LayerPaint 로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
/* =============================================================================
|
||
* D03_PlanAnalysis_UI_LayerDefs.ts
|
||
* D03 바탕 겹 정의(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 586-2) — 겹 단추 묶음 · 처음 값 · 보호구역 · 토양형 색 ·
|
||
* 산사태위험지도 범례 · 선 겹(단층 · 지질구조선 · 0차곡) 꼴 · 켬끔 취향 읽기 · 면 겹 채우기 · 선 긋기 · 그림 겹 놓기 ·
|
||
* GL 바탕 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림).
|
||
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기 차례 · 범례).
|
||
* ========================================================================== */
|
||
|
||
import type { IconName } from "@ui/ui_template_icons";
|
||
import type { LegendItem } from "@ui/ui_template_flow_legend";
|
||
import { readByKey } from "../A00_Common/b_page_state";
|
||
import {
|
||
drawPreparedLayer,
|
||
normalizedToScreen,
|
||
type GeoJsonCollection,
|
||
type PreparedLayer,
|
||
type ViewState,
|
||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||
import type { PlanRaster } from "./D03_PlanAnalysis_Api";
|
||
import type { ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||
import type { GlImage } from "./D03_PlanAnalysis_UI_MapGl";
|
||
|
||
export type PlanLayerKey =
|
||
| "satellite"
|
||
| "hillshade"
|
||
| "contour"
|
||
| "slope"
|
||
| "stream"
|
||
| "water"
|
||
| "road"
|
||
| "forestRoad"
|
||
| "forestRoadFile"
|
||
| "cadastral"
|
||
| "zones"
|
||
| "soils"
|
||
| "landslide"
|
||
| "landslideZones"
|
||
| "faults"
|
||
| "lineaments"
|
||
| "zeroOrder"
|
||
| "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: "forestRoadFile",
|
||
icon: "map",
|
||
title: "올린 임도망도(산림청 시군구 SHP = 진한 주황 실선 · 옆 단추로 올림)",
|
||
on: false,
|
||
},
|
||
],
|
||
[
|
||
{ key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false },
|
||
{
|
||
key: "zones",
|
||
icon: "shield",
|
||
title: "보호 · 규제 구역(브이월드 여덟 종 · 참고용)",
|
||
on: false,
|
||
},
|
||
{
|
||
key: "soils",
|
||
icon: "leaf",
|
||
title: "산림입지도(토양형 · 산림청 산림입지토양도 · 참고용)",
|
||
on: false,
|
||
},
|
||
{
|
||
key: "landslide",
|
||
icon: "alert",
|
||
title: "산사태위험지도(1~5 등급 · 행안부 생활안전지도 · 참고용)",
|
||
on: false,
|
||
},
|
||
{
|
||
key: "landslideZones",
|
||
icon: "alertOctagon",
|
||
title: "산사태취약지역(시도 지정 현황 · 점 + 지번 필지 테두리 · 참고용)",
|
||
on: false,
|
||
},
|
||
{ key: "faults", icon: "bolt", title: "단층(브이월드 지질 · 참고용)", on: false },
|
||
{
|
||
key: "lineaments",
|
||
icon: "lineDashed",
|
||
title: "지질구조선(브이월드 지질 · 참고용)",
|
||
on: false,
|
||
},
|
||
{
|
||
key: "zeroOrder",
|
||
icon: "arrowsJoin",
|
||
title: "0차곡(지형 추정 골짜기 머리 · 위 집수 0.1~1 ha · 곡두 통과 피함)",
|
||
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",
|
||
};
|
||
/** 산사태위험지도 범례(1 등급 = 가장 위험). */
|
||
export const LANDSLIDE_LEGEND: LegendItem[] = [1, 2, 3, 4, 5].map((grade) => ({
|
||
label: `${grade}등급`,
|
||
color: `var(--draw-map-landslide-${grade})`,
|
||
}));
|
||
/** 선 겹 — 이름 · 그림 꼴(서버 `LINE_KINDS` 열쇠 = `kind` · 없으면 0차곡). 새 선 겹 = 여기 한 줄. */
|
||
export type LineLayerKey = "faults" | "lineaments" | "zeroOrder";
|
||
export const LINE_LAYERS: Record<
|
||
LineLayerKey,
|
||
{ label: string; kind?: string; color: string; width: number; dash: number[] }
|
||
> = {
|
||
faults: { label: "단층", kind: "faults", color: "--draw-map-fault", width: 2.4, dash: [] },
|
||
lineaments: {
|
||
label: "지질구조선",
|
||
kind: "lineaments",
|
||
color: "--draw-map-lineament",
|
||
width: 1.8,
|
||
dash: [8, 4],
|
||
},
|
||
zeroOrder: { label: "0차곡", color: "--draw-map-zero-order", width: 1.8, dash: [4, 3] },
|
||
};
|
||
export const LINE_KEYS = Object.keys(LINE_LAYERS) as LineLayerKey[];
|
||
/** 토양형 색 — 받은 범위 안 코드 차례로 돌려 씀. */
|
||
export const SOIL_COLORS = [1, 2, 3, 4, 5, 6].map((n) => `--draw-map-soil-${n}`);
|
||
/** 도로 중 기존 임도로 짐작하는 구분. */
|
||
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([]);
|
||
}
|
||
|
||
export const loadImage = (src: string): Promise<HTMLImageElement> =>
|
||
new Promise((done, fail) => {
|
||
const image = new Image();
|
||
image.onload = () => done(image);
|
||
image.onerror = () => fail(new Error("그림을 읽지 못했습니다"));
|
||
image.src = src;
|
||
});
|
||
|
||
/** 선 겹 긋기(점 솎음 `dot` · `everyM` = 등고 간격 거름). */
|
||
export function stroke(
|
||
context: CanvasRenderingContext2D,
|
||
map: ScreenMap,
|
||
layer: PreparedLayer,
|
||
color: string,
|
||
width: number,
|
||
everyM?: number,
|
||
): void {
|
||
context.strokeStyle = color;
|
||
context.lineWidth = width;
|
||
drawPreparedLayer(context, layer, map.view, "dot", everyM);
|
||
}
|
||
|
||
/** 그림 겹(작업 좌표계 m 상자 · 위 = 북). */
|
||
export function drawRaster(
|
||
context: CanvasRenderingContext2D,
|
||
map: ScreenMap,
|
||
raster: { raster: PlanRaster; image: HTMLImageElement },
|
||
): void {
|
||
// 세 귀퉁이로 놓음 — TM 격자는 경위도 바탕에 대해 수렴각만큼 돌아 있음(586-0)
|
||
const [x0, y0, x1, y1] = raster.raster.box_m;
|
||
const [left, top] = map.toScreen([x0, y1]);
|
||
const [right, rightY] = map.toScreen([x1, y1]);
|
||
const [bottomX, bottom] = map.toScreen([x0, y0]);
|
||
const { naturalWidth: w, naturalHeight: h } = raster.image;
|
||
context.transform(
|
||
(right - left) / w,
|
||
(rightY - top) / w,
|
||
(bottomX - left) / h,
|
||
(bottom - top) / h,
|
||
left,
|
||
top,
|
||
);
|
||
context.drawImage(raster.image, 0, 0);
|
||
}
|
||
|
||
/** 켬끔 취향(`key`) — 없거나 깨지면 처음 값. */
|
||
export function readPref(key: string): Record<PlanLayerKey, boolean> {
|
||
const on = Object.fromEntries(LAYERS.map((def) => [def.key, def.on])) as Record<
|
||
PlanLayerKey,
|
||
boolean
|
||
>;
|
||
try {
|
||
const saved = JSON.parse(readByKey(key) ?? "{}") as Partial<Record<PlanLayerKey, unknown>>;
|
||
for (const def of LAYERS)
|
||
if (typeof saved[def.key] === "boolean") on[def.key] = saved[def.key] as boolean;
|
||
} catch {
|
||
// 깨진 값 = 처음 값
|
||
}
|
||
return on;
|
||
}
|
||
|
||
/** GL 자료 — 지물마다 그릴 색(`_color`) · 덧 속성을 붙인 사본. */
|
||
export function glTagged(
|
||
collection: GeoJsonCollection,
|
||
color: (props: Record<string, unknown>) => string,
|
||
extra: Record<string, unknown> = {},
|
||
): GeoJsonCollection {
|
||
return {
|
||
...collection,
|
||
features: (collection.features ?? []).map((feature) => ({
|
||
...feature,
|
||
properties: { ...feature.properties, ...extra, _color: color(feature.properties ?? {}) },
|
||
})),
|
||
};
|
||
}
|
||
|
||
/** GL 그림 자리 — 서버 경위도 귀퉁이(왼쪽 아래부터 돎) → 왼쪽 위부터 · 없으면 null. */
|
||
export function glImage(raster: PlanRaster, url = raster.image): GlImage | null {
|
||
const box = raster.box;
|
||
return box?.length === 4 ? { url, corners: [box[3], box[2], box[1], box[0]] } : null;
|
||
}
|
||
|
||
/** 음영 그늘 그림 — 위성 위 「곱하기 0.75」와 같은 검은 반투명(밝기 g → 투명도 0.75 · (1 − g)). */
|
||
export function shadeImage(image: HTMLImageElement): string {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = image.naturalWidth;
|
||
canvas.height = image.naturalHeight;
|
||
const context = canvas.getContext("2d")!;
|
||
context.drawImage(image, 0, 0);
|
||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
|
||
const px = pixels.data;
|
||
for (let i = 0; i < px.length; i += 4) {
|
||
px[i + 3] = Math.round(0.75 * (255 - px[i]) * (px[i + 3] / 255));
|
||
px[i] = px[i + 1] = px[i + 2] = 0;
|
||
}
|
||
context.putImageData(pixels, 0, 0);
|
||
return canvas.toDataURL("image/png");
|
||
}
|