501 lines
16 KiB
JavaScript
501 lines
16 KiB
JavaScript
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: "海洋填色" },
|
||
];
|
||
}
|