1132 lines
36 KiB
JavaScript
1132 lines
36 KiB
JavaScript
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, screenToEarthCoords, vector3ToLatLon } from "./utils.js";
|
||
|
||
// ─── Module state ──────────────────────────────────────────────────────────────
|
||
let _earthObj = null;
|
||
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 _boundaryProviderState = "unloaded";
|
||
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 HIGH_PRECISION_BOUNDARIES_STORAGE_KEY = "planet.earth.boundaries.highPrecisionEnabled";
|
||
|
||
function canUseLocalStorage() {
|
||
try {
|
||
return typeof window !== "undefined" && !!window.localStorage;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export function getHighPrecisionBoundariesEnabled() {
|
||
if (!canUseLocalStorage()) return false;
|
||
return window.localStorage.getItem(HIGH_PRECISION_BOUNDARIES_STORAGE_KEY) === "true";
|
||
}
|
||
|
||
export function setHighPrecisionBoundariesEnabled(enabled) {
|
||
const nextEnabled = Boolean(enabled);
|
||
if (canUseLocalStorage()) {
|
||
window.localStorage.setItem(
|
||
HIGH_PRECISION_BOUNDARIES_STORAGE_KEY,
|
||
nextEnabled ? "true" : "false",
|
||
);
|
||
}
|
||
return nextEnabled;
|
||
}
|
||
|
||
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")}`;
|
||
}
|
||
|
||
function hexToRgb(hex) {
|
||
return [
|
||
(hex >> 16) & 255,
|
||
(hex >> 8) & 255,
|
||
hex & 255,
|
||
];
|
||
}
|
||
|
||
function drawLandMaskCanvas(features, width, height) {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const ctx = canvas.getContext("2d");
|
||
|
||
if (!ctx) {
|
||
return null;
|
||
}
|
||
|
||
// Ocean background
|
||
ctx.fillStyle = hexToStyle(OCEAN_HEX);
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
// Land polygons using evenodd fill rule so holes (lakes, islands) work correctly
|
||
ctx.fillStyle = hexToStyle(COUNTRY_BOUNDARY_CONFIG.landColor);
|
||
|
||
for (const feat of features) {
|
||
const geom = feat.geometry;
|
||
if (!geom) continue;
|
||
const polys =
|
||
geom.type === "Polygon" ? [geom.coordinates] :
|
||
geom.type === "MultiPolygon" ? geom.coordinates : null;
|
||
if (!polys) continue;
|
||
|
||
for (const rings of polys) {
|
||
ctx.beginPath();
|
||
for (const ring of rings) {
|
||
for (let i = 0; i < ring.length; i++) {
|
||
// equirectangular: x = (lon+180)/360*width, y = (90-lat)/180*height
|
||
const px = ((ring[i][0] + 180) / 360) * width;
|
||
const py = ((90 - ring[i][1]) / 180) * height;
|
||
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||
}
|
||
ctx.closePath();
|
||
}
|
||
ctx.fill("evenodd");
|
||
}
|
||
}
|
||
|
||
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,
|
||
);
|
||
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 ───────────────────────────────────────────────────────
|
||
|
||
function makeLandMesh(tex) {
|
||
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset;
|
||
const geo = new THREE.SphereGeometry(r, 128, 64);
|
||
const mat = new THREE.MeshBasicMaterial({
|
||
color: 0xffffff,
|
||
map: tex,
|
||
transparent: COUNTRY_BOUNDARY_CONFIG.landOpacity < 1,
|
||
opacity: COUNTRY_BOUNDARY_CONFIG.landOpacity,
|
||
depthTest: true,
|
||
depthWrite: false,
|
||
});
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
mesh.name = "country-land-ocean";
|
||
mesh.renderOrder = COUNTRY_BOUNDARY_CONFIG.landRenderOrder;
|
||
mesh.visible = false;
|
||
mesh.raycast = () => {};
|
||
return mesh;
|
||
}
|
||
|
||
function makeTintMesh() {
|
||
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset;
|
||
const geo = new THREE.SphereGeometry(r, 64, 32);
|
||
const mat = new THREE.MeshBasicMaterial({ color: COUNTRY_BOUNDARY_CONFIG.tintColor, depthWrite: false });
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
mesh.name = "country-tint";
|
||
mesh.renderOrder = COUNTRY_BOUNDARY_CONFIG.tintRenderOrder;
|
||
mesh.visible = false;
|
||
mesh.raycast = () => {};
|
||
return mesh;
|
||
}
|
||
|
||
// ─── 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;
|
||
for (let i = 0; i < n - 1; i++) {
|
||
out.push(latLonToVector3(ring[i][1], ring[i][0], radius));
|
||
out.push(latLonToVector3(ring[i+1][1], ring[i+1][0], radius));
|
||
}
|
||
}
|
||
|
||
function featureToSegments(geom, radius) {
|
||
const pts = [];
|
||
if (!geom) return pts;
|
||
if (geom.type === "Polygon") {
|
||
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 = boundaryLineRadius();
|
||
const mat = new THREE.LineBasicMaterial({
|
||
color: COUNTRY_BOUNDARY_CONFIG.lineColor,
|
||
transparent: true,
|
||
opacity: COUNTRY_BOUNDARY_CONFIG.lineOpacity,
|
||
depthTest: true,
|
||
depthWrite: false,
|
||
});
|
||
|
||
const all = [];
|
||
for (const feat of features) {
|
||
const pts = featureToSegments(feat.geometry, r);
|
||
for (const point of pts) all.push(point);
|
||
}
|
||
|
||
const geo = all.length > 0
|
||
? new THREE.BufferGeometry().setFromPoints(all)
|
||
: new THREE.BufferGeometry();
|
||
const lines = new THREE.LineSegments(geo, mat);
|
||
lines.name = "country-boundary-all";
|
||
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.lineRenderOrder;
|
||
lines.visible = false;
|
||
lines.raycast = () => {};
|
||
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";
|
||
}
|
||
|
||
async function fetchJsonAsset(url, { required = false } = {}) {
|
||
const resp = await fetch(url, { cache: "no-store" });
|
||
if (!resp.ok) {
|
||
if (required) throw new Error(`${url} HTTP ${resp.status}`);
|
||
return null;
|
||
}
|
||
const text = await resp.text();
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch (err) {
|
||
if (required) throw err;
|
||
console.warn("[country-boundaries] JSON asset unavailable", url, err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
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 !== "pmtiles-mvt") 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,
|
||
transparent: COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity < 1,
|
||
opacity: COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity,
|
||
depthTest: false,
|
||
depthWrite: false,
|
||
});
|
||
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
|
||
lines.name = "country-hover";
|
||
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder;
|
||
lines.visible = false;
|
||
lines.raycast = () => {};
|
||
return lines;
|
||
}
|
||
|
||
function buildHoverGlowLines() {
|
||
const mat = new THREE.LineBasicMaterial({
|
||
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
|
||
transparent: true,
|
||
opacity: COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity,
|
||
depthTest: false,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
linewidth: COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth,
|
||
});
|
||
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
|
||
lines.name = "country-hover-glow";
|
||
lines.renderOrder =
|
||
COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder -
|
||
COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset;
|
||
lines.visible = false;
|
||
lines.raycast = () => {};
|
||
return lines;
|
||
}
|
||
|
||
function setBoundaryLinesDimmed(dimmed) {
|
||
if (!_boundaryLines?.material) return;
|
||
_boundaryLines.material.opacity = dimmed
|
||
? COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity
|
||
: COUNTRY_BOUNDARY_CONFIG.lineOpacity;
|
||
_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;
|
||
if (_hoverLines) _hoverLines.visible = nextVisible;
|
||
}
|
||
|
||
function featureListToSegments(features, 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) {
|
||
return points.length > 0
|
||
? new THREE.BufferGeometry().setFromPoints(points)
|
||
: new THREE.BufferGeometry();
|
||
}
|
||
|
||
function markCachedHoverGeometry(geometry) {
|
||
if (geometry) geometry.userData.countryBoundaryHoverCached = true;
|
||
return geometry;
|
||
}
|
||
|
||
function setLineGeometry(line, geometry) {
|
||
if (!line || !geometry || line.geometry === geometry) return;
|
||
if (!line.geometry?.userData?.countryBoundaryHoverCached) {
|
||
line.geometry?.dispose?.();
|
||
}
|
||
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 = boundaryLineRadius();
|
||
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
|
||
const geometries = {
|
||
core: markCachedHoverGeometry(
|
||
makeLineGeometry(featureListToSegments(features, coreRadius)),
|
||
),
|
||
glow: markCachedHoverGeometry(
|
||
makeLineGeometry(featureListToSegments(features, glowRadius)),
|
||
),
|
||
};
|
||
_hoverGeometryCache.set(cacheKey, geometries);
|
||
return geometries;
|
||
}
|
||
|
||
function disposeHoverGeometryCache() {
|
||
_hoverGeometryCache.forEach(({ core, glow }) => {
|
||
core?.dispose?.();
|
||
glow?.dispose?.();
|
||
});
|
||
_hoverGeometryCache.clear();
|
||
}
|
||
|
||
// ─── Point-in-polygon (lat/lon space) ─────────────────────────────────────────
|
||
|
||
function pointInRing(lat, lon, ring) {
|
||
let inside = false;
|
||
const n = ring.length;
|
||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||
const xi = ring[i][0], yi = ring[i][1];
|
||
const xj = ring[j][0], yj = ring[j][1];
|
||
if ((yi > lat) !== (yj > lat) && lon < (xj - xi) * (lat - yi) / (yj - yi) + xi) {
|
||
inside = !inside;
|
||
}
|
||
}
|
||
return inside;
|
||
}
|
||
|
||
function featureContains(lat, lon, feat) {
|
||
const geom = feat.geometry;
|
||
if (!geom) return false;
|
||
if (geom.type === "Polygon") {
|
||
if (!pointInRing(lat, lon, geom.coordinates[0])) return false;
|
||
return geom.coordinates.slice(1).every(h => !pointInRing(lat, lon, h));
|
||
}
|
||
if (geom.type === "MultiPolygon") {
|
||
return geom.coordinates.some(poly =>
|
||
pointInRing(lat, lon, poly[0]) &&
|
||
poly.slice(1).every(h => !pointInRing(lat, lon, h))
|
||
);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function makeCountryInfo(feat) {
|
||
if (!feat) return null;
|
||
const p = feat.properties || {};
|
||
return {
|
||
name: p.NAME_EN || p.NAME || p.ADMIN || "",
|
||
nameZh: p.NAME_ZH || null,
|
||
isoA3: p.ISO_A3 || p.ADM0_A3 || null,
|
||
isoA2: p.ISO_A2 || null,
|
||
continent: p.CONTINENT || null,
|
||
};
|
||
}
|
||
|
||
function getCountryHighlightGroupKey(feat) {
|
||
const p = feat?.properties || {};
|
||
const isoA3 = p.ISO_A3 || p.ADM0_A3 || "";
|
||
if (isoA3 === "CHN" || isoA3 === "TWN") {
|
||
return "CHN_TWN";
|
||
}
|
||
return isoA3 || p.ISO_A2 || p.NAME_EN || p.NAME || p.ADMIN || null;
|
||
}
|
||
|
||
function getHighlightFeatures(feat) {
|
||
const groupKey = getCountryHighlightGroupKey(feat);
|
||
if (!groupKey) return feat ? [feat] : [];
|
||
return _features.filter(f => getCountryHighlightGroupKey(f) === groupKey);
|
||
}
|
||
|
||
// ─── Public API ────────────────────────────────────────────────────────────────
|
||
|
||
/** Called during init (before data load). Creates the placeholder tint sphere. */
|
||
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. */
|
||
export async function loadCountryBoundaries() {
|
||
if (_loaded) return _features.length;
|
||
if (_loadPromise) return _loadPromise;
|
||
|
||
_loadPromise = (async () => {
|
||
let geojson = { type: "FeatureCollection", features: [] };
|
||
let baseGeojson = null;
|
||
let claimGeojson = null;
|
||
const highPrecisionEnabled = getHighPrecisionBoundariesEnabled();
|
||
const manifest = highPrecisionEnabled
|
||
? await fetchJsonAsset(COUNTRY_BOUNDARY_CONFIG.tileManifestPath)
|
||
: null;
|
||
if (manifest) {
|
||
const provider = manifestProvider(manifest);
|
||
const pmtilesUrl = manifestPmtilesUrl(manifest);
|
||
const pmtilesResp = provider === "pmtiles-mvt"
|
||
? await fetch(pmtilesUrl, { method: "HEAD", cache: "no-store" })
|
||
: null;
|
||
const highPrecisionReady = (
|
||
["pmtiles-mvt", "geojson-high-precision"].includes(provider) &&
|
||
(provider !== "pmtiles-mvt" || pmtilesResp?.ok)
|
||
);
|
||
if (highPrecisionReady) {
|
||
_tileManifest = manifest;
|
||
_tileProvider = provider;
|
||
_boundaryProviderState = provider;
|
||
_tileAssetVersion = [
|
||
_tileManifest.version,
|
||
_tileManifest.builtAt,
|
||
_tileManifest.sourceFeatureCount,
|
||
_tileManifest.pmtiles?.sha256,
|
||
].filter(Boolean).join("-");
|
||
const basePath = _tileManifest.base || _tileManifest.baseGeojson;
|
||
const hoverPath = _tileManifest.hoverIndex || _tileManifest.hoverIndexGeojson;
|
||
if (basePath) baseGeojson = await fetchJsonAsset(versionedBoundaryAssetUrl(basePath));
|
||
if (hoverPath) {
|
||
geojson = await fetchJsonAsset(versionedBoundaryAssetUrl(hoverPath)) || geojson;
|
||
}
|
||
const claimPath = _tileManifest.claimLine || _tileManifest.chinaClaims?.path;
|
||
if (claimPath) claimGeojson = await fetchJsonAsset(versionedBoundaryAssetUrl(claimPath));
|
||
}
|
||
}
|
||
if (_tileManifest && !(geojson.features || []).length) {
|
||
console.warn("[country-boundaries] high precision hover index unavailable; using legacy fallback");
|
||
_tileManifest = null;
|
||
_pmtilesArchive = null;
|
||
claimGeojson = null;
|
||
}
|
||
if (!_tileManifest) {
|
||
geojson = await fetchJsonAsset(COUNTRY_BOUNDARY_CONFIG.legacyFallbackPath, { required: true });
|
||
baseGeojson = geojson;
|
||
_tileProvider = "legacy-geojson";
|
||
_boundaryProviderState = "legacy-geojson";
|
||
_tileAssetVersion = "legacy";
|
||
}
|
||
_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);
|
||
|
||
_landTexture = buildLandTexture(_features);
|
||
_landMesh = makeLandMesh(_landTexture);
|
||
_earthObj.add(_landMesh);
|
||
|
||
_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);
|
||
|
||
_hoverLines = buildHoverLines();
|
||
_earthObj.add(_hoverLines);
|
||
|
||
_loaded = true;
|
||
return _features.length;
|
||
})();
|
||
|
||
return _loadPromise;
|
||
}
|
||
|
||
/** Load if not yet loaded, then return feature count. */
|
||
export async function ensureCountryBoundariesReady() {
|
||
if (!_loaded) await loadCountryBoundaries();
|
||
return _features.length;
|
||
}
|
||
|
||
/**
|
||
* Show or hide the country boundary lines.
|
||
* The land/ocean fill is the base earth map and stays independent from this
|
||
* line visibility switch.
|
||
* @param {boolean} visible
|
||
* @param {{ showTint?: boolean, showLandFill?: boolean, suppressLandFill?: boolean }} [opts]
|
||
* showLandFill – whether to show the base land/ocean fill.
|
||
* Defaults to the current stored value so callers that only
|
||
* care about visibility don't need to repeat it.
|
||
* suppressLandFill – temporarily keep the fill below the high-res texture
|
||
* without changing the layer's own fill state.
|
||
*/
|
||
export function toggleCountryBoundaries(
|
||
visible,
|
||
{ showTint = false, showLandFill = null, suppressLandFill = null } = {},
|
||
) {
|
||
_visible = Boolean(visible);
|
||
|
||
if (showLandFill !== null) _landFillEnabled = Boolean(showLandFill);
|
||
if (suppressLandFill !== null) _landFillSuppressed = Boolean(suppressLandFill);
|
||
|
||
if (_landMesh) {
|
||
_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;
|
||
_lastHoverInfo = null;
|
||
setHoverLinesVisible(false);
|
||
setActiveTileKeys([]);
|
||
_lastTileSignature = "";
|
||
updateBoundaryLineDimState();
|
||
}
|
||
|
||
if (_tintMesh) _tintMesh.visible = _visible && showTint && _tintEnabled;
|
||
}
|
||
|
||
/**
|
||
* Show or hide the land/ocean canvas fill independently of boundary lines.
|
||
*/
|
||
export function setLandFillEnabled(enabled) {
|
||
_landFillEnabled = Boolean(enabled);
|
||
if (_landMesh) _landMesh.visible = _landFillEnabled && !_landFillSuppressed;
|
||
}
|
||
|
||
export function setLandFillSuppressed(enabled) {
|
||
_landFillSuppressed = Boolean(enabled);
|
||
if (_landMesh) _landMesh.visible = _landFillEnabled && !_landFillSuppressed;
|
||
}
|
||
|
||
/**
|
||
* Enable / disable the solid dark tint overlay (used when high-res texture is off).
|
||
*/
|
||
export function setSurfaceTintEnabled(enabled) {
|
||
_tintEnabled = Boolean(enabled);
|
||
if (_tintMesh) _tintMesh.visible = _visible && _tintEnabled;
|
||
}
|
||
|
||
export function getShowCountryBoundaries() {
|
||
return _visible;
|
||
}
|
||
|
||
export function getCountryBoundaryProviderState() {
|
||
return _boundaryProviderState;
|
||
}
|
||
|
||
/** Clear the hover highlight without hiding the full layer. */
|
||
export function clearCountryBoundaryHover({ cancelSticky = true } = {}) {
|
||
if (cancelSticky) cancelPendingHoverClear();
|
||
if (!_hoveredFeature && !_lastHoverInfo) return;
|
||
_hoveredFeature = null;
|
||
_hoveredGroupKey = null;
|
||
_lastHoverInfo = null;
|
||
updateBoundaryLineDimState();
|
||
setHoverLinesVisible(false);
|
||
}
|
||
|
||
/**
|
||
* Update hover highlight for the given lat/lon coords.
|
||
* Returns a country-info object when hovering over land, or null over ocean.
|
||
*/
|
||
export function updateCountryBoundaryHover(coords) {
|
||
if (!_loaded || !_visible) return null;
|
||
const { lat, lon } = 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) {
|
||
updateBoundaryLineDimState();
|
||
setHoverLinesVisible(false);
|
||
} else {
|
||
updateBoundaryLineDimState();
|
||
const highlightFeatures = getHighlightFeatures(found);
|
||
const geometries = getHoverGeometries(groupKey, highlightFeatures);
|
||
setLineGeometry(_hoverGlowLines, geometries.glow);
|
||
setLineGeometry(_hoverLines, geometries.core);
|
||
setHoverLinesVisible(true);
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
if (_earthObj) _earthObj.remove(obj);
|
||
if (!obj.geometry?.userData?.countryBoundaryHoverCached) {
|
||
obj.geometry?.dispose();
|
||
}
|
||
if (obj.material) {
|
||
if (obj.material.map) obj.material.map.dispose();
|
||
obj.material.dispose();
|
||
}
|
||
}
|
||
|
||
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";
|
||
_boundaryProviderState = "unloaded";
|
||
_pmtilesArchive = null;
|
||
_features = [];
|
||
disposeHoverGeometryCache();
|
||
_loaded = false;
|
||
_loadPromise = null;
|
||
_visible = false;
|
||
_landFillEnabled = true;
|
||
_landFillSuppressed = false;
|
||
_tintEnabled = false;
|
||
}
|
||
|
||
export function getCountryBoundaryLegendItems() {
|
||
return [
|
||
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.lineColor), label: "国界线" },
|
||
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.landColor), label: "陆地填色" },
|
||
{ color: hexToStyle(OCEAN_HEX), label: "海洋填色" },
|
||
];
|
||
}
|