Files
planet/frontend/public/earth/js/country-boundaries.js
2026-04-27 13:58:29 +08:00

501 lines
16 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 { CONFIG, COUNTRY_BOUNDARY_CONFIG } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
// ─── Module state ──────────────────────────────────────────────────────────────
let _earthObj = null;
let _features = [];
let _landMesh = null;
let _tintMesh = null;
let _boundaryLines = null;
let _hoverGlowLines = null;
let _hoverLines = null;
let _hoveredFeature = null;
let _hoveredGroupKey = null;
let _visible = false;
let _landFillEnabled = true;
let _landFillSuppressed = false;
let _tintEnabled = false;
let _loaded = false;
let _loadPromise = null;
const OCEAN_HEX = 0x010609;
// ─── Equirectangular land/ocean fill texture ──────────────────────────────────
function hexToStyle(hex) {
return `#${hex.toString(16).padStart(6, "0")}`;
}
function hexToRgb(hex) {
return [
(hex >> 16) & 255,
(hex >> 8) & 255,
hex & 255,
];
}
function buildLandTexture(features) {
const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth;
const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight;
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;
}
// 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");
}
}
const imageData = ctx.getImageData(0, 0, width, height);
const tex = new THREE.DataTexture(
new Uint8Array(imageData.data),
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;
}
// ─── 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 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)));
}
return pts;
}
function buildBoundaryLines(features) {
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset;
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);
all.push(...pts);
}
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 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 clearHoverLineGeometries() {
if (_hoverGlowLines) _hoverGlowLines.geometry.setFromPoints([]);
if (_hoverLines) _hoverLines.geometry.setFromPoints([]);
}
function featureListToSegments(features, radius) {
return features.flatMap(f => featureToSegments(f.geometry, radius));
}
// ─── 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);
}
/** 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 () => {
const resp = await fetch(COUNTRY_BOUNDARY_CONFIG.dataPath);
if (!resp.ok) throw new Error(`国界数据加载失败 HTTP ${resp.status}`);
const geojson = await resp.json();
_features = (geojson.features || []).filter(f => f.geometry);
const tex = buildLandTexture(_features);
_landMesh = makeLandMesh(tex);
_earthObj.add(_landMesh);
_boundaryLines = buildBoundaryLines(_features);
_earthObj.add(_boundaryLines);
_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 (_hoverGlowLines) _hoverGlowLines.visible = _visible;
if (_hoverLines) _hoverLines.visible = _visible;
if (!_visible) {
_hoveredFeature = null;
_hoveredGroupKey = null;
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
}
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() {
if (!_hoveredFeature) return;
_hoveredFeature = null;
_hoveredGroupKey = null;
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
}
/**
* 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 || groupKey !== _hoveredGroupKey) {
_hoveredFeature = found;
_hoveredGroupKey = groupKey;
if (_hoverLines) {
if (!found) {
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
} else {
setBoundaryLinesDimmed(true);
const highlightFeatures = getHighlightFeatures(found);
const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset;
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
if (_hoverGlowLines) {
const glowPts = featureListToSegments(highlightFeatures, glowRadius);
_hoverGlowLines.geometry.setFromPoints(glowPts);
}
const corePts = featureListToSegments(highlightFeatures, coreRadius);
_hoverLines.geometry.setFromPoints(corePts);
}
}
}
return found ? makeCountryInfo(found) : null;
}
/** Dispose all Three.js objects and reset state. */
export function clearCountryBoundaryData() {
_hoveredFeature = null;
_hoveredGroupKey = null;
function disposeObj(obj) {
if (!obj) return;
if (_earthObj) _earthObj.remove(obj);
obj.geometry?.dispose();
if (obj.material) {
if (obj.material.map) obj.material.map.dispose();
obj.material.dispose();
}
}
disposeObj(_hoverLines);
disposeObj(_hoverGlowLines);
disposeObj(_boundaryLines);
disposeObj(_landMesh);
disposeObj(_tintMesh);
_hoverLines = null;
_hoverGlowLines = null;
_boundaryLines = null;
_landMesh = null;
_tintMesh = null;
_features = [];
_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: "海洋填色" },
];
}