- 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>
545 lines
21 KiB
TypeScript
545 lines
21 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 · 옆 올리기 단추)) ·
|
|
* 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 산사태위험지도 · 산사태취약지역(시도 표 점 · 지번 필지 테두리) ·
|
|
* 선 겹(단층 · 지질구조선 · 0차곡) ·
|
|
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
|
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
|
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림.
|
|
* 그리기 차례(아래 → 위) = 위성(`scene.image`) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
|
|
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
|
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
|
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`).
|
|
* 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 { pickFiles } from "@ui/ui_template_drop";
|
|
import { createIconButton } from "@ui/ui_template_button";
|
|
import { createMapTools } from "@ui/ui_template_map_tools";
|
|
import { themeColor } from "@ui/ui_template_palette";
|
|
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
|
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
|
import {
|
|
fetchVWorldMeta,
|
|
getVWorldMapUrl,
|
|
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,
|
|
fetchPlanSheetLayer,
|
|
fetchPlanSoils,
|
|
fetchPlanZeroOrder,
|
|
fetchPlanZones,
|
|
uploadPlanForestRoads,
|
|
} 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,
|
|
} 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 type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
|
|
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
|
|
|
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";
|
|
/** 등고선을 받아 올 그은 선 둘레(m). */
|
|
const CONTOUR_PAD_M = 600;
|
|
type DataKey = keyof Prepared;
|
|
|
|
/** 겹 → 받는 자료(임도 짐작 = 도로 자료). */
|
|
const dataKey = (key: Exclude<PlanLayerKey, "satellite">): DataKey =>
|
|
key === "forestRoad" ? "road" : key;
|
|
|
|
export interface PlanLayers {
|
|
bar: HTMLElement;
|
|
legend: HTMLElement;
|
|
/** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */
|
|
layers: PlanMapLayer[];
|
|
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */
|
|
load: () => Promise<void>;
|
|
/** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */
|
|
state: () => {
|
|
on: Record<PlanLayerKey, boolean>;
|
|
loaded: PlanLayerKey[];
|
|
counts: Record<string, number>;
|
|
/** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */
|
|
gl: Record<string, number>;
|
|
};
|
|
}
|
|
|
|
export function createPlanLayers(opts: {
|
|
projectId: string;
|
|
map: PlanMap;
|
|
/** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선). */
|
|
points: () => Vertex[];
|
|
}): PlanLayers {
|
|
const { projectId, map } = opts;
|
|
const on = readPref(PREF_KEY);
|
|
const data: Partial<Prepared> = {};
|
|
const pending = new Map<DataKey | "satellite", Promise<void>>();
|
|
let uploading = false;
|
|
let meta: VWorldMeta | null = null;
|
|
let satellite: HTMLImageElement | null = null;
|
|
/** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */
|
|
let hillshade: { plain: GlImage; shade: GlImage } | null = null;
|
|
const gl = createGlBase(projectId, (why) => {
|
|
if (!glOn) return;
|
|
showToast(`그래픽 카드 그림을 못 써 옛 그림으로 — ${why}`, "info");
|
|
useGl(false);
|
|
});
|
|
let glOn = readByKey(CANVAS_PREF_KEY) !== "1";
|
|
|
|
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 upload = createIconButton({
|
|
icon: "upload",
|
|
title: "임도망도 SHP 올리기(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)",
|
|
size: 32,
|
|
onClick: () =>
|
|
pickFiles(
|
|
{ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true },
|
|
(files) => void uploadRoads(files),
|
|
),
|
|
});
|
|
upload.dataset.layer = "forestRoadUpload";
|
|
const roadGroup = groups.find((group) => group.includes(buttons.get("forestRoadFile")!))!;
|
|
roadGroup.splice(roadGroup.indexOf(buttons.get("forestRoadFile")!) + 1, 0, upload);
|
|
const glButton = createIconButton({
|
|
icon: "gauge",
|
|
title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)",
|
|
size: 32,
|
|
pressed: glOn,
|
|
onClick: () => {
|
|
writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0");
|
|
useGl(!glOn);
|
|
},
|
|
});
|
|
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));
|
|
void ensure("satellite");
|
|
showSatellite();
|
|
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" ? glOn || !!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.title} — 지형 받기 뒤 보임`;
|
|
button.title = title;
|
|
button.setAttribute("aria-label", title);
|
|
}
|
|
upload.disabled = !meta || uploading;
|
|
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) : [];
|
|
legend.replaceChildren(...rows.map((row) => createLegend(row)));
|
|
legend.hidden = !rows.length;
|
|
}
|
|
|
|
sync();
|
|
useGl(glOn);
|
|
|
|
function toggle(key: PlanLayerKey): void {
|
|
on[key] = !on[key];
|
|
writeByKey(PREF_KEY, JSON.stringify(on));
|
|
void ensure(key);
|
|
showSatellite();
|
|
sync();
|
|
map.redrawBase();
|
|
}
|
|
|
|
function showSatellite(): void {
|
|
map.update({ image: on.satellite ? satellite : null });
|
|
}
|
|
|
|
/** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */
|
|
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 at = meta;
|
|
const job = fetchLayer(key, at)
|
|
.then(() => {
|
|
if (meta !== at) return;
|
|
showSatellite();
|
|
sync();
|
|
map.redrawBase();
|
|
})
|
|
.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(slot));
|
|
pending.set(slot, job);
|
|
return job;
|
|
}
|
|
|
|
/** 올리기 — 받으면 그 겹을 켜고 바로 그림 · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */
|
|
async function uploadRoads(files: File[]): Promise<void> {
|
|
if (!meta) return;
|
|
const at = meta;
|
|
const job = uploadPlanForestRoads(projectId, files).then((info) => {
|
|
if (meta !== at) return;
|
|
data.forestRoadFile = {
|
|
layer: prepareLayer(info.collection, createNormalizer(at)),
|
|
info,
|
|
};
|
|
gl.setData("forestRoadFile", info.collection);
|
|
on.forestRoadFile = true;
|
|
writeByKey(PREF_KEY, JSON.stringify(on));
|
|
showToast(
|
|
`임도망도 받음 — 범위 안 ${info.count}가닥 ${(info.length_m / 1000).toFixed(2)} km (파일 ${info.total}가닥)`,
|
|
"success",
|
|
);
|
|
});
|
|
uploading = true;
|
|
pending.set("forestRoadFile", job);
|
|
sync();
|
|
try {
|
|
await job;
|
|
} catch (error) {
|
|
showToast(
|
|
`임도망도 올리지 못함 — ${error instanceof Error ? error.message : String(error)}`,
|
|
"error",
|
|
);
|
|
} finally {
|
|
uploading = false;
|
|
pending.delete("forestRoadFile");
|
|
sync();
|
|
map.redrawBase();
|
|
}
|
|
}
|
|
|
|
async function fetchLayer(key: PlanLayerKey, at: VWorldMeta): 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 === "satellite") {
|
|
if (glOn) return; // GL = 조각
|
|
// 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀
|
|
const v = `${at.x_min}_${at.x_max}_${at.y_min}_${at.y_max}`;
|
|
satellite = await loadImage(`${getVWorldMapUrl(projectId, "satellite")}&v=${v}`);
|
|
} else if (key === "contour") {
|
|
const points = opts.points();
|
|
const xs = points.map(([x]) => x);
|
|
const ys = points.map(([, y]) => y);
|
|
const bbox = [
|
|
Math.min(...xs) - CONTOUR_PAD_M,
|
|
Math.min(...ys) - CONTOUR_PAD_M,
|
|
Math.max(...xs) + CONTOUR_PAD_M,
|
|
Math.max(...ys) + CONTOUR_PAD_M,
|
|
].map((v) => v.toFixed(0));
|
|
const [collection] = await Promise.all([
|
|
fetchCachedSheetLayer<GeoJsonCollection>(projectId, "도엽_등고선", "route", bbox.join(",")),
|
|
glFont("400", "0123456789-"),
|
|
]);
|
|
data.contour = indexLayerLod(prepare(collection, ["등고수치"]));
|
|
gl.setData("contour", collection);
|
|
} else if (key === "stream") {
|
|
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
|
|
projectId,
|
|
"도엽_하천중심선",
|
|
);
|
|
data.stream = [
|
|
prepare(pick(collection, (props) => props["구분"] === "세류")),
|
|
prepare(pick(collection, (props) => props["구분"] !== "세류")),
|
|
];
|
|
gl.setData("stream", collection);
|
|
} else if (key === "cadastral") {
|
|
const collection = await fetchPlanCadastral(projectId);
|
|
data.cadastral = prepare(collection);
|
|
gl.setData("cadastral", collection);
|
|
} 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["도로구분"]))),
|
|
),
|
|
};
|
|
gl.setData("road", collection);
|
|
} else if (key === "forestRoadFile") {
|
|
const info = await fetchPlanForestRoads(projectId);
|
|
data.forestRoadFile = { layer: prepare(info.collection), info };
|
|
gl.setData("forestRoadFile", info.collection);
|
|
} else if (key === "landslideZones") {
|
|
const info = await fetchPlanLandslideZones(projectId);
|
|
data.landslideZones = { parcels: prepare(info.parcels), info };
|
|
gl.setData("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" || key === "hillshade" || key === "landslide") {
|
|
const raster = await fetchPlanRaster(projectId, key);
|
|
const image = await loadImage(raster.image);
|
|
data[key] = { raster, image };
|
|
const plain = glImage(raster);
|
|
if (key !== "hillshade") gl.setData(key, plain);
|
|
else if (plain) hillshade = { plain, shade: glImage(raster, shadeImage(image))! };
|
|
} else if (key === "water") {
|
|
const parts = await Promise.all(
|
|
WATER_KEYS.map((name) => fetchPlanSheetLayer(projectId, name)),
|
|
);
|
|
const water = { features: parts.flatMap((part) => part.features ?? []) };
|
|
data.water = prepare(water);
|
|
gl.setData("water", water);
|
|
} else if (key === "building") {
|
|
const building = await fetchPlanSheetLayer(projectId, "건물");
|
|
data.building = prepare(building);
|
|
gl.setData("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)));
|
|
data.zones = { kinds, summary: zones.summary };
|
|
gl.setData(
|
|
"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);
|
|
data.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, data.soils!.kinds[i].color]),
|
|
);
|
|
gl.setData(
|
|
"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 got = kind
|
|
? await fetchPlanLines(projectId, kind).then((lines) => ({
|
|
collection: lines,
|
|
...lines.summary,
|
|
}))
|
|
: await fetchPlanZeroOrder(projectId);
|
|
data[key] = {
|
|
layer: prepare(got.collection),
|
|
count: got.count,
|
|
length_m: got.length_m,
|
|
};
|
|
gl.setData(key, got.collection);
|
|
} finally {
|
|
window.clearTimeout(slow);
|
|
}
|
|
} else if (key === "bands") {
|
|
const info = await fetchPlanBands(projectId);
|
|
data.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,
|
|
});
|
|
gl.setData("bands", {
|
|
features: [
|
|
...(band("road", "#facc15").features ?? []),
|
|
...(band("stream", "#22d3ee").features ?? []),
|
|
],
|
|
});
|
|
} 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 }];
|
|
});
|
|
await glFont("600", data.place.map((place) => place.name).join(""));
|
|
gl.setData("place", {
|
|
features: data.place.map(({ lon, lat, name }) => ({
|
|
type: "Feature",
|
|
properties: { name },
|
|
geometry: { type: "Point", coordinates: [lon, lat] },
|
|
})),
|
|
} as GeoJsonCollection);
|
|
}
|
|
}
|
|
|
|
const canvasLayers = createCanvasLayers({
|
|
on,
|
|
data,
|
|
meta: () => meta,
|
|
satellite: () => satellite,
|
|
});
|
|
|
|
/** GL 이면 캔버스 바탕 겹은 쉼 — 등고 주곡선 간격(화면 안 가닥 수)만 셈. */
|
|
const layers: PlanMapLayer[] = [
|
|
...canvasLayers.map((layer): PlanMapLayer => ({
|
|
...layer,
|
|
draw: (c, m) => void (glOn || layer.draw(c, m)),
|
|
})),
|
|
{
|
|
base: true,
|
|
draw: (_, m) => {
|
|
if (glOn && on.contour && data.contour)
|
|
gl.setContourStep(pickLevelStep(data.contour, m.view, 5));
|
|
},
|
|
},
|
|
];
|
|
|
|
async function load(): Promise<void> {
|
|
const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null);
|
|
const changed =
|
|
JSON.stringify(next && [next.x_min, next.x_max, next.y_min, next.y_max]) !==
|
|
JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]);
|
|
meta = next;
|
|
if (changed) {
|
|
satellite = null;
|
|
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 });
|
|
showSatellite();
|
|
sync();
|
|
await Promise.all(LAYERS.map((def) => ensure(def.key)));
|
|
}
|
|
|
|
return {
|
|
bar,
|
|
legend,
|
|
layers,
|
|
load,
|
|
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,
|
|
]),
|
|
),
|
|
}),
|
|
};
|
|
}
|