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 _hoverGeometryCache = new Map(); 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 setHoverLinesVisible(visible) { const nextVisible = _visible && Boolean(visible); if (_hoverGlowLines) _hoverGlowLines.visible = nextVisible; if (_hoverLines) _hoverLines.visible = nextVisible; } function featureListToSegments(features, radius) { return features.flatMap(f => featureToSegments(f.geometry, radius)); } 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 getHoverGeometries(groupKey, features) { const cacheKey = groupKey || features[0] || "__empty__"; const cached = _hoverGeometryCache.get(cacheKey); if (cached) return cached; const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset; const 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); } /** 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; setHoverLinesVisible(_hoveredFeature); if (!_visible) { _hoveredFeature = null; _hoveredGroupKey = null; setBoundaryLinesDimmed(false); setHoverLinesVisible(false); } 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); 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 || groupKey !== _hoveredGroupKey) { _hoveredFeature = found; _hoveredGroupKey = groupKey; if (_hoverLines) { if (!found) { setBoundaryLinesDimmed(false); setHoverLinesVisible(false); } else { setBoundaryLinesDimmed(true); const highlightFeatures = getHighlightFeatures(found); const geometries = getHoverGeometries(groupKey, highlightFeatures); setLineGeometry(_hoverGlowLines, geometries.glow); setLineGeometry(_hoverLines, geometries.core); setHoverLinesVisible(true); } } } 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); 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(_landMesh); disposeObj(_tintMesh); _hoverLines = null; _hoverGlowLines = null; _boundaryLines = null; _landMesh = null; _tintMesh = 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: "海洋填色" }, ]; }