- 새 D03_PlanAnalysis_SheetTiles.py — 도엽 도곽으로 자른 선 겹 조각(등고 · 계곡 = 전국 등고선 · 세류 · 물 면 · 도로 · 건물 · 지명 = 도엽 zip) · zip 은 프로젝트 · 공용 지형 창고에서 복사(원본 안 지움) 뒤 받기 · 빈 도엽 = 빈 조각 · 받기 실패 저장 안 함 · B04 함수 부르기만 - 라우터 길 둘 — 보이는 도엽 목록(bbox) · 조각 하나 - 새 UI_SheetTiles.ts — 보기 멈춘 뒤 보이는 도엽 · 없는 조각만 하나씩 · 12 장 이하 다 · 13~30 계곡선만 · 넘음 숨김 · 서클 - UI_Layers — 도엽 선 겹 원천을 조각으로 · 시험 606 새 · 475 옛 겹 길 단언 바꿈 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
693 lines
28 KiB
TypeScript
693 lines
28 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_Layers.ts
|
|
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 요구 395-2 · 440-8 · 442 · 443) — 지도 왼쪽 위 아이콘 한 줄
|
|
* (공용 지도 도구줄 `createMapTools` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로).
|
|
*
|
|
* 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) ·
|
|
* 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류) ·
|
|
* 올린 임도망도(시군구 SHP · 올리기 = 왼쪽 「위치」 칸 `uploadRoads`)) ·
|
|
* 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 산사태위험지도 · 산사태취약지역(시도 표 점 · 지번 필지 테두리) ·
|
|
* 선 겹(단층 · 지질구조선 · 0차곡) ·
|
|
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
|
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
|
* 받기 = 켤 때 · 받은 지형 범위 지문(`load(have)` · 페이지가 지형면을 다 읽은 뒤 넘김)이 바뀌면 차례 +1 · 켠 겹 모두 다시 ·
|
|
* 옛 차례 답은 버림(475) · 도엽 선 겹(등고 · 세류 · 물 면 · 도로 · 건물 · 지명) = 보이는 도엽 조각(606 · `UI_SheetTiles`) · 지형을 안 받았으면 단추를 막음 ·
|
|
* 받기 실패 = 그 겹만 꺼짐 + 알림 · 받는 일은 페이지 서클에 셈(`track`).
|
|
* 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
|
|
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
|
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
|
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`).
|
|
* 급경사 = 서버 값 그림을 고른 범위대로 칠함 · 범례 ⚙ → 범위 모달(603 · `D03_PlanAnalysis_UI_SlopeBands`).
|
|
* GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을
|
|
* MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 ·
|
|
* WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈).
|
|
* ========================================================================== */
|
|
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import { createLegend } 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 { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
|
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import {
|
|
createNormalizer,
|
|
pickLevelStep,
|
|
type GeoJsonCollection,
|
|
type PreparedLayer,
|
|
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
|
import {
|
|
indexLayerLod,
|
|
prepareLayer,
|
|
} from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
|
import {
|
|
fetchPlanBands,
|
|
fetchPlanCadastral,
|
|
fetchPlanForestRoads,
|
|
fetchPlanLandslideZones,
|
|
fetchPlanLines,
|
|
fetchPlanRaster,
|
|
fetchPlanSoils,
|
|
fetchPlanZeroOrder,
|
|
fetchPlanZones,
|
|
deletePlanForestRoad,
|
|
uploadPlanForestRoads,
|
|
type PlanForestRoads,
|
|
} from "./D03_PlanAnalysis_Api";
|
|
import {
|
|
FOREST_ROAD_KINDS,
|
|
GROUPS,
|
|
LAYERS,
|
|
LINE_LAYERS,
|
|
SOIL_COLORS,
|
|
WATER_KEYS,
|
|
ZONE_COLORS,
|
|
glImage,
|
|
glTagged,
|
|
loadImage,
|
|
readPref,
|
|
shadeImage,
|
|
type PlanLayerKey,
|
|
type RoadPick,
|
|
} from "./D03_PlanAnalysis_UI_LayerDefs";
|
|
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 { createSatLightRow, readSatLight } from "./D03_PlanAnalysis_UI_SatLight";
|
|
import {
|
|
createSheetTiles,
|
|
SHEET_TILE_NAMES,
|
|
type SheetTiles,
|
|
} from "./D03_PlanAnalysis_UI_SheetTiles";
|
|
import {
|
|
openSlopeBands,
|
|
paintSlope,
|
|
readSlopeBands,
|
|
slopeGear,
|
|
slopeValues,
|
|
writeSlopeBands,
|
|
} from "./D03_PlanAnalysis_UI_SlopeBands";
|
|
import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
|
|
|
|
const PREF_KEY = "aislo:pref:d03.layers";
|
|
/** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */
|
|
const glFont = (weight: string, text: string): Promise<unknown> =>
|
|
document.fonts.load(`${weight} 24px "${GL_FONT}"`, text || "가").catch(() => null);
|
|
/** 옛 캔버스로 그림("1") — 처음 = GL. */
|
|
const CANVAS_PREF_KEY = "aislo:pref:d03.canvas";
|
|
/** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */
|
|
const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림";
|
|
type DataKey = keyof Prepared;
|
|
|
|
/** 겹 → 받는 자료(임도 짐작 = 도로 자료). */
|
|
const dataKey = (key: Exclude<PlanLayerKey, "satellite">): DataKey =>
|
|
key === "forestRoad" ? "road" : key;
|
|
|
|
export interface PlanLayers {
|
|
bar: HTMLElement;
|
|
legend: HTMLElement;
|
|
/** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */
|
|
layers: PlanMapLayer[];
|
|
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위(`have` · 지형면을 다 읽은 뒤 값)가 바뀌면 켠 겹 모두 새로. */
|
|
load: (have: [number, number, number, number] | null) => Promise<void>;
|
|
/** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */
|
|
uploadRoads: (files: File[]) => Promise<PlanForestRoads | null>;
|
|
/** 올린 묶음 하나 지움(605-1) — 지도에서 바로 빠짐 · 못 지우면 알림 + 거짓. */
|
|
deleteRoad: (bundle: string) => Promise<boolean>;
|
|
/** 올린 묶음 · 선 고름(605-2) — 지도 강조(꺼 있던 겹은 켬) · null = 강조 없앰. */
|
|
pickRoad: (pick: RoadPick | null) => void;
|
|
/** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */
|
|
state: () => {
|
|
on: Record<PlanLayerKey, boolean>;
|
|
loaded: PlanLayerKey[];
|
|
counts: Record<string, number>;
|
|
/** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */
|
|
gl: Record<string, number>;
|
|
/** 겹이 받은 범위(마지막 `load` 값) · 차례 번호(475 검증). */
|
|
area: [number, number, number, number] | null;
|
|
turn: number;
|
|
tiles: ReturnType<SheetTiles["state"]>;
|
|
};
|
|
}
|
|
|
|
export function createPlanLayers(opts: {
|
|
projectId: string;
|
|
map: PlanMap;
|
|
/** 받는 일을 진행 서클에 셈(끝나면 뺌). */
|
|
track: <T>(job: Promise<T>) => Promise<T>;
|
|
}): PlanLayers {
|
|
const { projectId, map, track } = opts;
|
|
const on = readPref(PREF_KEY);
|
|
const data: Partial<Prepared> = {};
|
|
const pending = new Map<DataKey | "satellite", Promise<void>>();
|
|
let meta: VWorldMeta | null = null;
|
|
let area: [number, number, number, number] | null = null;
|
|
/** 차례 — 받은 범위 · 위성 틀이 바뀔 때 +1 · 옛 차례 답은 자료 · GL 에 안 씀. */
|
|
let areaTurn = 0;
|
|
/** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */
|
|
let hillshade: { plain: GlImage; shade: GlImage } | null = null;
|
|
const gl = createGlBase(projectId, (why) => {
|
|
if (!glOn) return;
|
|
showToast(`그래픽 카드 그림을 못 써 옛 그림으로 — ${why} · ${NO_SATELLITE_NOTE}`, "info");
|
|
useGl(false);
|
|
});
|
|
let glOn = readByKey(CANVAS_PREF_KEY) !== "1";
|
|
/** 도엽 조각(606) — 조각이 오면 그 겹만 합친 자료로 다시 얹음. */
|
|
const tiles = createSheetTiles({
|
|
projectId,
|
|
track,
|
|
wanted: () =>
|
|
meta ? LAYERS.flatMap((def) => (on[def.key] && SHEET_TILE_NAMES[def.key]) || []) : [],
|
|
onChange: (names) => {
|
|
for (const def of LAYERS)
|
|
if (meta && on[def.key] && SHEET_TILE_NAMES[def.key]?.some((name) => names.has(name)))
|
|
void fetchLayer(def.key, meta, areaTurn).then(() => (sync(), map.redrawBase()));
|
|
},
|
|
});
|
|
|
|
const buttons = new Map<PlanLayerKey, HTMLButtonElement>();
|
|
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 glButton = createIconButton({
|
|
icon: "gauge",
|
|
title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)",
|
|
size: 32,
|
|
pressed: glOn,
|
|
onClick: () => {
|
|
writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0");
|
|
useGl(!glOn);
|
|
if (!glOn) showToast(NO_SATELLITE_NOTE, "info");
|
|
},
|
|
});
|
|
glButton.dataset.layer = "gl";
|
|
/** GL ↔ 옛 캔버스 — 못 켜면 캔버스(알림은 `onFail`). */
|
|
function useGl(want: boolean): void {
|
|
glOn = want && map.setGl(gl);
|
|
if (!glOn) map.setGl(null);
|
|
glButton.setAttribute("aria-pressed", String(glOn));
|
|
sync();
|
|
map.redrawBase();
|
|
}
|
|
groups.push([glButton]);
|
|
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 = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이";
|
|
|
|
/** 받음 — 위성 = GL 조각(받을 것 없음 · 옛 캔버스 = 위성 없음). */
|
|
const loadedOf = (key: PlanLayerKey): boolean => key === "satellite" || !!data[dataKey(key)];
|
|
|
|
function sync(): void {
|
|
for (const def of LAYERS) {
|
|
const button = buttons.get(def.key)!;
|
|
button.setAttribute("aria-pressed", String(on[def.key]));
|
|
button.disabled = !meta;
|
|
const title = !meta
|
|
? `${def.title} — 지형 받기 뒤 보임`
|
|
: def.key === "satellite" && !glOn
|
|
? `${def.title} — ${NO_SATELLITE_NOTE}`
|
|
: def.title;
|
|
button.title = title;
|
|
button.setAttribute("aria-label", title);
|
|
}
|
|
for (const def of LAYERS) gl.setVisible(def.key, on[def.key]);
|
|
gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain));
|
|
const rows = meta ? legendRows(on, data) : [];
|
|
const light = meta && glOn && on.satellite; // 옛 캔버스 = 위성 없음
|
|
legend.replaceChildren(...rows.map((row) => createLegend(row)), ...(light ? [lightRow] : []));
|
|
if (meta && on.slope && data.slope) legend.firstElementChild?.append(gear);
|
|
legend.hidden = !rows.length && !light;
|
|
}
|
|
|
|
/** 위성 밝기(요구 475) — 위성 켠 동안 범례 맨 아래 한 줄 · 바로 반영. */
|
|
gl.setSatelliteLight(readSatLight());
|
|
const lightRow = createSatLightRow(readSatLight(), (level) => {
|
|
gl.setSatelliteLight(level);
|
|
map.redrawBase();
|
|
});
|
|
|
|
/** 급경사 범위(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;
|
|
got.paintMs = -1; // 칠하는 중(검증 창구)
|
|
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);
|
|
|
|
function toggle(key: PlanLayerKey): void {
|
|
on[key] = !on[key];
|
|
writeByKey(PREF_KEY, JSON.stringify(on));
|
|
void ensure(key);
|
|
sync();
|
|
map.redrawBase();
|
|
}
|
|
|
|
/** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */
|
|
function ensure(key: PlanLayerKey): Promise<void> {
|
|
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 mine = areaTurn;
|
|
const job: Promise<void> = track(fetchLayer(key, meta, mine))
|
|
.then(() => {
|
|
if (mine !== areaTurn) return;
|
|
sync();
|
|
map.redrawBase();
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (mine !== areaTurn) return;
|
|
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.get(slot) === job && pending.delete(slot));
|
|
pending.set(slot, job);
|
|
return job;
|
|
}
|
|
|
|
/** 올리기 — 받으면 그 겹을 켜고 바로 그림(지형 받기 전이면 받은 뒤 그림) · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */
|
|
async function uploadRoads(files: File[]): Promise<PlanForestRoads | null> {
|
|
const at = meta;
|
|
const job = track(uploadPlanForestRoads(projectId, files)).then((info) => {
|
|
if (meta !== at) return info;
|
|
applyRoads(info);
|
|
showToast(
|
|
`임도망도 받음 — 범위 안 ${info.count}가닥 ${(info.length_m / 1000).toFixed(2)} km (파일 ${info.total}가닥)`,
|
|
"success",
|
|
);
|
|
return info;
|
|
});
|
|
// 거절은 아래 catch 가 알림 — 기다림 몫은 삼킴(처리 안 된 거절 없음)
|
|
pending.set(
|
|
"forestRoadFile",
|
|
job.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
),
|
|
);
|
|
try {
|
|
return await job;
|
|
} catch (error) {
|
|
showToast(
|
|
`임도망도 올리지 못함 — ${error instanceof Error ? error.message : String(error)}`,
|
|
"error",
|
|
);
|
|
return null;
|
|
} finally {
|
|
pending.delete("forestRoadFile");
|
|
sync();
|
|
map.redrawBase();
|
|
}
|
|
}
|
|
|
|
/** 올린 임도망 자료를 바꿔 그림(올림 · 지움) — 켜 둠 · null = 하나도 안 남음(끔). */
|
|
function applyRoads(info: PlanForestRoads | null): void {
|
|
if (info) {
|
|
if (meta)
|
|
data.forestRoadFile = {
|
|
layer: prepareLayer(info.collection, createNormalizer(meta)),
|
|
info,
|
|
};
|
|
gl.setData("forestRoadFile", info.collection);
|
|
} else {
|
|
delete data.forestRoadFile;
|
|
gl.setData("forestRoadFile", null);
|
|
roadPick = null;
|
|
gl.setRoadPick(null);
|
|
}
|
|
on.forestRoadFile = !!info;
|
|
writeByKey(PREF_KEY, JSON.stringify(on));
|
|
}
|
|
|
|
async function deleteRoad(bundle: string): Promise<boolean> {
|
|
try {
|
|
const info = await deletePlanForestRoad(projectId, bundle);
|
|
if (roadPick?.bundle === bundle) pickRoad(null);
|
|
applyRoads(info);
|
|
showToast(
|
|
info
|
|
? `임도망도 묶음 지움 — 남은 ${info.bundles.length}묶음`
|
|
: "임도망도 묶음 지움 — 남은 것 없음",
|
|
"success",
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
showToast(
|
|
`임도망도 묶음을 지우지 못함 — ${error instanceof Error ? error.message : String(error)}`,
|
|
"error",
|
|
);
|
|
return false;
|
|
} finally {
|
|
sync();
|
|
map.redrawBase();
|
|
}
|
|
}
|
|
|
|
/** 고른 묶음 · 선만 담은 캔버스 자료(옛 그림용) — 자료 · 고름이 같으면 그대로. */
|
|
let roadPick: RoadPick | null = null;
|
|
let pickMemo: { from: unknown; pick: RoadPick; layer: PreparedLayer } | null = null;
|
|
function pickLayer(): PreparedLayer | null {
|
|
const got = data.forestRoadFile;
|
|
if (!roadPick || !got || !meta) return null;
|
|
if (pickMemo?.from !== got || pickMemo.pick !== roadPick) {
|
|
const mine = roadPick;
|
|
const features = (got.info.collection.features ?? []).filter((f) => {
|
|
const props = f.properties ?? {};
|
|
return (
|
|
props["__bundle"] === mine.bundle &&
|
|
(mine.line === undefined || props["__line"] === mine.line)
|
|
);
|
|
});
|
|
pickMemo = {
|
|
from: got,
|
|
pick: mine,
|
|
layer: prepareLayer({ ...got.info.collection, features }, createNormalizer(meta)),
|
|
};
|
|
}
|
|
return pickMemo.layer;
|
|
}
|
|
|
|
function pickRoad(pick: RoadPick | null): void {
|
|
roadPick = pick;
|
|
gl.setRoadPick(pick);
|
|
if (pick && !on.forestRoadFile) toggle("forestRoadFile");
|
|
else map.redrawBase();
|
|
}
|
|
|
|
/** 겹 하나 받기 — 자료 · GL 은 모아 두었다가 지금 차례일 때만 씀(옛 받은 범위 답 버림 · 475). */
|
|
async function fetchLayer(key: PlanLayerKey, at: VWorldMeta, mine: number): Promise<void> {
|
|
const got: Partial<Prepared> = {};
|
|
const sets: Array<Parameters<typeof gl.setData>> = [];
|
|
const put = (...args: Parameters<typeof gl.setData>): void => void sets.push(args);
|
|
let shade = null as typeof hillshade;
|
|
await fill();
|
|
if (mine !== areaTurn) {
|
|
if (got.slope?.url) URL.revokeObjectURL(got.slope.url);
|
|
return;
|
|
}
|
|
if (got.slope && data.slope?.url) URL.revokeObjectURL(data.slope.url);
|
|
Object.assign(data, got);
|
|
if (shade) hillshade = shade;
|
|
for (const args of sets) gl.setData(...args);
|
|
// 칠하는 동안 범위가 바뀜 = 새 범위로 다시
|
|
if (got.slope && got.slope.bands !== slopeBands) {
|
|
got.slope.bands = slopeBands;
|
|
void repaintSlope();
|
|
}
|
|
|
|
async function fill(): Promise<void> {
|
|
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 === "contour") {
|
|
await glFont("400", "0123456789-");
|
|
const collection = tiles.collection("등고선");
|
|
got.contour = indexLayerLod(prepare(collection, ["등고수치"]));
|
|
put("contour", collection);
|
|
} else if (key === "stream") {
|
|
const collection = tiles.collection("하천중심선");
|
|
got.stream = [
|
|
prepare(pick(collection, (props) => props["구분"] === "세류")),
|
|
prepare(pick(collection, (props) => props["구분"] !== "세류")),
|
|
];
|
|
put("stream", collection);
|
|
} else if (key === "cadastral") {
|
|
const collection = await fetchPlanCadastral(projectId);
|
|
got.cadastral = prepare(collection);
|
|
put("cadastral", collection);
|
|
} else if (key === "road" || key === "forestRoad") {
|
|
const collection = tiles.collection("도로중심선");
|
|
got.road = {
|
|
all: prepare(collection),
|
|
small: prepare(
|
|
pick(collection, (props) => FOREST_ROAD_KINDS.has(String(props["도로구분"]))),
|
|
),
|
|
};
|
|
put("road", collection);
|
|
} else if (key === "forestRoadFile") {
|
|
const info = await fetchPlanForestRoads(projectId);
|
|
got.forestRoadFile = { layer: prepare(info.collection), info };
|
|
put("forestRoadFile", info.collection);
|
|
} else if (key === "landslideZones") {
|
|
const info = await fetchPlanLandslideZones(projectId);
|
|
got.landslideZones = { parcels: prepare(info.parcels), info };
|
|
put("landslideZones", {
|
|
features: [
|
|
...(info.parcels.features ?? []),
|
|
...info.points.map((point) => ({
|
|
type: "Feature",
|
|
properties: {},
|
|
geometry: { type: "Point", coordinates: [point.lon, point.lat] },
|
|
})),
|
|
],
|
|
} as GeoJsonCollection);
|
|
if (info.missing.length)
|
|
showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info");
|
|
} 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);
|
|
const paintMs = Math.round(performance.now() - started);
|
|
got.slope = { raster, values, bands, ...painted, paintMs };
|
|
put("slope", glImage(raster, painted.url));
|
|
} else if (key === "hillshade" || key === "landslide") {
|
|
const raster = await fetchPlanRaster(projectId, key);
|
|
const image = await loadImage(raster.image);
|
|
got[key] = { raster, image };
|
|
const plain = glImage(raster);
|
|
if (key !== "hillshade") put(key, plain);
|
|
else if (plain) shade = { plain, shade: glImage(raster, shadeImage(image))! };
|
|
} else if (key === "water") {
|
|
const parts = WATER_KEYS.map((name) => tiles.collection(name));
|
|
const water = { features: parts.flatMap((part) => part.features ?? []) };
|
|
got.water = prepare(water);
|
|
put("water", water);
|
|
} else if (key === "building") {
|
|
const building = tiles.collection("건물");
|
|
got.building = prepare(building);
|
|
put("building", building);
|
|
} 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)));
|
|
got.zones = { kinds, summary: zones.summary };
|
|
put(
|
|
"zones",
|
|
glTagged(zones, (props) =>
|
|
themeColor(ZONE_COLORS[String(props["kind"])] ?? "--draw-warning", "#eab308"),
|
|
),
|
|
);
|
|
const failed = Object.values(zones.summary).filter((kind) => kind.failed);
|
|
if (failed.length)
|
|
showToast(
|
|
`보호 · 규제 구역 일부 받지 못함 — ${failed.map((kind) => kind.label).join(" · ")}`,
|
|
"error",
|
|
);
|
|
} else if (key === "soils") {
|
|
const soils = await fetchPlanSoils(projectId);
|
|
got.soils = {
|
|
kinds: soils.summary.map((soil, i) => ({
|
|
layer: prepare(pick(soils, (props) => props["code"] === soil.code)),
|
|
color: SOIL_COLORS[i % SOIL_COLORS.length],
|
|
})),
|
|
summary: soils.summary,
|
|
};
|
|
const colorOf = new Map(
|
|
soils.summary.map((soil, i) => [soil.code, got.soils!.kinds[i].color]),
|
|
);
|
|
put(
|
|
"soils",
|
|
glTagged(soils, (props) =>
|
|
themeColor(colorOf.get(props["code"] as never) ?? "", "#facc15"),
|
|
),
|
|
);
|
|
} else if (key === "faults" || key === "lineaments" || key === "zeroOrder") {
|
|
const kind = LINE_LAYERS[key].kind;
|
|
// 0차곡 첫 셈 = 몇 초 — 늦으면 알림
|
|
const slow = window.setTimeout(
|
|
() => showToast(`${LINE_LAYERS[key].label} 셈 중 — 처음 한 번만`, "info"),
|
|
600,
|
|
);
|
|
try {
|
|
const found = kind
|
|
? await fetchPlanLines(projectId, kind).then((lines) => ({
|
|
collection: lines,
|
|
...lines.summary,
|
|
}))
|
|
: await fetchPlanZeroOrder(projectId);
|
|
got[key] = {
|
|
layer: prepare(found.collection),
|
|
count: found.count,
|
|
length_m: found.length_m,
|
|
};
|
|
put(key, found.collection);
|
|
} finally {
|
|
window.clearTimeout(slow);
|
|
}
|
|
} else if (key === "bands") {
|
|
const info = await fetchPlanBands(projectId);
|
|
got.bands = {
|
|
stream: prepare(info.stream.collection),
|
|
road: prepare(info.road.collection),
|
|
info,
|
|
};
|
|
const band = (name: "stream" | "road", fallback: string): GeoJsonCollection =>
|
|
glTagged(info[name].collection, () => themeColor(`--draw-map-band-${name}`, fallback), {
|
|
_band: name,
|
|
});
|
|
put("bands", {
|
|
features: [
|
|
...(band("road", "#facc15").features ?? []),
|
|
...(band("stream", "#22d3ee").features ?? []),
|
|
],
|
|
});
|
|
} else {
|
|
const collection = tiles.collection("지명");
|
|
got.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 }];
|
|
});
|
|
await glFont("600", got.place.map((place) => place.name).join(""));
|
|
put("place", {
|
|
features: got.place.map(({ lon, lat, name }) => ({
|
|
type: "Feature",
|
|
properties: { name },
|
|
geometry: { type: "Point", coordinates: [lon, lat] },
|
|
})),
|
|
} as GeoJsonCollection);
|
|
}
|
|
}
|
|
}
|
|
|
|
const canvasLayers = createCanvasLayers({
|
|
on,
|
|
data,
|
|
meta: () => meta,
|
|
roadPick: pickLayer,
|
|
});
|
|
|
|
/** GL 이면 캔버스 바탕 겹은 쉼 — 등고 주곡선 간격(화면 안 가닥 수)만 셈. */
|
|
const layers: PlanMapLayer[] = [
|
|
...canvasLayers.map((layer): PlanMapLayer => ({
|
|
...layer,
|
|
draw: (c, m) => void (glOn || layer.draw(c, m)),
|
|
})),
|
|
{
|
|
base: true,
|
|
draw: (_, m) => {
|
|
if (meta) tiles.view(m, meta);
|
|
if (glOn && on.contour && data.contour)
|
|
gl.setContourStep(pickLevelStep(data.contour, m.view, 5));
|
|
},
|
|
},
|
|
];
|
|
|
|
async function load(have: [number, number, number, number] | null): Promise<void> {
|
|
const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null);
|
|
// 바뀜 = 받은 범위 지문 또는 위성 틀 → 차례 +1 · 자료 · 받는 중 비움 · 켠 겹 모두 다시(475 · 596-4)
|
|
const frame = (m: VWorldMeta | null) =>
|
|
JSON.stringify(m && [m.x_min, m.x_max, m.y_min, m.y_max]);
|
|
const changed = JSON.stringify(have) !== JSON.stringify(area) || frame(next) !== frame(meta);
|
|
[meta, area] = [next, have];
|
|
if (changed) {
|
|
areaTurn += 1;
|
|
pending.clear();
|
|
for (const key of Object.keys(data)) delete data[key as DataKey];
|
|
hillshade = null;
|
|
for (const def of LAYERS)
|
|
if (def.key !== "satellite" && def.key !== "forestRoad") gl.setData(def.key, null);
|
|
}
|
|
map.update({ meta });
|
|
sync();
|
|
await Promise.all(LAYERS.map((def) => ensure(def.key)));
|
|
}
|
|
|
|
return {
|
|
bar,
|
|
legend,
|
|
layers,
|
|
load,
|
|
uploadRoads,
|
|
deleteRoad,
|
|
pickRoad,
|
|
state: () => ({
|
|
on: { ...on },
|
|
loaded: LAYERS.map((def) => def.key).filter(loadedOf),
|
|
counts: layerCounts(data),
|
|
gl: Object.fromEntries(
|
|
(gl.map()?.getStyle().layers ?? [])
|
|
.filter((layer) => layer.layout?.visibility !== "none")
|
|
.map((layer) => [
|
|
layer.id,
|
|
layer.type === "raster"
|
|
? 1
|
|
: gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length,
|
|
]),
|
|
),
|
|
area,
|
|
turn: areaTurn,
|
|
tiles: tiles.state(),
|
|
}),
|
|
};
|
|
}
|