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.
This commit is contained in:
@@ -6,9 +6,12 @@
|
||||
"name": "planet-frontend",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"antd": "^5.12.5",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"pbf": "^4.0.1",
|
||||
"pmtiles": "^4.4.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
@@ -143,6 +146,10 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="],
|
||||
|
||||
"@mapbox/vector-tile": ["@mapbox/vector-tile@2.0.4", "", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^4.0.1" } }, "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg=="],
|
||||
|
||||
"@rc-component/async-validator": ["@rc-component/async-validator@5.1.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA=="],
|
||||
|
||||
"@rc-component/color-picker": ["@rc-component/color-picker@2.0.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6", "@babel/runtime": "^7.23.6", "classnames": "^2.2.6", "rc-util": "^5.38.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q=="],
|
||||
@@ -227,6 +234,8 @@
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||
|
||||
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
|
||||
|
||||
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
@@ -291,6 +300,8 @@
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
@@ -339,12 +350,18 @@
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"pbf": ["pbf@4.0.1", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"pmtiles": ["pmtiles@4.4.1", "", { "dependencies": { "fflate": "^0.8.2" } }, "sha512-5oTeQc/yX/ft1evbpIlnoCZugQuug/iYIAj/ZTqIqzdGek4uZEho99En890EE6NOSI3JTI3IG8R7r8+SltphxA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"protocol-buffers-schema": ["protocol-buffers-schema@3.6.1", "", {}, "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"rc-cascader": ["rc-cascader@3.34.0", "", { "dependencies": { "@babel/runtime": "^7.25.7", "classnames": "^2.3.1", "rc-select": "~14.16.2", "rc-tree": "~5.13.0", "rc-util": "^5.43.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag=="],
|
||||
@@ -433,6 +450,8 @@
|
||||
|
||||
"resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="],
|
||||
|
||||
"resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="],
|
||||
|
||||
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||
|
||||
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
@@ -50,6 +50,23 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /earth/data/boundaries/v1/manifest.json {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~ ^/earth/data/boundaries/.*\.pmtiles$ {
|
||||
types { application/octet-stream pmtiles; }
|
||||
add_header Accept-Ranges bytes;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /earth/data/boundaries/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"antd": "^5.12.5",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"pbf": "^4.0.1",
|
||||
"pmtiles": "^4.4.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,9 @@
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://esm.sh/three@0.128.0",
|
||||
"pmtiles": "https://esm.sh/pmtiles@4.4.1",
|
||||
"@mapbox/vector-tile": "https://esm.sh/@mapbox/vector-tile@2.0.4",
|
||||
"pbf": "https://esm.sh/pbf@4.0.1",
|
||||
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
|
||||
"satellite.js": "https://esm.sh/satellite.js@5.0.0",
|
||||
"hls.js": "https://esm.sh/hls.js@1.6.15",
|
||||
|
||||
@@ -186,9 +186,28 @@ export const TERRAIN_CONFIG = {
|
||||
};
|
||||
|
||||
export const COUNTRY_BOUNDARY_CONFIG = {
|
||||
dataPath: new URL("../data/countries-admin0.min.geojson", import.meta.url).href,
|
||||
tileManifestPath: new URL("../data/boundaries/v1/manifest.json", import.meta.url).href,
|
||||
tileBasePath: new URL("../data/boundaries/v1/", import.meta.url).href,
|
||||
tileProvider: "auto",
|
||||
pmtilesPath: new URL("../data/boundaries/earth-boundaries-china-pov-v1.pmtiles", import.meta.url).href,
|
||||
mvtLayerNames: {
|
||||
boundary: ["boundary_admin0", "boundary_disputed_internal", "coastline"],
|
||||
claim: ["claim_line"],
|
||||
},
|
||||
tileZoomThresholds: [
|
||||
{ minViewZoom: 5.2, tileZoom: 10 },
|
||||
{ minViewZoom: 4.6, tileZoom: 9 },
|
||||
{ minViewZoom: 4.0, tileZoom: 8 },
|
||||
{ minViewZoom: 3.4, tileZoom: 7 },
|
||||
{ minViewZoom: 2.8, tileZoom: 6 },
|
||||
{ minViewZoom: 1.6, tileZoom: 5 },
|
||||
],
|
||||
tilePrefetchRing: 1,
|
||||
tileDebounceMs: 180,
|
||||
tileCacheLimit: 150,
|
||||
lineAltitudeOffset: 0.115,
|
||||
hoverAltitudeOffset: 0.14,
|
||||
hoverAltitudeOffset: 0.115,
|
||||
hoverMissStickyMs: 160,
|
||||
lineColor: 0x7fc7ff,
|
||||
lineOpacity: 0.58,
|
||||
lineRenderOrder: 2.2,
|
||||
@@ -199,13 +218,13 @@ export const COUNTRY_BOUNDARY_CONFIG = {
|
||||
hoverGlowOpacity: 0.38,
|
||||
hoverGlowLineWidth: 3,
|
||||
hoverGlowRenderOrderOffset: 0.01,
|
||||
hoverGlowRadiusOffset: 0.04,
|
||||
hoverGlowRadiusOffset: 0,
|
||||
tintAltitudeOffset: 0.04,
|
||||
tintColor: 0x0b1830,
|
||||
tintRenderOrder: 0.2,
|
||||
landColor: 0x080f1b,
|
||||
landOpacity: 1.0,
|
||||
landAltitudeOffset: 0.08,
|
||||
landAltitudeOffset: 0.32,
|
||||
landRenderOrder: 0.86,
|
||||
landMaskWidth: 2048,
|
||||
landMaskHeight: 1024,
|
||||
@@ -492,7 +511,7 @@ export const EARTH_MATERIAL_CONFIG = {
|
||||
shininess: 12,
|
||||
emissive: 0x010609,
|
||||
opacity: 1,
|
||||
textureOverlayAltitudeOffset: 0.1,
|
||||
textureOverlayAltitudeOffset: 0.48,
|
||||
textureOverlayOpacity: 0.88,
|
||||
textureOverlayRenderOrder: 0.96,
|
||||
textureOverlaySpecular: 0x05080d,
|
||||
@@ -536,7 +555,9 @@ export const EARTH_MATERIAL_CONFIG = {
|
||||
sunDirection: { x: 1, y: 0.2, z: 0.4 },
|
||||
nightFloor: 0.24,
|
||||
dayBoost: 1.12,
|
||||
featherScale: 0.001,
|
||||
twilightWidth: 0.2,
|
||||
twilightFeatherScale: 1.0,
|
||||
twilightIntensity: 0.14,
|
||||
twilightColor: 0x4ea0ff,
|
||||
nightTintColor: 0x0b1830,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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 } from "./utils.js";
|
||||
import { latLonToVector3, screenToEarthCoords, vector3ToLatLon } from "./utils.js";
|
||||
|
||||
// ─── Module state ──────────────────────────────────────────────────────────────
|
||||
let _earthObj = null;
|
||||
@@ -8,21 +11,48 @@ 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")}`;
|
||||
}
|
||||
@@ -35,32 +65,14 @@ function hexToRgb(hex) {
|
||||
];
|
||||
}
|
||||
|
||||
function buildLandTexture(features) {
|
||||
const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth;
|
||||
const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight;
|
||||
|
||||
function drawLandMaskCanvas(features, width, height) {
|
||||
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;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ocean background
|
||||
@@ -93,21 +105,43 @@ function buildLandTexture(features) {
|
||||
}
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const tex = new THREE.DataTexture(
|
||||
new Uint8Array(imageData.data),
|
||||
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,
|
||||
);
|
||||
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;
|
||||
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 ───────────────────────────────────────────────────────
|
||||
@@ -145,6 +179,14 @@ function makeTintMesh() {
|
||||
|
||||
// ─── 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;
|
||||
@@ -161,12 +203,16 @@ function featureToSegments(geom, radius) {
|
||||
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 = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset;
|
||||
const r = boundaryLineRadius();
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color: COUNTRY_BOUNDARY_CONFIG.lineColor,
|
||||
transparent: true,
|
||||
@@ -178,7 +224,7 @@ function buildBoundaryLines(features) {
|
||||
const all = [];
|
||||
for (const feat of features) {
|
||||
const pts = featureToSegments(feat.geometry, r);
|
||||
all.push(...pts);
|
||||
for (const point of pts) all.push(point);
|
||||
}
|
||||
|
||||
const geo = all.length > 0
|
||||
@@ -192,6 +238,350 @@ function buildBoundaryLines(features) {
|
||||
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,
|
||||
@@ -236,6 +626,15 @@ function setBoundaryLinesDimmed(dimmed) {
|
||||
_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;
|
||||
@@ -243,7 +642,12 @@ function setHoverLinesVisible(visible) {
|
||||
}
|
||||
|
||||
function featureListToSegments(features, radius) {
|
||||
return features.flatMap(f => featureToSegments(f.geometry, 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) {
|
||||
@@ -265,12 +669,26 @@ function setLineGeometry(line, geometry) {
|
||||
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 = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset;
|
||||
const coreRadius = boundaryLineRadius();
|
||||
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
|
||||
const geometries = {
|
||||
core: markCachedHoverGeometry(
|
||||
@@ -357,6 +775,8 @@ 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. */
|
||||
@@ -365,18 +785,71 @@ export async function loadCountryBoundaries() {
|
||||
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();
|
||||
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);
|
||||
|
||||
const tex = buildLandTexture(_features);
|
||||
_landMesh = makeLandMesh(tex);
|
||||
_landTexture = buildLandTexture(_features);
|
||||
_landMesh = makeLandMesh(_landTexture);
|
||||
_earthObj.add(_landMesh);
|
||||
|
||||
_boundaryLines = buildBoundaryLines(_features);
|
||||
_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);
|
||||
|
||||
@@ -421,13 +894,22 @@ export function toggleCountryBoundaries(
|
||||
_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;
|
||||
setBoundaryLinesDimmed(false);
|
||||
_lastHoverInfo = null;
|
||||
setHoverLinesVisible(false);
|
||||
setActiveTileKeys([]);
|
||||
_lastTileSignature = "";
|
||||
updateBoundaryLineDimState();
|
||||
}
|
||||
|
||||
if (_tintMesh) _tintMesh.visible = _visible && showTint && _tintEnabled;
|
||||
@@ -459,11 +941,13 @@ export function getShowCountryBoundaries() {
|
||||
}
|
||||
|
||||
/** Clear the hover highlight without hiding the full layer. */
|
||||
export function clearCountryBoundaryHover() {
|
||||
if (!_hoveredFeature) return;
|
||||
export function clearCountryBoundaryHover({ cancelSticky = true } = {}) {
|
||||
if (cancelSticky) cancelPendingHoverClear();
|
||||
if (!_hoveredFeature && !_lastHoverInfo) return;
|
||||
_hoveredFeature = null;
|
||||
_hoveredGroupKey = null;
|
||||
setBoundaryLinesDimmed(false);
|
||||
_lastHoverInfo = null;
|
||||
updateBoundaryLineDimState();
|
||||
setHoverLinesVisible(false);
|
||||
}
|
||||
|
||||
@@ -478,15 +962,30 @@ export function updateCountryBoundaryHover(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) {
|
||||
setBoundaryLinesDimmed(false);
|
||||
updateBoundaryLineDimState();
|
||||
setHoverLinesVisible(false);
|
||||
} else {
|
||||
setBoundaryLinesDimmed(true);
|
||||
updateBoundaryLineDimState();
|
||||
const highlightFeatures = getHighlightFeatures(found);
|
||||
const geometries = getHoverGeometries(groupKey, highlightFeatures);
|
||||
setLineGeometry(_hoverGlowLines, geometries.glow);
|
||||
@@ -496,13 +995,17 @@ export function updateCountryBoundaryHover(coords) {
|
||||
}
|
||||
}
|
||||
|
||||
return found ? makeCountryInfo(found) : null;
|
||||
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;
|
||||
@@ -519,14 +1022,33 @@ export function clearCountryBoundaryData() {
|
||||
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;
|
||||
|
||||
@@ -36,18 +36,24 @@ const _earthSunDirection = new THREE.Vector3(
|
||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.z,
|
||||
).normalize();
|
||||
|
||||
function applyEarthDayNightShader(material) {
|
||||
function applyEarthDayNightShader(material, options = {}) {
|
||||
if (!material || !EARTH_MATERIAL_CONFIG.dayNight.enabled) return;
|
||||
|
||||
const twilightColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.twilightColor);
|
||||
const nightTintColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.nightTintColor);
|
||||
|
||||
const {
|
||||
nightFloor = EARTH_MATERIAL_CONFIG.dayNight.nightFloor,
|
||||
} = options;
|
||||
material.onBeforeCompile = (shader) => {
|
||||
_earthShaders.push(shader);
|
||||
shader.uniforms.uSunDirectionWorld = { value: _earthSunDirection.clone() };
|
||||
shader.uniforms.uNightFloor = { value: EARTH_MATERIAL_CONFIG.dayNight.nightFloor };
|
||||
shader.uniforms.uNightFloor = { value: nightFloor };
|
||||
shader.uniforms.uDayBoost = { value: EARTH_MATERIAL_CONFIG.dayNight.dayBoost };
|
||||
shader.uniforms.uFeatherScale = { value: EARTH_MATERIAL_CONFIG.dayNight.featherScale };
|
||||
shader.uniforms.uTwilightWidth = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightWidth };
|
||||
shader.uniforms.uTwilightFeatherScale = {
|
||||
value: EARTH_MATERIAL_CONFIG.dayNight.twilightFeatherScale,
|
||||
};
|
||||
shader.uniforms.uTwilightIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightIntensity };
|
||||
shader.uniforms.uTwilightColor = { value: twilightColor };
|
||||
shader.uniforms.uNightTintColor = { value: nightTintColor };
|
||||
@@ -71,7 +77,9 @@ varying vec3 vWorldNormal;
|
||||
uniform vec3 uSunDirectionWorld;
|
||||
uniform float uNightFloor;
|
||||
uniform float uDayBoost;
|
||||
uniform float uFeatherScale;
|
||||
uniform float uTwilightWidth;
|
||||
uniform float uTwilightFeatherScale;
|
||||
uniform float uTwilightIntensity;
|
||||
uniform vec3 uTwilightColor;
|
||||
uniform vec3 uNightTintColor;
|
||||
@@ -83,8 +91,9 @@ uniform float uDayNightEnabled;`,
|
||||
vec3 worldNormal = normalize(vWorldNormal);
|
||||
vec3 sunDir = normalize(uSunDirectionWorld);
|
||||
float sunFacing = dot(worldNormal, sunDir);
|
||||
float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing);
|
||||
float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing));
|
||||
float edgeFeather = max(fwidth(sunFacing) * uFeatherScale, uTwilightWidth);
|
||||
float daylight = smoothstep(-edgeFeather, edgeFeather, sunFacing);
|
||||
float twilight = 1.0 - smoothstep(0.0, edgeFeather * uTwilightFeatherScale, abs(sunFacing));
|
||||
|
||||
// Camera-facing diffuse: vNormal and vViewPosition are both in view space.
|
||||
// N·V gives 1.0 at center-facing, 0 at limb — creates depth cue regardless of earth rotation.
|
||||
@@ -170,10 +179,12 @@ export function createEarth(scene) {
|
||||
);
|
||||
const occluderMaterial = new THREE.MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
depthTest: false,
|
||||
depthWrite: true,
|
||||
side: THREE.FrontSide,
|
||||
});
|
||||
const occluder = new THREE.Mesh(occluderGeometry, occluderMaterial);
|
||||
occluder.renderOrder = -1;
|
||||
occluder.renderOrder = 0.5;
|
||||
earth.add(occluder);
|
||||
|
||||
// Keep the original atmosphere shells on the legacy camera-facing shader so
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
setSurfaceTintEnabled,
|
||||
toggleCountryBoundaries,
|
||||
updateCountryBoundaryHover,
|
||||
updateCountryBoundaryTiles,
|
||||
} from "./country-boundaries.js";
|
||||
import {
|
||||
loadGeoJSONFromPath,
|
||||
@@ -4525,7 +4526,9 @@ function onMouseMove(event) {
|
||||
inertialVelocity.y = rotationDeltaY;
|
||||
inertialVelocity.x = rotationDeltaX;
|
||||
previousMousePosition = { x: event.clientX, y: event.clientY };
|
||||
clearCountryBoundaryHover();
|
||||
if (!document.body.classList.contains("layout-mode-mobile")) {
|
||||
clearCountryBoundaryHover();
|
||||
}
|
||||
hideTooltip();
|
||||
return;
|
||||
}
|
||||
@@ -5210,6 +5213,12 @@ function animate() {
|
||||
renderer,
|
||||
now: performance.now(),
|
||||
});
|
||||
updateCountryBoundaryTiles({
|
||||
camera,
|
||||
earth: getEarthSurfacePickTarget() || earth,
|
||||
renderer,
|
||||
viewZoom: getZoomLevel(),
|
||||
});
|
||||
updateNewsViewFocus(getCurrentViewCenterCoords());
|
||||
const satPositions = getSatellitePositions();
|
||||
if (
|
||||
|
||||
@@ -106,6 +106,11 @@ function mapNewsItemToCruiseEvent(item) {
|
||||
latitude,
|
||||
longitude,
|
||||
locationLabel: item.location_label || REGION_LABELS[item.region] || "全球",
|
||||
sourceLocationLabel: item.location_label || REGION_LABELS[item.region] || "全球",
|
||||
targetLocationConfidence: item.location_meta?.target?.confidence ?? null,
|
||||
targetLocationSource: item.location_source || "",
|
||||
verified: item.verified === true,
|
||||
locationMeta: item.location_meta || null,
|
||||
sortTimestamp: getItemTimestamp(item),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const NEWS_HUD_MORPH_MS = 300;
|
||||
const NEWS_HUD_MIN_WIDTH_PX = 420;
|
||||
const NEWS_HUD_MIN_HEIGHT_PX = 360;
|
||||
const NEWS_HUD_RESIZE_MARGIN_PX = 12;
|
||||
const NEWS_REALTIME_RECONNECT_MS = 5000;
|
||||
|
||||
let initialized = false;
|
||||
let refreshPromise = null;
|
||||
@@ -23,6 +24,8 @@ let lastFetchAt = 0;
|
||||
let lastRegionSwitchAt = 0;
|
||||
let selectedCruiseStoryId = null;
|
||||
let morphTimer = null;
|
||||
let newsRealtimeSocket = null;
|
||||
let newsRealtimeReconnectTimer = null;
|
||||
|
||||
function getElements() {
|
||||
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||
@@ -438,6 +441,84 @@ function renderPayload(nextPayload) {
|
||||
}));
|
||||
}
|
||||
|
||||
function getNewsRealtimeUrl() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
function clearNewsRealtimeReconnectTimer() {
|
||||
if (!newsRealtimeReconnectTimer) return;
|
||||
window.clearTimeout(newsRealtimeReconnectTimer);
|
||||
newsRealtimeReconnectTimer = null;
|
||||
}
|
||||
|
||||
function scheduleNewsRealtimeReconnect() {
|
||||
if (newsRealtimeReconnectTimer) return;
|
||||
newsRealtimeReconnectTimer = window.setTimeout(() => {
|
||||
newsRealtimeReconnectTimer = null;
|
||||
connectNewsRealtime();
|
||||
}, NEWS_REALTIME_RECONNECT_MS);
|
||||
}
|
||||
|
||||
function applyNewsRealtimePatch(updatePayload) {
|
||||
const itemId = updatePayload?.item_id;
|
||||
const patch = updatePayload?.patch;
|
||||
if (!payload || !itemId || !patch || typeof patch !== "object") return;
|
||||
const items = Array.isArray(payload.items) ? payload.items : [];
|
||||
let changed = false;
|
||||
const nextItems = items.map((item) => {
|
||||
if (item?.id !== itemId) return item;
|
||||
changed = true;
|
||||
return {
|
||||
...item,
|
||||
...patch,
|
||||
};
|
||||
});
|
||||
if (!changed) return;
|
||||
renderPayload({
|
||||
...payload,
|
||||
items: nextItems,
|
||||
});
|
||||
}
|
||||
|
||||
function connectNewsRealtime() {
|
||||
if (newsRealtimeSocket || typeof WebSocket === "undefined") return;
|
||||
const socket = new WebSocket(getNewsRealtimeUrl());
|
||||
newsRealtimeSocket = socket;
|
||||
socket.onopen = () => {
|
||||
clearNewsRealtimeReconnectTimer();
|
||||
socket.send(JSON.stringify({
|
||||
type: "subscribe",
|
||||
data: {
|
||||
channel: "earth_news",
|
||||
},
|
||||
}));
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.type === "heartbeat" && message.data?.action === "ping") {
|
||||
socket.send(JSON.stringify({ type: "heartbeat" }));
|
||||
return;
|
||||
}
|
||||
if (message.type !== "data_frame" || message.channel !== "earth_news") return;
|
||||
applyNewsRealtimePatch(message.payload);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (newsRealtimeSocket === socket) {
|
||||
newsRealtimeSocket = null;
|
||||
}
|
||||
scheduleNewsRealtimeReconnect();
|
||||
};
|
||||
socket.onerror = () => {
|
||||
socket.close();
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchNews(lat, lon) {
|
||||
const url = new URL(EARTH_NEWS_API, window.location.origin);
|
||||
if (typeof lat === "number") url.searchParams.set("lat", lat.toFixed(4));
|
||||
@@ -614,6 +695,7 @@ export function initNewsPanel() {
|
||||
});
|
||||
hudCloseBtn?.addEventListener("click", closeNewsHud);
|
||||
setupNewsHudResize();
|
||||
connectNewsRealtime();
|
||||
|
||||
["news-refresh", "mobile-news-refresh"].forEach((id) => {
|
||||
const refreshBtn = document.getElementById(id);
|
||||
|
||||
@@ -303,7 +303,7 @@ export default function AISettings() {
|
||||
nextSecret === savedPreview ||
|
||||
nextSecret === revealedSecret ||
|
||||
nextSecret.startsWith('••••') ||
|
||||
nextSecret.includes('*')
|
||||
Array.from(nextSecret).every((char) => char === '*' || char === '•' || /\s/.test(char))
|
||||
)
|
||||
|
||||
const buildAiProviderDraftPayload = (values: any) => {
|
||||
@@ -381,9 +381,11 @@ export default function AISettings() {
|
||||
setFeedback({ type: 'success', message: 'AI 配置已保存' })
|
||||
message.success('AI 配置已保存')
|
||||
await fetchAISettings()
|
||||
} catch {
|
||||
setFeedback({ type: 'error', message: 'AI 配置保存失败' })
|
||||
message.error('AI 配置保存失败')
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||||
const errorMessage = err.response?.data?.message || err.response?.data?.detail || 'AI 配置保存失败'
|
||||
setFeedback({ type: 'error', message: errorMessage })
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -591,12 +593,8 @@ export default function AISettings() {
|
||||
buildAiProviderDraftPayload(values),
|
||||
)
|
||||
if (response.data.success || response.data.connected) {
|
||||
if (response.data.integrations) {
|
||||
setIntegrations(response.data.integrations)
|
||||
await fetchAISettings()
|
||||
}
|
||||
setFeedback({ type: 'success', message: response.data.message || 'AI Provider 连接成功' })
|
||||
message.success(response.data.message || 'AI Provider 连接成功')
|
||||
setFeedback({ type: 'success', message: response.data.message || '连接测试通过' })
|
||||
message.success(response.data.message || '连接测试通过')
|
||||
} else {
|
||||
setFeedback({ type: 'error', message: response.data.message || 'AI Provider 连接失败' })
|
||||
message.error(response.data.message || 'AI Provider 连接失败')
|
||||
@@ -680,6 +678,9 @@ export default function AISettings() {
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div style={{ marginTop: -16, marginBottom: 12 }}>
|
||||
<Text type="secondary">测试不保存,保存才生效。</Text>
|
||||
</div>
|
||||
<Form.Item name={['ai_provider', 'provider_api']} label="协议适配">
|
||||
<Select>
|
||||
<Select.Option value="openai-completions">OpenAI Chat Completions</Select.Option>
|
||||
@@ -819,6 +820,9 @@ export default function AISettings() {
|
||||
onTest={() => { void testWebSearchConnection() }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div style={{ marginTop: -16, marginBottom: 12 }}>
|
||||
<Text type="secondary">测试不保存,保存才生效。</Text>
|
||||
</div>
|
||||
<Form.Item label="WebSearch API Key">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
|
||||
@@ -38,6 +38,8 @@ import { formatPhaseMetric, getPhaseDisplay, getPhaseSummary } from '../../utils
|
||||
|
||||
const { Text } = Typography
|
||||
const COLLECTION_REFRESH_DELAY_MS = 800
|
||||
const COLLECTION_STATUS_POLL_MS = 900
|
||||
const COLLECTION_STATUS_MAX_POLLS = 30
|
||||
|
||||
interface BuiltInDataSource {
|
||||
id: number
|
||||
@@ -217,6 +219,7 @@ type DatasourceTaskStatus = {
|
||||
records_processed?: number | null
|
||||
total_records?: number | null
|
||||
status?: string | null
|
||||
error_message?: string | null
|
||||
}
|
||||
|
||||
type ActiveFilter = 'enabled' | 'disabled' | 'all'
|
||||
@@ -228,6 +231,7 @@ const PRODUCT_LABELS: Record<string, string> = {
|
||||
cables: '海底光缆',
|
||||
satellites: '卫星',
|
||||
bgp: 'BGP',
|
||||
earth: 'Earth',
|
||||
compute: '算力',
|
||||
ai: 'AI',
|
||||
media: '媒体',
|
||||
@@ -239,6 +243,7 @@ const PRODUCT_TAG_COLORS: Record<string, string> = {
|
||||
cables: 'geekblue',
|
||||
satellites: 'purple',
|
||||
bgp: 'volcano',
|
||||
earth: 'lime',
|
||||
compute: 'blue',
|
||||
ai: 'magenta',
|
||||
media: 'green',
|
||||
@@ -412,11 +417,44 @@ function DataSources() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDatasourceTaskStatus = async (id: number) => {
|
||||
const res = await axios.get<DatasourceTaskStatus>(`/api/v1/datasources/${id}/task-status`)
|
||||
const fetchDatasourceTaskStatus = async (id: number, taskId?: number | null) => {
|
||||
const res = await axios.get<DatasourceTaskStatus>(`/api/v1/datasources/${id}/task-status`, {
|
||||
params: taskId ? { task_id: taskId } : undefined,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
const pollDatasourceAfterTrigger = (id: number, taskId?: number | null) => {
|
||||
let polls = 0
|
||||
let lastTaskId = taskId ?? null
|
||||
|
||||
const poll = async () => {
|
||||
polls += 1
|
||||
try {
|
||||
const status = await fetchDatasourceTaskStatus(id, lastTaskId)
|
||||
lastTaskId = status.task_id ?? lastTaskId
|
||||
await fetchData()
|
||||
|
||||
if (status.status === 'failed') {
|
||||
messageApi.error(status.error_message || status.phase_message || '采集失败')
|
||||
return
|
||||
}
|
||||
if (status.status && status.status !== 'running' && status.status !== 'idle') {
|
||||
return
|
||||
}
|
||||
if (polls < COLLECTION_STATUS_MAX_POLLS) {
|
||||
window.setTimeout(poll, COLLECTION_STATUS_POLL_MS)
|
||||
}
|
||||
} catch (error) {
|
||||
if (polls < COLLECTION_STATUS_MAX_POLLS) {
|
||||
window.setTimeout(poll, COLLECTION_STATUS_POLL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.setTimeout(poll, COLLECTION_REFRESH_DELAY_MS)
|
||||
}
|
||||
|
||||
const triggerDatasource = async (id: number, options?: { force?: boolean }) => {
|
||||
const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, {
|
||||
params: { force: options?.force ?? false },
|
||||
@@ -427,11 +465,8 @@ function DataSources() {
|
||||
return { ok: false, status: res.status, detail: res.data?.detail } satisfies TriggerDatasourceResult
|
||||
}
|
||||
|
||||
if (res.data.task_id) {
|
||||
void fetchData()
|
||||
} else {
|
||||
window.setTimeout(fetchData, COLLECTION_REFRESH_DELAY_MS)
|
||||
}
|
||||
void fetchData()
|
||||
pollDatasourceAfterTrigger(id, res.data.task_id)
|
||||
return { ok: true, response: res } satisfies TriggerDatasourceResult
|
||||
}
|
||||
|
||||
@@ -916,6 +951,7 @@ function DataSources() {
|
||||
{ value: 'cables', label: '海底光缆' },
|
||||
{ value: 'satellites', label: '卫星' },
|
||||
{ value: 'bgp', label: 'BGP' },
|
||||
{ value: 'earth', label: 'Earth' },
|
||||
{ value: 'compute', label: '算力' },
|
||||
{ value: 'ai', label: 'AI' },
|
||||
{ value: 'media', label: '媒体' },
|
||||
|
||||
@@ -417,6 +417,11 @@ function Settings() {
|
||||
const collectorOptions = useMemo(() => [...collectors, ...customCollectors], [collectors, customCollectors])
|
||||
const selectedCollector = collectorOptions.find((collector) => collector.source === selectedCollectorSource)
|
||||
const selectedCollectorConfig = [...collectorConfigs, ...customSourceConfigs].find((config) => config.name === selectedCollectorSource)
|
||||
const isEarthBoundarySourceCollector = Boolean(
|
||||
selectedCollector?.source &&
|
||||
['earth_admin0_boundaries', 'earth_coastline', 'earth_claim_lines'].includes(selectedCollector.source),
|
||||
)
|
||||
const isEarthPmtilesBuilder = selectedCollector?.source === 'earth_boundary_tiles'
|
||||
const [customStreamStatus, setCustomStreamStatus] = useState<{ running: boolean; done: boolean } | null>(null)
|
||||
const [customStreamBusy, setCustomStreamBusy] = useState(false)
|
||||
const selectedCollectorHealth = selectedCollector
|
||||
@@ -561,7 +566,11 @@ function Settings() {
|
||||
}, [integrationForm, integrations, loading])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !selectedCollectorConfig) return
|
||||
if (loading) return
|
||||
if (!selectedCollectorConfig) {
|
||||
collectorConfigForm.resetFields()
|
||||
return
|
||||
}
|
||||
const config = selectedCollectorConfig.config || {}
|
||||
const boundingBoxes = config.bounding_boxes ?? [[[-90, -180], [90, 180]]]
|
||||
if (selectedCollector?.is_custom) {
|
||||
@@ -591,6 +600,10 @@ function Settings() {
|
||||
config: {
|
||||
timeout: config.timeout ?? 30,
|
||||
retry: config.retry ?? 3,
|
||||
method: config.method || 'GET',
|
||||
target_schema: config.target_schema || (isEarthBoundarySourceCollector ? 'earth_boundary_source' : undefined),
|
||||
license: config.license || '',
|
||||
mapping_json_text: JSON.stringify(config.mapping_json || {}, null, 2),
|
||||
max_messages: config.max_messages ?? 500,
|
||||
receive_timeout_seconds: config.receive_timeout_seconds ?? 30,
|
||||
message_types: config.message_types ?? ['PositionReport', 'ShipStaticData'],
|
||||
@@ -598,7 +611,7 @@ function Settings() {
|
||||
bounding_boxes_json: stringifyBoundingBoxes(boundingBoxes),
|
||||
},
|
||||
})
|
||||
}, [collectorConfigForm, loading, selectedCollector, selectedCollectorConfig])
|
||||
}, [collectorConfigForm, isEarthBoundarySourceCollector, loading, selectedCollector, selectedCollectorConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedCollector || !collectorOptions.some((collector) => collector.source === requestedCollector)) return
|
||||
@@ -706,10 +719,12 @@ function Settings() {
|
||||
setWebSearchSaveFeedback({ type: 'success', message: 'WebSearch 配置已保存' })
|
||||
message.success('外部集成配置已保存')
|
||||
await fetchSettings()
|
||||
} catch {
|
||||
setAiProviderSaveFeedback({ type: 'error', message: 'AI 配置保存失败' })
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||||
const errorMessage = err.response?.data?.message || err.response?.data?.detail || '外部集成配置保存失败'
|
||||
setAiProviderSaveFeedback({ type: 'error', message: errorMessage })
|
||||
setWebSearchSaveFeedback({ type: 'error', message: 'WebSearch 配置保存失败' })
|
||||
message.error('外部集成配置保存失败')
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
setSavingIntegrations(false)
|
||||
}
|
||||
@@ -724,7 +739,7 @@ function Settings() {
|
||||
nextSecret === savedPreview ||
|
||||
nextSecret === revealedSecret ||
|
||||
nextSecret.startsWith('••••') ||
|
||||
nextSecret.includes('*')
|
||||
Array.from(nextSecret).every((char) => char === '*' || char === '•' || /\s/.test(char))
|
||||
)
|
||||
|
||||
const buildAiProviderDraftPayload = (values: any) => {
|
||||
@@ -899,31 +914,8 @@ function Settings() {
|
||||
buildAiProviderDraftPayload(values),
|
||||
)
|
||||
if (response.data.success || response.data.connected) {
|
||||
if (response.data.integrations) {
|
||||
setIntegrations(response.data.integrations)
|
||||
integrationForm.setFieldsValue({
|
||||
ai_provider: {
|
||||
service_url: response.data.integrations.ai_provider.service_url,
|
||||
service_token: response.data.integrations.ai_provider.service_token.configured
|
||||
? response.data.integrations.ai_provider.service_token.preview
|
||||
: '',
|
||||
default_provider: response.data.integrations.ai_provider.default_provider || response.data.integrations.ai_provider.provider,
|
||||
provider: response.data.integrations.ai_provider.provider,
|
||||
provider_api: response.data.integrations.ai_provider.provider_api,
|
||||
base_url: response.data.integrations.ai_provider.base_url,
|
||||
model: response.data.integrations.ai_provider.model,
|
||||
api_key: response.data.integrations.ai_provider.api_key.configured
|
||||
? response.data.integrations.ai_provider.api_key.preview
|
||||
: '',
|
||||
max_tokens: response.data.integrations.ai_provider.max_tokens,
|
||||
anthropic_version: response.data.integrations.ai_provider.anthropic_version,
|
||||
timeout_seconds: response.data.integrations.ai_provider.timeout_seconds,
|
||||
retry_attempts: response.data.integrations.ai_provider.retry_attempts,
|
||||
},
|
||||
})
|
||||
}
|
||||
setAiProviderSaveFeedback({ type: 'success', message: response.data.message || 'AI Provider 连接成功,已保存为全局默认配置' })
|
||||
message.success(response.data.message || 'AI Provider 连接成功')
|
||||
setAiProviderSaveFeedback({ type: 'success', message: response.data.message || '连接测试通过' })
|
||||
message.success(response.data.message || '连接测试通过')
|
||||
} else {
|
||||
setAiProviderSaveFeedback({ type: 'error', message: response.data.message || 'AI Provider 连接失败' })
|
||||
message.error(response.data.message || 'AI Provider 连接失败')
|
||||
@@ -996,12 +988,23 @@ function Settings() {
|
||||
delete configValues.bounding_boxes_json
|
||||
delete configValues.bounding_box_preset
|
||||
}
|
||||
if (['earth_admin0_boundaries', 'earth_coastline', 'earth_claim_lines'].includes(selectedCollector.source)) {
|
||||
try {
|
||||
configValues.mapping_json = JSON.parse(configValues.mapping_json_text || '{}')
|
||||
} catch {
|
||||
message.error('Earth 边界 mapping_json 必须是合法 JSON')
|
||||
return
|
||||
}
|
||||
configValues.target_schema = configValues.target_schema || 'earth_boundary_source'
|
||||
configValues.method = configValues.method || 'GET'
|
||||
delete configValues.mapping_json_text
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: selectedCollector.source,
|
||||
description: `内置采集器覆盖配置:${selectedCollector.name}`,
|
||||
source_type: selectedCollector.source === 'aisstream_vessels' ? 'websocket' : 'http',
|
||||
endpoint: baseValues.endpoint,
|
||||
source_type: selectedCollector.source === 'aisstream_vessels' ? 'websocket' : isEarthPmtilesBuilder ? 'internal' : 'http',
|
||||
endpoint: baseValues.endpoint || '',
|
||||
auth_type: selectedCollector.source === 'aisstream_vessels' ? 'api_key' : 'none',
|
||||
headers,
|
||||
config: configValues,
|
||||
@@ -2026,6 +2029,9 @@ function Settings() {
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div style={{ marginTop: -16, marginBottom: 12 }}>
|
||||
<Text type="secondary">测试不保存,保存才生效。</Text>
|
||||
</div>
|
||||
|
||||
<Form.Item name={['ai_provider', 'provider_api']} label="协议适配">
|
||||
<Select>
|
||||
@@ -2151,6 +2157,9 @@ function Settings() {
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ marginTop: -16, marginBottom: 12 }}>
|
||||
<Text type="secondary">测试不保存,保存才生效。</Text>
|
||||
</div>
|
||||
|
||||
<Form.Item label="WebSearch API Key">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
@@ -2481,14 +2490,39 @@ function Settings() {
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
<Form.Item name="endpoint" label="Endpoint" rules={[{ required: true, message: '请输入 Endpoint' }]}>
|
||||
<Input placeholder={selectedCollectorConfig?.default_url || 'https://api.example.com'} />
|
||||
<Form.Item
|
||||
name="endpoint"
|
||||
label="Endpoint"
|
||||
rules={isEarthPmtilesBuilder ? [] : [{ required: true, message: '请输入 Endpoint' }]}
|
||||
>
|
||||
<Input
|
||||
disabled={isEarthPmtilesBuilder}
|
||||
placeholder={isEarthPmtilesBuilder ? '内部构建器不需要 Endpoint' : selectedCollectorConfig?.default_url || 'https://api.example.com'}
|
||||
/>
|
||||
</Form.Item>
|
||||
{!selectedCollector?.is_custom ? (
|
||||
<Form.Item label="默认 Endpoint">
|
||||
<Input value={selectedCollectorConfig?.default_url || '-'} disabled />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isEarthBoundarySourceCollector ? (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||
<Form.Item name={['config', 'method']} label="请求方法">
|
||||
<Select options={[{ value: 'GET', label: 'GET' }, { value: 'POST', label: 'POST' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['config', 'target_schema']} label="目标 Schema">
|
||||
<Select options={[{ value: 'earth_boundary_source', label: 'earth_boundary_source' }]} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name={['config', 'license']} label="License">
|
||||
<Input placeholder="例如 ODbL / source license / internal" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['config', 'mapping_json_text']} label="Mapping JSON">
|
||||
<Input.TextArea rows={8} />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
<Form.List name="headers">
|
||||
{(fields, { add, remove }) => (
|
||||
<Form.Item label="请求头">
|
||||
|
||||
Reference in New Issue
Block a user