- 열쇠 그대로(nationalRoad · forestRoadFile) — 옛 켬끔 취향 이어짐 · forestRoad 열쇠 · 도로 소로 거름은 B05 자료 셈 이음으로 남김(겹 목록 · 꼴 · 범례만 걷음) - 기존 도로(road) 겹 · 소스 · 범례 그대로 - 시험 553_4 겹 차례 · 584_3 범례 기대값을 새 이름으로 고침 · 622_2 에 630 · 534 지킴 덧 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
673 lines
28 KiB
TypeScript
673 lines
28 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_Layers.ts
|
|
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 624-2 · 요구 395-2 · 440-8 · 442 · 443) — 지도 왼쪽 위 아이콘 한 줄
|
|
* (공용 겹 줄 `A00_Common/map/map_layer_bar` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로).
|
|
*
|
|
* 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) ·
|
|
* 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 임도망도(전국 국가임도 · 조각) ·
|
|
* 임도(업로드)(시군구 SHP · 올리기 = 왼쪽 「위치」 칸 `uploadRoads`)) ·
|
|
* 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 산사태위험지도 · 산사태취약지역(시도 표 점 · 지번 필지 테두리) ·
|
|
* 선 겹(단층 · 지질구조선 · 0차곡) ·
|
|
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
|
* 겹 목록 · 처음 켬(위성 · 등고선 · 세류) · 취향(`aislo:pref:d03.layers`) = 공용 `map_layer_list` 페이지 표 「d03」.
|
|
* 받기 = 켤 때 · 받은 지형 범위 지문(`load(have)` · 페이지가 지형면을 다 읽은 뒤 넘김)이 바뀌면 차례 +1 · 켠 겹 모두 다시 ·
|
|
* 옛 차례 답은 버림(475) · 도엽 선 겹(등고 · 세류 · 물 면 · 도로 · 건물 · 지명) = 보이는 도엽 조각(606 · `UI_SheetTiles`) · 지형을 안 받았으면 단추를 막음 ·
|
|
* 받기 실패 = 그 겹만 꺼짐 + 알림 · 받는 일은 페이지 서클에 셈(`track`).
|
|
* 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
|
|
* 건물 → 등고선 → 도로 → 임도(업로드) → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
|
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
|
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · 칸 = 공용 `map_legend` · 줄 = `D03_PlanAnalysis_UI_LayerLegend`).
|
|
* 급경사 = 서버 값 그림을 고른 범위대로 칠함 · 범례 ⚙ → 범위 모달(603 · `D03_PlanAnalysis_UI_SlopeBands`).
|
|
* GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을
|
|
* MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 ·
|
|
* WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈).
|
|
* 받은 산림 · 환경 자료(622-2) = 임도망도 · 경제림 · 생태자연도(조각만 · 받기 없음) · 산림입지도 · 산사태위험(GL = 조각 ·
|
|
* 옛 캔버스 = 지금 받기 그대로) — 꼴 · 범례 = 공용 `A00_Common/map/map_forest_layers`.
|
|
* ========================================================================== */
|
|
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import { themeColor } from "@ui/ui_template_palette";
|
|
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
|
import { soilColor, tileOnly, type TileOnlyKey } from "../A00_Common/map/map_forest_layers";
|
|
import { createMapLayerBar } from "../A00_Common/map/map_layer_bar";
|
|
import { LAYERS, LINE_LAYERS, ZONE_COLORS, type RoadPick } from "../A00_Common/map/map_layer_list";
|
|
import type { TerrainSource } from "../A00_Common/map/map_terrain_chip";
|
|
import { createMapLegend } from "../A00_Common/map/map_legend";
|
|
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import {
|
|
createNormalizer,
|
|
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,
|
|
WATER_KEYS,
|
|
glImage,
|
|
glTagged,
|
|
loadImage,
|
|
shadeImage,
|
|
type PlanLayerKey,
|
|
} from "./D03_PlanAnalysis_UI_LayerDefs";
|
|
import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend";
|
|
import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint";
|
|
import { createGlBase, glFont, type GlImage } from "./D03_PlanAnalysis_UI_MapGl";
|
|
import { createSatLightRow, readSatLight } from "../A00_Common/map/map_sat_light";
|
|
import {
|
|
createSheetTiles,
|
|
SHEET_TILE_NAMES,
|
|
type SheetTiles,
|
|
} from "./D03_PlanAnalysis_UI_SheetTiles";
|
|
import {
|
|
openSlopeBands,
|
|
paintSlope,
|
|
readSlopeBands,
|
|
slopeGear,
|
|
slopeValues,
|
|
writeSlopeBands,
|
|
} from "../A00_Common/map/map_slope_bands";
|
|
import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
|
|
|
|
/** 옛 캔버스로 그림("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, TileOnlyKey>): 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;
|
|
/** 지형 원천이 바뀜(624-4) — 지형 겹(급경사 · 음영 · 0차곡)만 비우고 켠 것은 새 원천으로 다시 받음. */
|
|
reterrain: () => Promise<void>;
|
|
/** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */
|
|
state: () => {
|
|
on: Record<PlanLayerKey, boolean>;
|
|
loaded: PlanLayerKey[];
|
|
counts: Record<string, number>;
|
|
/** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */
|
|
gl: Record<string, number>;
|
|
/** GL 줌(GL 끔 = null · 622-2 검증). */
|
|
zoom: number | null;
|
|
/** 겹이 받은 범위(마지막 `load` 값) · 차례 번호(475 검증). */
|
|
area: [number, number, number, number] | null;
|
|
turn: number;
|
|
tiles: ReturnType<SheetTiles["state"]>;
|
|
/** 지형 겹이 받은 원천(응답 `source` · 624-4). */
|
|
sources: Record<string, string | undefined>;
|
|
};
|
|
}
|
|
|
|
export function createPlanLayers(opts: {
|
|
projectId: string;
|
|
map: PlanMap;
|
|
/** 받는 일을 진행 서클에 셈(끝나면 뺌). */
|
|
track: <T>(job: Promise<T>) => Promise<T>;
|
|
/** 지형 원천(지형 칩 · 624-4) — 급경사 · 음영 · 0차곡 `terrain=`. */
|
|
terrain: () => TerrainSource;
|
|
}): PlanLayers {
|
|
const { projectId, map, track, terrain } = opts;
|
|
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, track, (why) => {
|
|
if (!glOn) return;
|
|
showToast(`그래픽 카드 그림을 못 써 옛 그림으로 — ${why} · ${NO_SATELLITE_NOTE}`, "info");
|
|
useGl(false);
|
|
});
|
|
let glOn = readByKey(CANVAS_PREF_KEY) !== "1";
|
|
/** 겹 줄 · 켬끔 · 취향 = 공용 겹 목록(624-2 · 페이지 표 「d03」). */
|
|
const layerBar = createMapLayerBar({
|
|
page: "d03",
|
|
glOn,
|
|
onToggle: (key) => {
|
|
void ensure(key);
|
|
sync();
|
|
map.redrawBase();
|
|
},
|
|
onGl: () => {
|
|
writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0");
|
|
useGl(!glOn);
|
|
if (!glOn) showToast(NO_SATELLITE_NOTE, "info");
|
|
},
|
|
});
|
|
const { on, toggle } = layerBar;
|
|
/** 도엽 조각(606) — 조각이 오면 그 겹만 합친 자료로 다시 얹음. */
|
|
const tiles = createSheetTiles({
|
|
projectId,
|
|
track,
|
|
wanted: () =>
|
|
meta ? LAYERS.flatMap((def) => (on[def.key] && SHEET_TILE_NAMES[def.key]) || []) : [],
|
|
gl: () => glOn,
|
|
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()));
|
|
},
|
|
});
|
|
|
|
/** GL ↔ 옛 캔버스 — 못 켜면 캔버스(알림은 `onFail`). */
|
|
function useGl(want: boolean): void {
|
|
glOn = want && map.setGl(gl);
|
|
if (!glOn) map.setGl(null);
|
|
if (!glOn) void Promise.all(LAYERS.map((def) => ensure(def.key))); // 산사태위험 한 장(622-2)
|
|
sync();
|
|
map.redrawBase();
|
|
}
|
|
const bar = layerBar.root;
|
|
bar.classList.add("d03-plan__layers");
|
|
// 571-1 — 오른쪽 평면 도구줄이 이 줄 오른쪽부터(CSS `--d03-layers-w`)
|
|
new ResizeObserver(() =>
|
|
map.root.style.setProperty("--d03-layers-w", `${layerBar.bar.offsetWidth}px`),
|
|
).observe(layerBar.bar);
|
|
const legendBox = createMapLegend("d03-plan__legend");
|
|
const legend = legendBox.root;
|
|
|
|
/** 받음 — 위성 · 산림 조각 = GL 조각(받을 것 없음 · 옛 캔버스 = 안 그림) · 산사태위험 = GL 이면 조각(622-2). */
|
|
const loadedOf = (key: PlanLayerKey): boolean =>
|
|
tileOnly(key) || (glOn && key === "landslide") || !!data[dataKey(key)];
|
|
|
|
function sync(): void {
|
|
layerBar.sync({ ready: !!meta, glOn });
|
|
for (const def of LAYERS) gl.setVisible(def.key, on[def.key]);
|
|
gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain));
|
|
const light = meta && glOn && on.satellite; // 옛 캔버스 = 위성 없음
|
|
legendBox.show(meta ? legendRows(on, data) : [], light ? [lightRow] : []);
|
|
if (meta && on.slope && data.slope) legend.firstElementChild?.append(gear);
|
|
}
|
|
|
|
/** 위성 밝기(요구 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 ensure(key: PlanLayerKey): Promise<void> {
|
|
if (!meta || !on[key] || loadedOf(key)) return Promise.resolve();
|
|
const slot = tileOnly(key) ? "satellite" : 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 || (!tileOnly(def.key) && 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;
|
|
layerBar.save();
|
|
}
|
|
|
|
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, terrain());
|
|
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,
|
|
key === "hillshade" ? terrain() : undefined,
|
|
);
|
|
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) => ({
|
|
layer: prepare(pick(soils, (props) => props["code"] === soil.code)),
|
|
color: soilColor(soil.code), // GL 조각과 같은 무리 색(그림 = 조각 · 622-2)
|
|
})),
|
|
summary: soils.summary,
|
|
};
|
|
} 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, terrain());
|
|
got[key] = {
|
|
layer: prepare(found.collection),
|
|
count: found.count,
|
|
length_m: found.length_m,
|
|
source: "source" in found ? found.source : undefined,
|
|
};
|
|
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) => void (meta && tiles.view(m, meta)),
|
|
},
|
|
];
|
|
|
|
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)));
|
|
}
|
|
|
|
/** 지형 원천 바뀜 — 차례 +1(옛 원천 답 버림 · 받는 중 겹은 다시) · 지형 겹만 비움. */
|
|
async function reterrain(): Promise<void> {
|
|
areaTurn += 1;
|
|
pending.clear();
|
|
if (data.slope?.url) URL.revokeObjectURL(data.slope.url);
|
|
for (const key of ["slope", "hillshade", "zeroOrder"] as const) {
|
|
delete data[key];
|
|
gl.setData(key, null);
|
|
}
|
|
hillshade = null;
|
|
sync();
|
|
map.redrawBase();
|
|
await Promise.all(LAYERS.map((def) => ensure(def.key)));
|
|
}
|
|
|
|
return {
|
|
bar,
|
|
legend,
|
|
layers,
|
|
load,
|
|
uploadRoads,
|
|
deleteRoad,
|
|
pickRoad,
|
|
reterrain,
|
|
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,
|
|
]),
|
|
),
|
|
zoom: gl.map()?.getZoom() ?? null,
|
|
area,
|
|
turn: areaTurn,
|
|
tiles: tiles.state(),
|
|
sources: {
|
|
slope: data.slope?.raster.source,
|
|
hillshade: data.hillshade?.raster.source,
|
|
zeroOrder: data.zeroOrder?.source,
|
|
},
|
|
}),
|
|
};
|
|
}
|