545 lines
19 KiB
TypeScript
545 lines
19 KiB
TypeScript
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
|
import {
|
|
fetchGisGeoJson,
|
|
fetchVWorldMeta,
|
|
getVWorldMapUrl,
|
|
type SurfaceBounds,
|
|
type VWorldMeta,
|
|
} from "./B04_wf1_Surface_Api_Fetch";
|
|
import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera";
|
|
|
|
export interface SurfaceMapViewer {
|
|
root: HTMLElement;
|
|
render: (projectId: string, referenceBounds?: SurfaceBounds) => void;
|
|
dispose: () => void;
|
|
}
|
|
|
|
type GeoJsonGeometry = {
|
|
type: string;
|
|
coordinates: unknown;
|
|
};
|
|
|
|
type GeoJsonFeature = {
|
|
geometry?: GeoJsonGeometry | null;
|
|
properties?: Record<string, unknown> | null;
|
|
};
|
|
|
|
type GeoJsonCollection = {
|
|
features?: GeoJsonFeature[];
|
|
};
|
|
|
|
const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
|
|
const GIS_LAYERS = [
|
|
"지적도",
|
|
"행정구역_시군구",
|
|
"행정구역_읍면동",
|
|
"등고선",
|
|
"도엽_등고선",
|
|
"도엽_하천중심선",
|
|
"도엽_표고점",
|
|
"도엽_성절토",
|
|
"도엽_옹벽석축",
|
|
"도엽_유수방향",
|
|
] as const;
|
|
type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number];
|
|
type GisLayer = (typeof GIS_LAYERS)[number];
|
|
|
|
const GIS_LAYER_COLORS: Record<GisLayer, string> = {
|
|
지적도: "#f97316",
|
|
행정구역_시군구: "#7c3aed",
|
|
행정구역_읍면동: "#22c55e",
|
|
등고선: "#fdba74",
|
|
도엽_등고선: "#a5b4fc",
|
|
도엽_하천중심선: "#2563eb",
|
|
도엽_표고점: "#f9a8d4",
|
|
도엽_성절토: "#f43f5e",
|
|
도엽_옹벽석축: "#0f766e",
|
|
도엽_유수방향: "#0891b2",
|
|
};
|
|
|
|
// 등고 라벨 표기 대상 레이어와 표고 속성 키 (gpkg=CTRLN_HG, 도엽=등고수치)
|
|
const CONTOUR_LABEL_KEYS: Partial<Record<GisLayer, string[]>> = {
|
|
등고선: ["CTRLN_HG"],
|
|
도엽_등고선: ["등고수치"],
|
|
};
|
|
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
export function createSurfaceMapViewer(): SurfaceMapViewer {
|
|
const root = document.createElement("section");
|
|
root.className = "b04-map";
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "b04-map__header";
|
|
const title = document.createElement("h3");
|
|
title.textContent = L("B04_Surface_Map_Title");
|
|
|
|
const controls = document.createElement("div");
|
|
controls.className = "b04-map__controls";
|
|
const backgroundGroup = document.createElement("div");
|
|
backgroundGroup.className = "b04-map__control-group";
|
|
const backgroundTitle = document.createElement("span");
|
|
backgroundTitle.textContent = L("B04_Surface_Map_Background");
|
|
const backgroundButtons = document.createElement("div");
|
|
backgroundButtons.className = "b04-map__layer-buttons";
|
|
backgroundGroup.append(backgroundTitle, backgroundButtons);
|
|
|
|
const gisGroup = document.createElement("div");
|
|
gisGroup.className = "b04-map__control-group";
|
|
const gisTitle = document.createElement("span");
|
|
gisTitle.textContent = L("B04_Surface_Map_GisLayer");
|
|
const gisButtons = document.createElement("div");
|
|
gisButtons.className = "b04-map__layer-buttons";
|
|
gisGroup.append(gisTitle, gisButtons);
|
|
|
|
const resetButton = document.createElement("button");
|
|
resetButton.type = "button";
|
|
resetButton.textContent = L("B04_Surface_Map_Reset");
|
|
controls.append(backgroundGroup, gisGroup, resetButton);
|
|
header.append(title, controls);
|
|
|
|
const viewport = document.createElement("div");
|
|
viewport.className = "b04-map__viewport";
|
|
const backgroundImages = new Map<BackgroundLayer, HTMLImageElement>();
|
|
BACKGROUND_LAYERS.forEach((layer) => {
|
|
const image = document.createElement("img");
|
|
image.className = "b04-map__image";
|
|
image.alt = L("B04_Surface_Map_ImageAlt");
|
|
image.draggable = false;
|
|
backgroundImages.set(layer, image);
|
|
});
|
|
const canvas = document.createElement("canvas");
|
|
canvas.className = "b04-map__canvas";
|
|
const empty = document.createElement("p");
|
|
empty.className = "b04-map__empty";
|
|
empty.textContent = L("B04_Surface_Map_Empty");
|
|
const status = document.createElement("span");
|
|
status.className = "b04-map__status";
|
|
const scaleBar = document.createElement("div");
|
|
scaleBar.className = "b04-map__scale";
|
|
const scaleText = document.createElement("span");
|
|
scaleBar.append(scaleText);
|
|
viewport.append(
|
|
...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!),
|
|
canvas,
|
|
empty,
|
|
status,
|
|
scaleBar,
|
|
);
|
|
root.append(header, viewport);
|
|
|
|
let currentProjectId: string | null = null;
|
|
let referenceBounds: SurfaceBounds | null = null;
|
|
let meta: VWorldMeta | null = null;
|
|
const geoJsonLayers = new Map<GisLayer, GeoJsonCollection>();
|
|
const activeBackgrounds = new Set<BackgroundLayer>(BACKGROUND_LAYERS);
|
|
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS);
|
|
let showContourLabels = false;
|
|
let scale = 1;
|
|
let offsetX = 0;
|
|
let offsetY = 0;
|
|
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
|
let loadSequence = 0;
|
|
|
|
function makeLayerButton<T extends string>(
|
|
label: string,
|
|
activeLayers: Set<T>,
|
|
layer: T,
|
|
color?: string,
|
|
): HTMLButtonElement {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "b04-map__layer-button is-active";
|
|
button.textContent = label;
|
|
button.setAttribute("aria-pressed", "true");
|
|
if (color) {
|
|
button.classList.add("b04-map__layer-button--gis");
|
|
button.style.setProperty("--b04-layer-color", color);
|
|
}
|
|
button.addEventListener("click", () => {
|
|
if (activeLayers.has(layer)) activeLayers.delete(layer);
|
|
else activeLayers.add(layer);
|
|
const isActive = activeLayers.has(layer);
|
|
button.classList.toggle("is-active", isActive);
|
|
button.setAttribute("aria-pressed", String(isActive));
|
|
syncLayerVisibility();
|
|
});
|
|
return button;
|
|
}
|
|
|
|
const backgroundLabels: Record<BackgroundLayer, string> = {
|
|
white: L("B04_Surface_Map_White"),
|
|
satellite: L("B04_Surface_Map_Satellite"),
|
|
hybrid: L("B04_Surface_Map_Hybrid"),
|
|
};
|
|
BACKGROUND_LAYERS.forEach((layer) => {
|
|
backgroundButtons.append(makeLayerButton(backgroundLabels[layer], activeBackgrounds, layer));
|
|
});
|
|
|
|
const gisLabels: Record<GisLayer, string> = {
|
|
지적도: L("B04_Surface_Map_Cadastral"),
|
|
행정구역_시군구: L("B04_Surface_Map_Sigungu"),
|
|
행정구역_읍면동: L("B04_Surface_Map_Eupmyeondong"),
|
|
등고선: L("B04_Surface_Map_Contour"),
|
|
도엽_등고선: L("B04_Surface_Map_SheetContour"),
|
|
도엽_하천중심선: L("B04_Surface_Map_SheetStream"),
|
|
도엽_표고점: L("B04_Surface_Map_SheetElevPoint"),
|
|
도엽_성절토: L("B04_Surface_Map_SheetCutFill"),
|
|
도엽_옹벽석축: L("B04_Surface_Map_SheetWall"),
|
|
도엽_유수방향: L("B04_Surface_Map_SheetFlowDir"),
|
|
};
|
|
GIS_LAYERS.forEach((layer) => {
|
|
gisButtons.append(
|
|
makeLayerButton(gisLabels[layer], activeGisLayers, layer, GIS_LAYER_COLORS[layer]),
|
|
);
|
|
});
|
|
|
|
// 등고 라벨 보기/숨기기 (기본 숨김 — 등고선·도엽 등고선의 계곡선 수치 표기)
|
|
const contourLabelButton = document.createElement("button");
|
|
contourLabelButton.type = "button";
|
|
contourLabelButton.className = "b04-map__layer-button";
|
|
contourLabelButton.textContent = L("B04_Surface_Map_ContourLabel");
|
|
contourLabelButton.setAttribute("aria-pressed", "false");
|
|
contourLabelButton.addEventListener("click", () => {
|
|
showContourLabels = !showContourLabels;
|
|
contourLabelButton.classList.toggle("is-active", showContourLabels);
|
|
contourLabelButton.setAttribute("aria-pressed", String(showContourLabels));
|
|
drawVectorLayer();
|
|
});
|
|
gisButtons.append(contourLabelButton);
|
|
|
|
function updateImageTransform(): void {
|
|
backgroundImages.forEach((image) => {
|
|
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
|
});
|
|
}
|
|
|
|
function syncLayerVisibility(): void {
|
|
backgroundImages.forEach((image, layer) => {
|
|
image.hidden = !activeBackgrounds.has(layer);
|
|
});
|
|
empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0;
|
|
drawVectorLayer();
|
|
}
|
|
|
|
function fitReferenceBounds(): void {
|
|
if (!meta || !referenceBounds) return;
|
|
const rect = viewport.getBoundingClientRect();
|
|
const width = Math.max(rect.width, 1);
|
|
const height = Math.max(rect.height, 1);
|
|
const mapRect = getMapRect(width, height);
|
|
const referenceWidth = Math.max(referenceBounds.x_max - referenceBounds.x_min, 1);
|
|
const referenceHeight = Math.max(referenceBounds.y_max - referenceBounds.y_min, 1);
|
|
scale =
|
|
Math.min(meta.width_meters / referenceWidth, meta.height_meters / referenceHeight) * 0.9;
|
|
const centerX = (referenceBounds.x_min + referenceBounds.x_max) / 2;
|
|
const centerY = (referenceBounds.y_min + referenceBounds.y_max) / 2;
|
|
const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width;
|
|
const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height;
|
|
offsetX = -(baseX - width / 2) * scale;
|
|
offsetY = -(baseY - height / 2) * scale;
|
|
}
|
|
|
|
function resetView(): void {
|
|
scale = 1;
|
|
offsetX = 0;
|
|
offsetY = 0;
|
|
fitReferenceBounds();
|
|
updateImageTransform();
|
|
drawVectorLayer();
|
|
}
|
|
|
|
function getMapRect(width: number, height: number): DOMRect {
|
|
if (!meta) return new DOMRect(0, 0, width, height);
|
|
const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1);
|
|
const viewportRatio = width / Math.max(height, 1);
|
|
const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio;
|
|
const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height;
|
|
return new DOMRect((width - mapWidth) / 2, (height - mapHeight) / 2, mapWidth, mapHeight);
|
|
}
|
|
|
|
function toCanvasPoint(
|
|
lon: number,
|
|
lat: number,
|
|
width: number,
|
|
height: number,
|
|
): [number, number] {
|
|
if (!meta) return [0, 0];
|
|
const mapRect = getMapRect(width, height);
|
|
const lonRange = meta.lon_max - meta.lon_min || 1;
|
|
const latRange = meta.lat_max - meta.lat_min || 1;
|
|
const baseX = mapRect.x + ((lon - meta.lon_min) / lonRange) * mapRect.width;
|
|
const baseY = mapRect.y + mapRect.height * (1 - (lat - meta.lat_min) / latRange);
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
return [
|
|
centerX + (baseX - centerX) * scale + offsetX,
|
|
centerY + (baseY - centerY) * scale + offsetY,
|
|
];
|
|
}
|
|
|
|
function drawRing(
|
|
context: CanvasRenderingContext2D,
|
|
ring: unknown,
|
|
width: number,
|
|
height: number,
|
|
closed: boolean,
|
|
): void {
|
|
if (!Array.isArray(ring) || ring.length === 0) return;
|
|
const points = ring.filter(
|
|
(point): point is [number, number] =>
|
|
Array.isArray(point) && typeof point[0] === "number" && typeof point[1] === "number",
|
|
);
|
|
if (points.length === 0) return;
|
|
context.beginPath();
|
|
points.forEach(([lon, lat], index) => {
|
|
const [x, y] = toCanvasPoint(lon, lat, width, height);
|
|
if (index === 0) context.moveTo(x, y);
|
|
else context.lineTo(x, y);
|
|
});
|
|
if (closed) context.closePath();
|
|
context.stroke();
|
|
}
|
|
|
|
function drawPoint(
|
|
context: CanvasRenderingContext2D,
|
|
coordinates: unknown,
|
|
width: number,
|
|
height: number,
|
|
marker: "dot" | "x" = "dot",
|
|
): void {
|
|
if (
|
|
!Array.isArray(coordinates) ||
|
|
typeof coordinates[0] !== "number" ||
|
|
typeof coordinates[1] !== "number"
|
|
) {
|
|
return;
|
|
}
|
|
const [x, y] = toCanvasPoint(coordinates[0], coordinates[1], width, height);
|
|
if (marker === "x") {
|
|
// 표고점: 조금 굵고 큰 X 마커
|
|
const arm = 4;
|
|
const prevWidth = context.lineWidth;
|
|
context.lineWidth = 2;
|
|
context.beginPath();
|
|
context.moveTo(x - arm, y - arm);
|
|
context.lineTo(x + arm, y + arm);
|
|
context.moveTo(x - arm, y + arm);
|
|
context.lineTo(x + arm, y - arm);
|
|
context.stroke();
|
|
context.lineWidth = prevWidth;
|
|
return;
|
|
}
|
|
context.beginPath();
|
|
context.arc(x, y, 2, 0, Math.PI * 2);
|
|
context.fillStyle = context.strokeStyle;
|
|
context.fill();
|
|
}
|
|
|
|
function contourLabelAnchor(geometry: GeoJsonGeometry): [number, number] | null {
|
|
const coords = geometry.coordinates;
|
|
if (!Array.isArray(coords)) return null;
|
|
const line =
|
|
geometry.type === "LineString"
|
|
? coords
|
|
: geometry.type === "MultiLineString"
|
|
? coords[0]
|
|
: null;
|
|
if (!Array.isArray(line) || line.length === 0) return null;
|
|
const mid = line[Math.floor(line.length / 2)];
|
|
if (!Array.isArray(mid) || typeof mid[0] !== "number" || typeof mid[1] !== "number") {
|
|
return null;
|
|
}
|
|
return [mid[0], mid[1]];
|
|
}
|
|
|
|
function drawContourLabels(
|
|
context: CanvasRenderingContext2D,
|
|
width: number,
|
|
height: number,
|
|
): void {
|
|
context.font = "10px sans-serif";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
(Object.keys(CONTOUR_LABEL_KEYS) as GisLayer[]).forEach((layer) => {
|
|
if (!activeGisLayers.has(layer)) return;
|
|
const keys = CONTOUR_LABEL_KEYS[layer] ?? [];
|
|
geoJsonLayers.get(layer)?.features?.forEach((feature) => {
|
|
if (!feature.geometry) return;
|
|
const raw = keys.map((key) => feature.properties?.[key]).find((value) => value != null);
|
|
const elevation = typeof raw === "number" ? raw : Number(raw);
|
|
// 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지
|
|
if (!Number.isFinite(elevation) || elevation % 25 !== 0) return;
|
|
const anchor = contourLabelAnchor(feature.geometry);
|
|
if (!anchor) return;
|
|
const [x, y] = toCanvasPoint(anchor[0], anchor[1], width, height);
|
|
context.lineWidth = 3;
|
|
context.strokeStyle = "rgba(255, 255, 255, 0.9)";
|
|
context.strokeText(String(elevation), x, y);
|
|
context.fillStyle = GIS_LAYER_COLORS[layer];
|
|
context.fillText(String(elevation), x, y);
|
|
});
|
|
});
|
|
}
|
|
|
|
function drawGeometry(
|
|
context: CanvasRenderingContext2D,
|
|
geometry: GeoJsonGeometry,
|
|
width: number,
|
|
height: number,
|
|
marker: "dot" | "x" = "dot",
|
|
): void {
|
|
const coordinates = geometry.coordinates;
|
|
if (!Array.isArray(coordinates)) return;
|
|
if (geometry.type === "Point") {
|
|
drawPoint(context, coordinates, width, height, marker);
|
|
} else if (geometry.type === "MultiPoint") {
|
|
coordinates.forEach((point) => drawPoint(context, point, width, height, marker));
|
|
} else if (geometry.type === "LineString") {
|
|
drawRing(context, coordinates, width, height, false);
|
|
} else if (geometry.type === "MultiLineString") {
|
|
coordinates.forEach((line) => drawRing(context, line, width, height, false));
|
|
} else if (geometry.type === "Polygon") {
|
|
coordinates.forEach((ring) => drawRing(context, ring, width, height, true));
|
|
} else if (geometry.type === "MultiPolygon") {
|
|
coordinates.forEach((polygon) => {
|
|
if (Array.isArray(polygon)) {
|
|
polygon.forEach((ring) => drawRing(context, ring, width, height, true));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function drawScaleBar(width: number, height: number): void {
|
|
if (!meta || width <= 0) {
|
|
scaleBar.hidden = true;
|
|
return;
|
|
}
|
|
const metersPerPixel = meta.width_meters / getMapRect(width, height).width / scale;
|
|
const meters = niceScaleDistance(100 * metersPerPixel);
|
|
const pixels = meters / metersPerPixel;
|
|
scaleBar.hidden = false;
|
|
scaleBar.style.width = `${pixels}px`;
|
|
scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
|
|
}
|
|
|
|
function drawVectorLayer(): void {
|
|
const rect = viewport.getBoundingClientRect();
|
|
const width = Math.max(1, Math.floor(rect.width));
|
|
const height = Math.max(1, Math.floor(rect.height));
|
|
const dpr = window.devicePixelRatio || 1;
|
|
canvas.width = Math.floor(width * dpr);
|
|
canvas.height = Math.floor(height * dpr);
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
const context = canvas.getContext("2d");
|
|
if (!context) return;
|
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
context.clearRect(0, 0, width, height);
|
|
// 등고선(전국 gpkg·도엽)은 선 수가 많아 가장 아래에 얇게 깔아 다른 레이어 판독을 방해하지 않게 한다.
|
|
const isContour = (layer: GisLayer): boolean => layer === "등고선" || layer === "도엽_등고선";
|
|
const drawOrder = [...GIS_LAYERS].sort((a, b) => (isContour(a) ? -1 : isContour(b) ? 1 : 0));
|
|
drawOrder.forEach((layer) => {
|
|
if (!activeGisLayers.has(layer)) return;
|
|
context.lineWidth = isContour(layer) ? 0.7 : 1.5;
|
|
context.strokeStyle = GIS_LAYER_COLORS[layer];
|
|
const marker = layer === "도엽_표고점" ? "x" : "dot";
|
|
geoJsonLayers.get(layer)?.features?.forEach((feature) => {
|
|
if (feature.geometry) drawGeometry(context, feature.geometry, width, height, marker);
|
|
});
|
|
});
|
|
if (showContourLabels) drawContourLabels(context, width, height);
|
|
updateImageTransform();
|
|
drawScaleBar(width, height);
|
|
}
|
|
|
|
async function loadLayers(): Promise<void> {
|
|
if (!currentProjectId) return;
|
|
const projectId = currentProjectId;
|
|
const sequence = ++loadSequence;
|
|
backgroundImages.forEach((image) => image.removeAttribute("src"));
|
|
meta = null;
|
|
geoJsonLayers.clear();
|
|
resetView();
|
|
status.textContent = L("B04_Surface_Map_Loading");
|
|
try {
|
|
const nextMeta = await fetchVWorldMeta(projectId, "satellite");
|
|
const loadedLayers = await Promise.all(
|
|
GIS_LAYERS.map(async (layer) => {
|
|
try {
|
|
const data = (await fetchGisGeoJson(projectId, layer)) as GeoJsonCollection;
|
|
return [layer, data] as const;
|
|
} catch {
|
|
return [layer, null] as const;
|
|
}
|
|
}),
|
|
);
|
|
if (sequence !== loadSequence) return;
|
|
meta = nextMeta;
|
|
loadedLayers.forEach(([layer, data]) => {
|
|
if (data) geoJsonLayers.set(layer, data);
|
|
});
|
|
BACKGROUND_LAYERS.forEach((layer) => {
|
|
backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`;
|
|
});
|
|
const featureCount = [...geoJsonLayers.values()].reduce(
|
|
(sum, collection) => sum + (collection.features?.length ?? 0),
|
|
0,
|
|
);
|
|
status.textContent = L("B04_Surface_Map_Features").replace(
|
|
"{count}",
|
|
featureCount.toLocaleString(),
|
|
);
|
|
resetView();
|
|
syncLayerVisibility();
|
|
} catch (error) {
|
|
if (sequence !== loadSequence) return;
|
|
status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed");
|
|
}
|
|
}
|
|
|
|
resetButton.addEventListener("click", resetView);
|
|
viewport.addEventListener(
|
|
"wheel",
|
|
(event) => {
|
|
event.preventDefault();
|
|
scale = Math.min(8, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87)));
|
|
drawVectorLayer();
|
|
},
|
|
{ passive: false },
|
|
);
|
|
viewport.addEventListener("pointerdown", (event) => {
|
|
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
|
|
viewport.setPointerCapture(event.pointerId);
|
|
});
|
|
viewport.addEventListener("pointermove", (event) => {
|
|
if (!dragStart) return;
|
|
offsetX = dragStart.offsetX + event.clientX - dragStart.x;
|
|
offsetY = dragStart.offsetY + event.clientY - dragStart.y;
|
|
drawVectorLayer();
|
|
});
|
|
const stopDragging = (): void => {
|
|
dragStart = null;
|
|
};
|
|
viewport.addEventListener("pointerup", stopDragging);
|
|
viewport.addEventListener("pointercancel", stopDragging);
|
|
|
|
const resizeObserver = new ResizeObserver(drawVectorLayer);
|
|
resizeObserver.observe(viewport);
|
|
|
|
return {
|
|
root,
|
|
render(projectId, nextReferenceBounds) {
|
|
currentProjectId = projectId;
|
|
referenceBounds = nextReferenceBounds ?? null;
|
|
void loadLayers();
|
|
},
|
|
dispose() {
|
|
loadSequence += 1;
|
|
resizeObserver.disconnect();
|
|
},
|
|
};
|
|
}
|