Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts
T
eomsangdonandClaude Opus 5.5 8793b4f6b2 feat(D03): 노선분석 바탕 MapLibre 전체 적용 — 겹 스물 · 등고 글 · 지명 GL · 처음 = 켬 · WebGL 못 쓰면 옛 캔버스 (PLAN 586-2 ①②③⑤ · 요구 448)
- 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>
2026-10-29 20:28:13 +09:00

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,
]),
),
}),
};
}