|
|
|
|
@@ -1,6 +1,9 @@
|
|
|
|
|
import * as THREE from "three";
|
|
|
|
|
import { PMTiles } from "pmtiles";
|
|
|
|
|
import { VectorTile } from "@mapbox/vector-tile";
|
|
|
|
|
import Pbf from "pbf";
|
|
|
|
|
import { CONFIG, COUNTRY_BOUNDARY_CONFIG } from "./constants.js";
|
|
|
|
|
import { latLonToVector3 } from "./utils.js";
|
|
|
|
|
import { latLonToVector3, screenToEarthCoords, vector3ToLatLon } from "./utils.js";
|
|
|
|
|
|
|
|
|
|
// ─── Module state ──────────────────────────────────────────────────────────────
|
|
|
|
|
let _earthObj = null;
|
|
|
|
|
@@ -8,21 +11,48 @@ let _features = [];
|
|
|
|
|
let _landMesh = null;
|
|
|
|
|
let _tintMesh = null;
|
|
|
|
|
let _boundaryLines = null;
|
|
|
|
|
let _coastlineLines = null;
|
|
|
|
|
let _claimGroup = null;
|
|
|
|
|
let _hoverGlowLines = null;
|
|
|
|
|
let _hoverLines = null;
|
|
|
|
|
let _hoveredFeature = null;
|
|
|
|
|
let _hoveredGroupKey = null;
|
|
|
|
|
let _hoverClearTimer = null;
|
|
|
|
|
let _lastHoverHitAt = 0;
|
|
|
|
|
let _lastHoverInfo = null;
|
|
|
|
|
let _hoverGeometryCache = new Map();
|
|
|
|
|
let _tileManifest = null;
|
|
|
|
|
let _tileProvider = "pmtiles-mvt";
|
|
|
|
|
let _pmtilesArchive = null;
|
|
|
|
|
let _tileCache = new Map();
|
|
|
|
|
let _tileLru = [];
|
|
|
|
|
let _inFlightTiles = new Map();
|
|
|
|
|
let _activeTileKeys = new Set();
|
|
|
|
|
let _lastTileSignature = "";
|
|
|
|
|
let _tileUpdateTimer = null;
|
|
|
|
|
let _visible = false;
|
|
|
|
|
let _landFillEnabled = true;
|
|
|
|
|
let _landFillSuppressed = false;
|
|
|
|
|
let _tintEnabled = false;
|
|
|
|
|
let _landTexture = null;
|
|
|
|
|
let _loaded = false;
|
|
|
|
|
let _loadPromise = null;
|
|
|
|
|
let _tileAssetVersion = "";
|
|
|
|
|
|
|
|
|
|
const OCEAN_HEX = 0x010609;
|
|
|
|
|
// ─── Equirectangular land/ocean fill texture ──────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function configureLandMaskTexture(texture) {
|
|
|
|
|
texture.wrapS = THREE.ClampToEdgeWrapping;
|
|
|
|
|
texture.wrapT = THREE.ClampToEdgeWrapping;
|
|
|
|
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
|
|
|
texture.magFilter = THREE.LinearFilter;
|
|
|
|
|
texture.generateMipmaps = true;
|
|
|
|
|
texture.anisotropy = 1;
|
|
|
|
|
texture.needsUpdate = true;
|
|
|
|
|
return texture;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hexToStyle(hex) {
|
|
|
|
|
return `#${hex.toString(16).padStart(6, "0")}`;
|
|
|
|
|
}
|
|
|
|
|
@@ -35,32 +65,14 @@ function hexToRgb(hex) {
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildLandTexture(features) {
|
|
|
|
|
const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth;
|
|
|
|
|
const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight;
|
|
|
|
|
|
|
|
|
|
function drawLandMaskCanvas(features, width, height) {
|
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
|
|
canvas.width = width;
|
|
|
|
|
canvas.height = height;
|
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
|
const oceanRgb = hexToRgb(OCEAN_HEX);
|
|
|
|
|
|
|
|
|
|
if (!ctx) {
|
|
|
|
|
const oceanData = new Uint8Array(width * height * 4);
|
|
|
|
|
for (let i = 0; i < oceanData.length; i += 4) {
|
|
|
|
|
oceanData[i] = oceanRgb[0];
|
|
|
|
|
oceanData[i + 1] = oceanRgb[1];
|
|
|
|
|
oceanData[i + 2] = oceanRgb[2];
|
|
|
|
|
oceanData[i + 3] = 255;
|
|
|
|
|
}
|
|
|
|
|
const fallbackTexture = new THREE.DataTexture(
|
|
|
|
|
oceanData,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
THREE.RGBAFormat,
|
|
|
|
|
);
|
|
|
|
|
fallbackTexture.needsUpdate = true;
|
|
|
|
|
return fallbackTexture;
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ocean background
|
|
|
|
|
@@ -93,21 +105,43 @@ function buildLandTexture(features) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const imageData = ctx.getImageData(0, 0, width, height);
|
|
|
|
|
const tex = new THREE.DataTexture(
|
|
|
|
|
new Uint8Array(imageData.data),
|
|
|
|
|
return canvas;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildTextureFromCanvas(canvas) {
|
|
|
|
|
const tex = new THREE.CanvasTexture(canvas);
|
|
|
|
|
configureLandMaskTexture(tex);
|
|
|
|
|
tex.flipY = true;
|
|
|
|
|
tex.userData = {
|
|
|
|
|
...(tex.userData || {}),
|
|
|
|
|
sourceCanvas: canvas,
|
|
|
|
|
};
|
|
|
|
|
return tex;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildFallbackLandTexture(width, height) {
|
|
|
|
|
const oceanRgb = hexToRgb(OCEAN_HEX);
|
|
|
|
|
const oceanData = new Uint8Array(width * height * 4);
|
|
|
|
|
for (let i = 0; i < oceanData.length; i += 4) {
|
|
|
|
|
oceanData[i] = oceanRgb[0];
|
|
|
|
|
oceanData[i + 1] = oceanRgb[1];
|
|
|
|
|
oceanData[i + 2] = oceanRgb[2];
|
|
|
|
|
oceanData[i + 3] = 255;
|
|
|
|
|
}
|
|
|
|
|
const fallbackTexture = new THREE.DataTexture(
|
|
|
|
|
oceanData,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
THREE.RGBAFormat,
|
|
|
|
|
);
|
|
|
|
|
tex.wrapS = THREE.ClampToEdgeWrapping;
|
|
|
|
|
tex.wrapT = THREE.ClampToEdgeWrapping;
|
|
|
|
|
tex.minFilter = THREE.LinearFilter;
|
|
|
|
|
tex.magFilter = THREE.LinearFilter;
|
|
|
|
|
tex.generateMipmaps = false;
|
|
|
|
|
tex.flipY = true;
|
|
|
|
|
tex.needsUpdate = true;
|
|
|
|
|
return tex;
|
|
|
|
|
return configureLandMaskTexture(fallbackTexture);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildLandTexture(features) {
|
|
|
|
|
const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth;
|
|
|
|
|
const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight;
|
|
|
|
|
const canvas = drawLandMaskCanvas(features, width, height);
|
|
|
|
|
return canvas ? buildTextureFromCanvas(canvas) : buildFallbackLandTexture(width, height);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Sphere mesh helpers ───────────────────────────────────────────────────────
|
|
|
|
|
@@ -145,6 +179,14 @@ function makeTintMesh() {
|
|
|
|
|
|
|
|
|
|
// ─── Boundary line geometry ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function boundaryLineRadius({ claim = false } = {}) {
|
|
|
|
|
return (
|
|
|
|
|
CONFIG.earthRadius +
|
|
|
|
|
COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset +
|
|
|
|
|
(claim ? 0.018 : 0)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ringToSegments(ring, radius, out) {
|
|
|
|
|
const n = ring.length;
|
|
|
|
|
if (n < 2) return;
|
|
|
|
|
@@ -161,12 +203,16 @@ function featureToSegments(geom, radius) {
|
|
|
|
|
geom.coordinates.forEach(ring => ringToSegments(ring, radius, pts));
|
|
|
|
|
} else if (geom.type === "MultiPolygon") {
|
|
|
|
|
geom.coordinates.forEach(poly => poly.forEach(ring => ringToSegments(ring, radius, pts)));
|
|
|
|
|
} else if (geom.type === "LineString") {
|
|
|
|
|
ringToSegments(geom.coordinates, radius, pts);
|
|
|
|
|
} else if (geom.type === "MultiLineString") {
|
|
|
|
|
geom.coordinates.forEach(line => ringToSegments(line, radius, pts));
|
|
|
|
|
}
|
|
|
|
|
return pts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildBoundaryLines(features) {
|
|
|
|
|
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset;
|
|
|
|
|
const r = boundaryLineRadius();
|
|
|
|
|
const mat = new THREE.LineBasicMaterial({
|
|
|
|
|
color: COUNTRY_BOUNDARY_CONFIG.lineColor,
|
|
|
|
|
transparent: true,
|
|
|
|
|
@@ -178,7 +224,7 @@ function buildBoundaryLines(features) {
|
|
|
|
|
const all = [];
|
|
|
|
|
for (const feat of features) {
|
|
|
|
|
const pts = featureToSegments(feat.geometry, r);
|
|
|
|
|
all.push(...pts);
|
|
|
|
|
for (const point of pts) all.push(point);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const geo = all.length > 0
|
|
|
|
|
@@ -192,6 +238,350 @@ function buildBoundaryLines(features) {
|
|
|
|
|
return lines;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isStandaloneCoastlineFeature(feature) {
|
|
|
|
|
const properties = feature?.properties || {};
|
|
|
|
|
return (
|
|
|
|
|
properties.PLANET_LAYER === "coastline" ||
|
|
|
|
|
properties.featurecla === "Coastline"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildClaimLines() {
|
|
|
|
|
const mat = new THREE.LineDashedMaterial({
|
|
|
|
|
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: 0.82,
|
|
|
|
|
dashSize: 0.7,
|
|
|
|
|
gapSize: 0.42,
|
|
|
|
|
depthTest: true,
|
|
|
|
|
depthWrite: false,
|
|
|
|
|
});
|
|
|
|
|
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
|
|
|
|
|
lines.name = "country-boundary-china-claims";
|
|
|
|
|
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.lineRenderOrder + 0.02;
|
|
|
|
|
lines.visible = false;
|
|
|
|
|
lines.raycast = () => {};
|
|
|
|
|
return lines;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeBoundaryTileObject(features, { claim = false } = {}) {
|
|
|
|
|
const radius = boundaryLineRadius({ claim });
|
|
|
|
|
const points = featureListToSegments(features, radius);
|
|
|
|
|
const geometry = makeLineGeometry(points);
|
|
|
|
|
const material = claim
|
|
|
|
|
? _claimGroup?.material?.clone?.() || buildClaimLines().material
|
|
|
|
|
: _boundaryLines?.material?.clone?.() || new THREE.LineBasicMaterial({
|
|
|
|
|
color: COUNTRY_BOUNDARY_CONFIG.lineColor,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: COUNTRY_BOUNDARY_CONFIG.lineOpacity,
|
|
|
|
|
depthTest: true,
|
|
|
|
|
depthWrite: false,
|
|
|
|
|
});
|
|
|
|
|
const lines = new THREE.LineSegments(geometry, material);
|
|
|
|
|
lines.name = claim ? "country-boundary-claim-tile" : "country-boundary-tile";
|
|
|
|
|
lines.renderOrder = claim
|
|
|
|
|
? COUNTRY_BOUNDARY_CONFIG.lineRenderOrder + 0.02
|
|
|
|
|
: COUNTRY_BOUNDARY_CONFIG.lineRenderOrder + 0.01;
|
|
|
|
|
lines.visible = _visible;
|
|
|
|
|
lines.raycast = () => {};
|
|
|
|
|
if (claim && typeof lines.computeLineDistances === "function") {
|
|
|
|
|
lines.computeLineDistances();
|
|
|
|
|
}
|
|
|
|
|
return lines;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function disposeTileObject(object) {
|
|
|
|
|
if (!object) return;
|
|
|
|
|
if (_earthObj) _earthObj.remove(object);
|
|
|
|
|
object.geometry?.dispose?.();
|
|
|
|
|
object.material?.dispose?.();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function touchTileKey(key) {
|
|
|
|
|
_tileLru = _tileLru.filter(item => item !== key);
|
|
|
|
|
_tileLru.push(key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trimTileCache() {
|
|
|
|
|
const limit = COUNTRY_BOUNDARY_CONFIG.tileCacheLimit;
|
|
|
|
|
while (_tileLru.length > limit) {
|
|
|
|
|
const key = _tileLru.shift();
|
|
|
|
|
if (!key || _activeTileKeys.has(key)) continue;
|
|
|
|
|
const cached = _tileCache.get(key);
|
|
|
|
|
_tileCache.delete(key);
|
|
|
|
|
disposeTileObject(cached?.object);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setTileObjectVisible(object, visible) {
|
|
|
|
|
if (object) object.visible = _visible && visible;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setActiveTileKeys(nextKeys) {
|
|
|
|
|
_activeTileKeys = new Set(nextKeys);
|
|
|
|
|
_tileCache.forEach((entry, key) => {
|
|
|
|
|
setTileObjectVisible(entry.object, _activeTileKeys.has(key));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tileUrlForKey(key) {
|
|
|
|
|
const [kind, z, x, y] = key.split("/");
|
|
|
|
|
const prefix = COUNTRY_BOUNDARY_CONFIG.tileBasePath.replace(/\/?$/, "/");
|
|
|
|
|
const versionSuffix = _tileAssetVersion ? `?v=${encodeURIComponent(_tileAssetVersion)}` : "";
|
|
|
|
|
if (kind === "claim") {
|
|
|
|
|
return `${prefix}china-claims/${z}/${x}/${y}.geojson${versionSuffix}`;
|
|
|
|
|
}
|
|
|
|
|
return `${prefix}${z}/${x}/${y}.geojson${versionSuffix}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function versionedBoundaryAssetUrl(path) {
|
|
|
|
|
const url = new URL(path, COUNTRY_BOUNDARY_CONFIG.tileBasePath);
|
|
|
|
|
if (_tileAssetVersion) url.searchParams.set("v", _tileAssetVersion);
|
|
|
|
|
return url.href;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function manifestProvider(manifest) {
|
|
|
|
|
const configured = COUNTRY_BOUNDARY_CONFIG.tileProvider;
|
|
|
|
|
if (configured && configured !== "auto") return configured;
|
|
|
|
|
return manifest?.tileProvider || manifest?.format || "pmtiles-mvt";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function manifestPmtilesUrl(manifest) {
|
|
|
|
|
const fromManifest =
|
|
|
|
|
manifest?.pmtiles?.url ||
|
|
|
|
|
manifest?.pmtiles?.path ||
|
|
|
|
|
manifest?.artifacts?.pmtiles?.url ||
|
|
|
|
|
manifest?.artifacts?.pmtiles?.path ||
|
|
|
|
|
manifest?.artifact;
|
|
|
|
|
if (!fromManifest) return COUNTRY_BOUNDARY_CONFIG.pmtilesPath;
|
|
|
|
|
return new URL(fromManifest, COUNTRY_BOUNDARY_CONFIG.tileBasePath).href;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensurePmtilesArchive() {
|
|
|
|
|
if (_pmtilesArchive) return _pmtilesArchive;
|
|
|
|
|
const url = manifestPmtilesUrl(_tileManifest);
|
|
|
|
|
_pmtilesArchive = new PMTiles(url);
|
|
|
|
|
return _pmtilesArchive;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getMvtLayerNames(kind) {
|
|
|
|
|
const layerConfig = COUNTRY_BOUNDARY_CONFIG.mvtLayerNames || {};
|
|
|
|
|
if (kind === "claim") return layerConfig.claim || ["claim_line"];
|
|
|
|
|
return layerConfig.boundary || ["boundary_admin0", "boundary_disputed_internal", "coastline"];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadPmtilesMvtFeatures(key) {
|
|
|
|
|
const [kind, zText, xText, yText] = key.split("/");
|
|
|
|
|
const z = Number(zText);
|
|
|
|
|
const x = Number(xText);
|
|
|
|
|
const y = Number(yText);
|
|
|
|
|
if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) return [];
|
|
|
|
|
|
|
|
|
|
const archive = ensurePmtilesArchive();
|
|
|
|
|
const tile = await archive.getZxy(z, x, y);
|
|
|
|
|
if (!tile?.data) return [];
|
|
|
|
|
|
|
|
|
|
const vectorTile = new VectorTile(new Pbf(new Uint8Array(tile.data)));
|
|
|
|
|
const features = [];
|
|
|
|
|
for (const layerName of getMvtLayerNames(kind)) {
|
|
|
|
|
const layer = vectorTile.layers[layerName];
|
|
|
|
|
if (!layer) continue;
|
|
|
|
|
for (let i = 0; i < layer.length; i++) {
|
|
|
|
|
const feature = layer.feature(i).toGeoJSON(x, y, z);
|
|
|
|
|
if (feature?.geometry) features.push(feature);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return features;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadDebugGeojsonFeatures(key) {
|
|
|
|
|
const resp = await fetch(tileUrlForKey(key));
|
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
if (resp.status === 404) return [];
|
|
|
|
|
throw new Error(`boundary tile ${key} HTTP ${resp.status}`);
|
|
|
|
|
}
|
|
|
|
|
const payload = await resp.json();
|
|
|
|
|
return (payload.features || []).filter(f => f.geometry);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadBoundaryTile(key) {
|
|
|
|
|
if (_tileCache.has(key)) {
|
|
|
|
|
touchTileKey(key);
|
|
|
|
|
return _tileCache.get(key);
|
|
|
|
|
}
|
|
|
|
|
if (_inFlightTiles.has(key)) return _inFlightTiles.get(key);
|
|
|
|
|
|
|
|
|
|
const promise = (async () => {
|
|
|
|
|
if (_tileProvider !== "pmtiles-mvt") {
|
|
|
|
|
throw new Error(`不支持的国界瓦片 provider: ${_tileProvider}`);
|
|
|
|
|
}
|
|
|
|
|
const features = await loadPmtilesMvtFeatures(key);
|
|
|
|
|
if (features.length === 0) return null;
|
|
|
|
|
const entry = {
|
|
|
|
|
object: makeBoundaryTileObject(features, { claim: key.startsWith("claim/") }),
|
|
|
|
|
};
|
|
|
|
|
_earthObj.add(entry.object);
|
|
|
|
|
_tileCache.set(key, entry);
|
|
|
|
|
touchTileKey(key);
|
|
|
|
|
trimTileCache();
|
|
|
|
|
return entry;
|
|
|
|
|
})().catch(err => {
|
|
|
|
|
console.warn("[country-boundaries] tile load failed", key, err);
|
|
|
|
|
return null;
|
|
|
|
|
}).finally(() => {
|
|
|
|
|
_inFlightTiles.delete(key);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_inFlightTiles.set(key, promise);
|
|
|
|
|
return promise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function lonToTileX(lon, zoom) {
|
|
|
|
|
const n = 2 ** zoom;
|
|
|
|
|
return Math.max(0, Math.min(n - 1, Math.floor(((lon + 180) / 360) * n)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function latToTileY(lat, zoom) {
|
|
|
|
|
const n = 2 ** zoom;
|
|
|
|
|
const clamped = Math.max(-85.05112878, Math.min(85.05112878, lat));
|
|
|
|
|
const rad = clamped * Math.PI / 180;
|
|
|
|
|
return Math.max(
|
|
|
|
|
0,
|
|
|
|
|
Math.min(n - 1, Math.floor((1 - Math.asinh(Math.tan(rad)) / Math.PI) / 2 * n)),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tileZoomForViewZoom(viewZoom) {
|
|
|
|
|
const thresholds = COUNTRY_BOUNDARY_CONFIG.tileZoomThresholds || [];
|
|
|
|
|
const maxZoom = _tileManifest?.tiles?.maxZoom ?? 0;
|
|
|
|
|
for (const threshold of thresholds) {
|
|
|
|
|
if (viewZoom >= threshold.minViewZoom) {
|
|
|
|
|
return Math.min(threshold.tileZoom, maxZoom);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bboxFromVisibleEarth(camera, renderer, earth) {
|
|
|
|
|
if (!camera || !renderer?.domElement || !earth) return null;
|
|
|
|
|
const rect = renderer.domElement.getBoundingClientRect();
|
|
|
|
|
if (rect.width <= 0 || rect.height <= 0) return null;
|
|
|
|
|
|
|
|
|
|
const samples = [
|
|
|
|
|
[rect.left + rect.width * 0.5, rect.top + rect.height * 0.5],
|
|
|
|
|
[rect.left, rect.top],
|
|
|
|
|
[rect.right, rect.top],
|
|
|
|
|
[rect.left, rect.bottom],
|
|
|
|
|
[rect.right, rect.bottom],
|
|
|
|
|
[rect.left + rect.width * 0.5, rect.top],
|
|
|
|
|
[rect.left + rect.width * 0.5, rect.bottom],
|
|
|
|
|
[rect.left, rect.top + rect.height * 0.5],
|
|
|
|
|
[rect.right, rect.top + rect.height * 0.5],
|
|
|
|
|
];
|
|
|
|
|
const coords = [];
|
|
|
|
|
for (const [x, y] of samples) {
|
|
|
|
|
const point = screenToEarthCoords(x, y, camera, earth, renderer.domElement);
|
|
|
|
|
if (!point) continue;
|
|
|
|
|
coords.push(vector3ToLatLon(point));
|
|
|
|
|
}
|
|
|
|
|
if (coords.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
const lats = coords.map(coord => coord.lat);
|
|
|
|
|
const lons = coords.map(coord => coord.lon);
|
|
|
|
|
const latMin = Math.max(-85.05112878, Math.min(...lats));
|
|
|
|
|
const latMax = Math.min(85.05112878, Math.max(...lats));
|
|
|
|
|
const latPad = Math.max(2, (latMax - latMin) * 0.18);
|
|
|
|
|
const rawLonMin = Math.max(-180, Math.min(...lons));
|
|
|
|
|
const rawLonMax = Math.min(180, Math.max(...lons));
|
|
|
|
|
const rawLonSpan = rawLonMax - rawLonMin;
|
|
|
|
|
|
|
|
|
|
if (rawLonSpan > 180) {
|
|
|
|
|
const shifted = lons.map(lon => lon < 0 ? lon + 360 : lon);
|
|
|
|
|
const shiftedMin = Math.min(...shifted);
|
|
|
|
|
const shiftedMax = Math.max(...shifted);
|
|
|
|
|
const shiftedPad = Math.max(2, (shiftedMax - shiftedMin) * 0.18);
|
|
|
|
|
const west = shiftedMin - shiftedPad;
|
|
|
|
|
const east = shiftedMax + shiftedPad;
|
|
|
|
|
const ranges = [];
|
|
|
|
|
if (west < 180) ranges.push({ west: Math.max(-180, west), east: 180 });
|
|
|
|
|
if (east > 180) ranges.push({ west: -180, east: Math.min(180, east - 360) });
|
|
|
|
|
return {
|
|
|
|
|
south: Math.max(-85.05112878, latMin - latPad),
|
|
|
|
|
north: Math.min(85.05112878, latMax + latPad),
|
|
|
|
|
ranges: ranges.length > 0 ? ranges : [{ west: -180, east: 180 }],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lonPad = Math.max(2, rawLonSpan * 0.18);
|
|
|
|
|
return {
|
|
|
|
|
west: Math.max(-180, rawLonMin - lonPad),
|
|
|
|
|
south: Math.max(-85.05112878, latMin - latPad),
|
|
|
|
|
east: Math.min(180, rawLonMax + lonPad),
|
|
|
|
|
north: Math.min(85.05112878, latMax + latPad),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tileKeysForBbox(bbox, zoom, { claim = false } = {}) {
|
|
|
|
|
if (Array.isArray(bbox.ranges)) {
|
|
|
|
|
return bbox.ranges.flatMap(range =>
|
|
|
|
|
tileKeysForBbox({ ...bbox, west: range.west, east: range.east, ranges: null }, zoom, { claim }),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const prefetch = COUNTRY_BOUNDARY_CONFIG.tilePrefetchRing;
|
|
|
|
|
const n = 2 ** zoom;
|
|
|
|
|
const xMin = lonToTileX(bbox.west, zoom);
|
|
|
|
|
const xMax = lonToTileX(bbox.east, zoom);
|
|
|
|
|
const yMin = latToTileY(bbox.north, zoom);
|
|
|
|
|
const yMax = latToTileY(bbox.south, zoom);
|
|
|
|
|
const keys = [];
|
|
|
|
|
for (let x = Math.max(0, xMin - prefetch); x <= Math.min(n - 1, xMax + prefetch); x++) {
|
|
|
|
|
for (let y = Math.max(0, yMin - prefetch); y <= Math.min(n - 1, yMax + prefetch); y++) {
|
|
|
|
|
keys.push(`${claim ? "claim" : "boundary"}/${zoom}/${x}/${y}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return keys;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refreshBoundaryTiles({ camera, renderer, earth, viewZoom }) {
|
|
|
|
|
if (!_loaded || !_visible || !_tileManifest) return;
|
|
|
|
|
if (_tileProvider === "geojson-high-precision") return;
|
|
|
|
|
const tileZoom = tileZoomForViewZoom(viewZoom);
|
|
|
|
|
if (!tileZoom) {
|
|
|
|
|
_lastTileSignature = "";
|
|
|
|
|
setActiveTileKeys([]);
|
|
|
|
|
updateBoundaryLineDimState();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bbox = bboxFromVisibleEarth(camera, renderer, earth);
|
|
|
|
|
if (!bbox) return;
|
|
|
|
|
const keys = tileKeysForBbox(bbox, tileZoom);
|
|
|
|
|
if (_tileManifest?.chinaClaims?.available) {
|
|
|
|
|
keys.push(...tileKeysForBbox(bbox, tileZoom, { claim: true }));
|
|
|
|
|
}
|
|
|
|
|
const signature = keys.slice().sort().join("|");
|
|
|
|
|
if (signature === _lastTileSignature) return;
|
|
|
|
|
_lastTileSignature = signature;
|
|
|
|
|
setActiveTileKeys(keys);
|
|
|
|
|
updateBoundaryLineDimState();
|
|
|
|
|
|
|
|
|
|
await Promise.all(keys.map(async key => {
|
|
|
|
|
const entry = await loadBoundaryTile(key);
|
|
|
|
|
if (!entry) return;
|
|
|
|
|
entry.object.userData.tileKey = key;
|
|
|
|
|
setTileObjectVisible(entry.object, _activeTileKeys.has(key));
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function updateCountryBoundaryTiles(context = {}) {
|
|
|
|
|
if (_tileUpdateTimer) return;
|
|
|
|
|
_tileUpdateTimer = setTimeout(() => {
|
|
|
|
|
_tileUpdateTimer = null;
|
|
|
|
|
refreshBoundaryTiles(context);
|
|
|
|
|
}, COUNTRY_BOUNDARY_CONFIG.tileDebounceMs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildHoverLines() {
|
|
|
|
|
const mat = new THREE.LineBasicMaterial({
|
|
|
|
|
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
|
|
|
|
|
@@ -236,6 +626,15 @@ function setBoundaryLinesDimmed(dimmed) {
|
|
|
|
|
_boundaryLines.material.needsUpdate = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateBoundaryLineDimState() {
|
|
|
|
|
if (_coastlineLines?.material) {
|
|
|
|
|
_coastlineLines.material.opacity = COUNTRY_BOUNDARY_CONFIG.lineOpacity;
|
|
|
|
|
_coastlineLines.material.needsUpdate = true;
|
|
|
|
|
_coastlineLines.visible = _visible && !_hoveredFeature;
|
|
|
|
|
}
|
|
|
|
|
setBoundaryLinesDimmed(Boolean(_hoveredFeature) || _activeTileKeys.size > 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setHoverLinesVisible(visible) {
|
|
|
|
|
const nextVisible = _visible && Boolean(visible);
|
|
|
|
|
if (_hoverGlowLines) _hoverGlowLines.visible = nextVisible;
|
|
|
|
|
@@ -243,7 +642,12 @@ function setHoverLinesVisible(visible) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function featureListToSegments(features, radius) {
|
|
|
|
|
return features.flatMap(f => featureToSegments(f.geometry, radius));
|
|
|
|
|
const all = [];
|
|
|
|
|
for (const feature of features) {
|
|
|
|
|
const points = featureToSegments(feature.geometry, radius);
|
|
|
|
|
for (const point of points) all.push(point);
|
|
|
|
|
}
|
|
|
|
|
return all;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeLineGeometry(points) {
|
|
|
|
|
@@ -265,12 +669,26 @@ function setLineGeometry(line, geometry) {
|
|
|
|
|
line.geometry = geometry;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelPendingHoverClear() {
|
|
|
|
|
if (!_hoverClearTimer) return;
|
|
|
|
|
clearTimeout(_hoverClearTimer);
|
|
|
|
|
_hoverClearTimer = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scheduleHoverClear(delayMs) {
|
|
|
|
|
if (_hoverClearTimer) return;
|
|
|
|
|
_hoverClearTimer = setTimeout(() => {
|
|
|
|
|
_hoverClearTimer = null;
|
|
|
|
|
clearCountryBoundaryHover({ cancelSticky: false });
|
|
|
|
|
}, Math.max(0, delayMs));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getHoverGeometries(groupKey, features) {
|
|
|
|
|
const cacheKey = groupKey || features[0] || "__empty__";
|
|
|
|
|
const cached = _hoverGeometryCache.get(cacheKey);
|
|
|
|
|
if (cached) return cached;
|
|
|
|
|
|
|
|
|
|
const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset;
|
|
|
|
|
const coreRadius = boundaryLineRadius();
|
|
|
|
|
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
|
|
|
|
|
const geometries = {
|
|
|
|
|
core: markCachedHoverGeometry(
|
|
|
|
|
@@ -357,6 +775,8 @@ export function createCountryBoundaryLayer(earthObj) {
|
|
|
|
|
_earthObj = earthObj;
|
|
|
|
|
_tintMesh = makeTintMesh();
|
|
|
|
|
_earthObj.add(_tintMesh);
|
|
|
|
|
_claimGroup = buildClaimLines();
|
|
|
|
|
_earthObj.add(_claimGroup);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Fetch GeoJSON, build meshes. Idempotent; safe to call multiple times. */
|
|
|
|
|
@@ -365,18 +785,71 @@ export async function loadCountryBoundaries() {
|
|
|
|
|
if (_loadPromise) return _loadPromise;
|
|
|
|
|
|
|
|
|
|
_loadPromise = (async () => {
|
|
|
|
|
const resp = await fetch(COUNTRY_BOUNDARY_CONFIG.dataPath);
|
|
|
|
|
if (!resp.ok) throw new Error(`国界数据加载失败 HTTP ${resp.status}`);
|
|
|
|
|
const geojson = await resp.json();
|
|
|
|
|
let geojson = { type: "FeatureCollection", features: [] };
|
|
|
|
|
let baseGeojson = null;
|
|
|
|
|
const manifestResp = await fetch(COUNTRY_BOUNDARY_CONFIG.tileManifestPath, { cache: "no-store" });
|
|
|
|
|
if (!manifestResp.ok) {
|
|
|
|
|
throw new Error(`高精度国界 PMTiles manifest 未生成 HTTP ${manifestResp.status}`);
|
|
|
|
|
}
|
|
|
|
|
_tileManifest = await manifestResp.json();
|
|
|
|
|
_tileProvider = manifestProvider(_tileManifest);
|
|
|
|
|
if (!["pmtiles-mvt", "geojson-high-precision"].includes(_tileProvider)) {
|
|
|
|
|
throw new Error(`高精度国界 provider 不可用: ${_tileProvider}`);
|
|
|
|
|
}
|
|
|
|
|
_tileAssetVersion = [
|
|
|
|
|
_tileManifest.version,
|
|
|
|
|
_tileManifest.builtAt,
|
|
|
|
|
_tileManifest.sourceFeatureCount,
|
|
|
|
|
_tileManifest.pmtiles?.sha256,
|
|
|
|
|
].filter(Boolean).join("-");
|
|
|
|
|
if (_tileProvider === "pmtiles-mvt") {
|
|
|
|
|
const pmtilesUrl = manifestPmtilesUrl(_tileManifest);
|
|
|
|
|
const pmtilesResp = await fetch(pmtilesUrl, { method: "HEAD", cache: "no-store" });
|
|
|
|
|
if (!pmtilesResp.ok) {
|
|
|
|
|
throw new Error(`高精度国界 PMTiles 不存在 HTTP ${pmtilesResp.status}: ${pmtilesUrl}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const basePath = _tileManifest.base || _tileManifest.baseGeojson;
|
|
|
|
|
const hoverPath = _tileManifest.hoverIndex || _tileManifest.hoverIndexGeojson;
|
|
|
|
|
const baseResp = basePath ? await fetch(versionedBoundaryAssetUrl(basePath)) : null;
|
|
|
|
|
const hoverResp = hoverPath ? await fetch(versionedBoundaryAssetUrl(hoverPath)) : null;
|
|
|
|
|
if (hoverResp?.ok) {
|
|
|
|
|
if (baseResp?.ok) baseGeojson = await baseResp.json();
|
|
|
|
|
geojson = await hoverResp.json();
|
|
|
|
|
}
|
|
|
|
|
const claimPath = _tileManifest.claimLine || _tileManifest.chinaClaims?.path;
|
|
|
|
|
const claimResp = claimPath ? await fetch(versionedBoundaryAssetUrl(claimPath)) : null;
|
|
|
|
|
const claimGeojson = claimResp?.ok ? await claimResp.json() : null;
|
|
|
|
|
_features = (geojson.features || []).filter(f => f.geometry);
|
|
|
|
|
const baseFeatures = (baseGeojson?.features || _features).filter(f => f.geometry);
|
|
|
|
|
const boundaryBaseFeatures = baseFeatures.filter(
|
|
|
|
|
feature => !isStandaloneCoastlineFeature(feature),
|
|
|
|
|
);
|
|
|
|
|
const coastlineFeatures = baseFeatures.filter(isStandaloneCoastlineFeature);
|
|
|
|
|
|
|
|
|
|
const tex = buildLandTexture(_features);
|
|
|
|
|
_landMesh = makeLandMesh(tex);
|
|
|
|
|
_landTexture = buildLandTexture(_features);
|
|
|
|
|
_landMesh = makeLandMesh(_landTexture);
|
|
|
|
|
_earthObj.add(_landMesh);
|
|
|
|
|
|
|
|
|
|
_boundaryLines = buildBoundaryLines(_features);
|
|
|
|
|
_boundaryLines = buildBoundaryLines(boundaryBaseFeatures);
|
|
|
|
|
_earthObj.add(_boundaryLines);
|
|
|
|
|
|
|
|
|
|
_coastlineLines = buildBoundaryLines(coastlineFeatures);
|
|
|
|
|
_coastlineLines.name = "country-coastline-all";
|
|
|
|
|
_earthObj.add(_coastlineLines);
|
|
|
|
|
|
|
|
|
|
if (claimGeojson?.features?.length && _claimGroup) {
|
|
|
|
|
const claimPoints = featureListToSegments(
|
|
|
|
|
claimGeojson.features,
|
|
|
|
|
boundaryLineRadius({ claim: true }),
|
|
|
|
|
);
|
|
|
|
|
_claimGroup.geometry?.dispose();
|
|
|
|
|
_claimGroup.geometry = makeLineGeometry(claimPoints);
|
|
|
|
|
if (typeof _claimGroup.computeLineDistances === "function") {
|
|
|
|
|
_claimGroup.computeLineDistances();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_hoverGlowLines = buildHoverGlowLines();
|
|
|
|
|
_earthObj.add(_hoverGlowLines);
|
|
|
|
|
|
|
|
|
|
@@ -421,13 +894,22 @@ export function toggleCountryBoundaries(
|
|
|
|
|
_landMesh.visible = _landFillEnabled && !_landFillSuppressed;
|
|
|
|
|
}
|
|
|
|
|
if (_boundaryLines) _boundaryLines.visible = _visible;
|
|
|
|
|
if (_coastlineLines) _coastlineLines.visible = _visible && !_hoveredFeature;
|
|
|
|
|
_tileCache.forEach((entry, key) => {
|
|
|
|
|
setTileObjectVisible(entry.object, _visible && _activeTileKeys.has(key));
|
|
|
|
|
});
|
|
|
|
|
if (_claimGroup) _claimGroup.visible = _visible && Boolean(_tileManifest?.chinaClaims?.available || _tileManifest?.claimLine);
|
|
|
|
|
setHoverLinesVisible(_hoveredFeature);
|
|
|
|
|
|
|
|
|
|
if (!_visible) {
|
|
|
|
|
cancelPendingHoverClear();
|
|
|
|
|
_hoveredFeature = null;
|
|
|
|
|
_hoveredGroupKey = null;
|
|
|
|
|
setBoundaryLinesDimmed(false);
|
|
|
|
|
_lastHoverInfo = null;
|
|
|
|
|
setHoverLinesVisible(false);
|
|
|
|
|
setActiveTileKeys([]);
|
|
|
|
|
_lastTileSignature = "";
|
|
|
|
|
updateBoundaryLineDimState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_tintMesh) _tintMesh.visible = _visible && showTint && _tintEnabled;
|
|
|
|
|
@@ -459,11 +941,13 @@ export function getShowCountryBoundaries() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Clear the hover highlight without hiding the full layer. */
|
|
|
|
|
export function clearCountryBoundaryHover() {
|
|
|
|
|
if (!_hoveredFeature) return;
|
|
|
|
|
export function clearCountryBoundaryHover({ cancelSticky = true } = {}) {
|
|
|
|
|
if (cancelSticky) cancelPendingHoverClear();
|
|
|
|
|
if (!_hoveredFeature && !_lastHoverInfo) return;
|
|
|
|
|
_hoveredFeature = null;
|
|
|
|
|
_hoveredGroupKey = null;
|
|
|
|
|
setBoundaryLinesDimmed(false);
|
|
|
|
|
_lastHoverInfo = null;
|
|
|
|
|
updateBoundaryLineDimState();
|
|
|
|
|
setHoverLinesVisible(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -478,15 +962,30 @@ export function updateCountryBoundaryHover(coords) {
|
|
|
|
|
const found = _features.find(f => featureContains(lat, lon, f)) || null;
|
|
|
|
|
const groupKey = getCountryHighlightGroupKey(found);
|
|
|
|
|
|
|
|
|
|
if (!found && _hoveredFeature) {
|
|
|
|
|
const stickyMs = Math.max(0, COUNTRY_BOUNDARY_CONFIG.hoverMissStickyMs || 0);
|
|
|
|
|
const elapsedMs = Date.now() - _lastHoverHitAt;
|
|
|
|
|
if (stickyMs > 0 && elapsedMs < stickyMs) {
|
|
|
|
|
scheduleHoverClear(stickyMs - elapsedMs);
|
|
|
|
|
return _lastHoverInfo;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (found) {
|
|
|
|
|
cancelPendingHoverClear();
|
|
|
|
|
_lastHoverHitAt = Date.now();
|
|
|
|
|
_lastHoverInfo = makeCountryInfo(found);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (found !== _hoveredFeature || groupKey !== _hoveredGroupKey) {
|
|
|
|
|
_hoveredFeature = found;
|
|
|
|
|
_hoveredGroupKey = groupKey;
|
|
|
|
|
if (_hoverLines) {
|
|
|
|
|
if (!found) {
|
|
|
|
|
setBoundaryLinesDimmed(false);
|
|
|
|
|
updateBoundaryLineDimState();
|
|
|
|
|
setHoverLinesVisible(false);
|
|
|
|
|
} else {
|
|
|
|
|
setBoundaryLinesDimmed(true);
|
|
|
|
|
updateBoundaryLineDimState();
|
|
|
|
|
const highlightFeatures = getHighlightFeatures(found);
|
|
|
|
|
const geometries = getHoverGeometries(groupKey, highlightFeatures);
|
|
|
|
|
setLineGeometry(_hoverGlowLines, geometries.glow);
|
|
|
|
|
@@ -496,13 +995,17 @@ export function updateCountryBoundaryHover(coords) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return found ? makeCountryInfo(found) : null;
|
|
|
|
|
if (!found) _lastHoverInfo = null;
|
|
|
|
|
return found ? _lastHoverInfo : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Dispose all Three.js objects and reset state. */
|
|
|
|
|
export function clearCountryBoundaryData() {
|
|
|
|
|
_hoveredFeature = null;
|
|
|
|
|
_hoveredGroupKey = null;
|
|
|
|
|
cancelPendingHoverClear();
|
|
|
|
|
_lastHoverHitAt = 0;
|
|
|
|
|
_lastHoverInfo = null;
|
|
|
|
|
|
|
|
|
|
function disposeObj(obj) {
|
|
|
|
|
if (!obj) return;
|
|
|
|
|
@@ -519,14 +1022,33 @@ export function clearCountryBoundaryData() {
|
|
|
|
|
disposeObj(_hoverLines);
|
|
|
|
|
disposeObj(_hoverGlowLines);
|
|
|
|
|
disposeObj(_boundaryLines);
|
|
|
|
|
disposeObj(_coastlineLines);
|
|
|
|
|
disposeObj(_claimGroup);
|
|
|
|
|
disposeObj(_landMesh);
|
|
|
|
|
disposeObj(_tintMesh);
|
|
|
|
|
_landTexture?.dispose?.();
|
|
|
|
|
_tileCache.forEach(entry => disposeTileObject(entry.object));
|
|
|
|
|
_tileCache.clear();
|
|
|
|
|
_tileLru = [];
|
|
|
|
|
_inFlightTiles.clear();
|
|
|
|
|
_activeTileKeys.clear();
|
|
|
|
|
_lastTileSignature = "";
|
|
|
|
|
if (_tileUpdateTimer) {
|
|
|
|
|
clearTimeout(_tileUpdateTimer);
|
|
|
|
|
_tileUpdateTimer = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_hoverLines = null;
|
|
|
|
|
_hoverGlowLines = null;
|
|
|
|
|
_boundaryLines = null;
|
|
|
|
|
_coastlineLines = null;
|
|
|
|
|
_claimGroup = null;
|
|
|
|
|
_landMesh = null;
|
|
|
|
|
_tintMesh = null;
|
|
|
|
|
_landTexture = null;
|
|
|
|
|
_tileManifest = null;
|
|
|
|
|
_tileProvider = "pmtiles-mvt";
|
|
|
|
|
_pmtilesArchive = null;
|
|
|
|
|
_features = [];
|
|
|
|
|
disposeHoverGeometryCache();
|
|
|
|
|
_loaded = false;
|
|
|
|
|
|