Files
planet/frontend/public/earth/js/country-boundaries.js
linkong 93eb41a9f7
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.58.0
Release 0.58.0 includes the Earth high-precision boundary PMTiles/MVT pipeline, standardized Earth boundary source collectors, China POV boundary configuration templates, and removal of the legacy low-precision GeoJSON fallback. It also adds Earth news target-location queueing/archive support, fixes datasource task status visibility, documents the Earth surface depth-spacing rules that prevent far-zoom z-fighting snow/black blocks, and updates bilingual operations/developer docs.
2026-05-15 17:40:07 +08:00

1069 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 _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")}`;
}
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";
}
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,
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;
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);
_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;
}
/** 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";
_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: "海洋填色" },
];
}