release: bump version to 0.68.0
This commit is contained in:
@@ -19,7 +19,6 @@ from app.models.datasource_config import DataSourceConfig
|
|||||||
from app.models.task import CollectionTask
|
from app.models.task import CollectionTask
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.vessel import AISRawObservation
|
from app.models.vessel import AISRawObservation
|
||||||
from app.services.vessel_ais_aggregation import VESSEL_AIS_SCHEMA
|
|
||||||
from app.services.scheduler import (
|
from app.services.scheduler import (
|
||||||
sync_datasource_job,
|
sync_datasource_job,
|
||||||
)
|
)
|
||||||
@@ -165,6 +164,8 @@ async def _load_latest_tasks(
|
|||||||
async def _load_collected_record_counts(
|
async def _load_collected_record_counts(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
sources: list[str],
|
sources: list[str],
|
||||||
|
*,
|
||||||
|
exact_vessel_counts: bool = False,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
if not sources:
|
if not sources:
|
||||||
return {}
|
return {}
|
||||||
@@ -185,14 +186,46 @@ async def _load_collected_record_counts(
|
|||||||
or "ais" in source
|
or "ais" in source
|
||||||
]
|
]
|
||||||
if vessel_sources:
|
if vessel_sources:
|
||||||
raw_result = await db.execute(
|
if exact_vessel_counts:
|
||||||
|
exact_result = await db.execute(
|
||||||
select(AISRawObservation.source, func.count(AISRawObservation.id))
|
select(AISRawObservation.source, func.count(AISRawObservation.id))
|
||||||
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
|
|
||||||
.where(AISRawObservation.source.in_(vessel_sources))
|
.where(AISRawObservation.source.in_(vessel_sources))
|
||||||
.group_by(AISRawObservation.source)
|
.group_by(AISRawObservation.source)
|
||||||
)
|
)
|
||||||
for source, count in raw_result.all():
|
for source, count in exact_result.all():
|
||||||
counts[source] = max(counts.get(source, 0), int(count or 0))
|
counts[source] = max(counts.get(source, 0), int(count or 0))
|
||||||
|
return counts
|
||||||
|
|
||||||
|
# AIS raw observations can be tens of millions of rows. Use planner
|
||||||
|
# statistics for the datasource list instead of blocking page load on
|
||||||
|
# source-level count(*) scans.
|
||||||
|
stats_result = await db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(pg_class.reltuples, 0)::bigint AS total_rows,
|
||||||
|
pg_stats.most_common_vals::text AS source_values,
|
||||||
|
pg_stats.most_common_freqs::text AS source_freqs
|
||||||
|
FROM pg_class
|
||||||
|
LEFT JOIN pg_stats
|
||||||
|
ON pg_stats.schemaname = 'public'
|
||||||
|
AND pg_stats.tablename = 'ais_raw_observations'
|
||||||
|
AND pg_stats.attname = 'source'
|
||||||
|
WHERE pg_class.relname = 'ais_raw_observations'
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats = stats_result.mappings().first()
|
||||||
|
if stats:
|
||||||
|
total_rows = int(stats["total_rows"] or 0)
|
||||||
|
values = str(stats["source_values"] or "").strip("{}")
|
||||||
|
freqs = str(stats["source_freqs"] or "").strip("{}")
|
||||||
|
source_values = [value.strip('"') for value in values.split(",") if value]
|
||||||
|
source_freqs = [float(value) for value in freqs.split(",") if value]
|
||||||
|
for source, freq in zip(source_values, source_freqs):
|
||||||
|
if source in vessel_sources:
|
||||||
|
counts[source] = max(counts.get(source, 0), int(round(total_rows * freq)))
|
||||||
|
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
@@ -929,7 +962,7 @@ async def get_datasource_row(
|
|||||||
[datasource],
|
[datasource],
|
||||||
include_endpoint=include_endpoint,
|
include_endpoint=include_endpoint,
|
||||||
)
|
)
|
||||||
record_counts = await _load_collected_record_counts(db, [datasource.source])
|
record_counts = await _load_collected_record_counts(db, [datasource.source], exact_vessel_counts=True)
|
||||||
return {
|
return {
|
||||||
"data": serialize_datasource_row(
|
"data": serialize_datasource_row(
|
||||||
datasource,
|
datasource,
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ async def list_tasks(
|
|||||||
SELECT ct.id, ct.datasource_id, ds.name as datasource_name, ct.status,
|
SELECT ct.id, ct.datasource_id, ds.name as datasource_name, ct.status,
|
||||||
ct.started_at, ct.completed_at, ct.records_processed, ct.error_message,
|
ct.started_at, ct.completed_at, ct.records_processed, ct.error_message,
|
||||||
ct.phase, ct.phase_progress, ct.phase_message, ct.phase_current,
|
ct.phase, ct.phase_progress, ct.phase_message, ct.phase_current,
|
||||||
ct.phase_total, ct.phase_unit, ct.total_records, ct.progress
|
ct.phase_total, ct.phase_unit, ct.total_records, ct.progress,
|
||||||
|
ct.task_type, ct.source, ds.source as datasource_source
|
||||||
FROM collection_tasks ct
|
FROM collection_tasks ct
|
||||||
JOIN data_sources ds ON ct.datasource_id = ds.id
|
JOIN data_sources ds ON ct.datasource_id = ds.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
@@ -39,12 +40,19 @@ async def list_tasks(
|
|||||||
|
|
||||||
if datasource_id:
|
if datasource_id:
|
||||||
query += " AND ct.datasource_id = :datasource_id"
|
query += " AND ct.datasource_id = :datasource_id"
|
||||||
count_query += " WHERE ct.datasource_id = :datasource_id"
|
count_query += " AND ct.datasource_id = :datasource_id"
|
||||||
params["datasource_id"] = datasource_id
|
params["datasource_id"] = datasource_id
|
||||||
if status:
|
if status:
|
||||||
|
statuses = [item.strip() for item in status.split(",") if item.strip()]
|
||||||
|
if len(statuses) > 1:
|
||||||
|
placeholders = ", ".join(f":status_{index}" for index, _item in enumerate(statuses))
|
||||||
|
query += f" AND ct.status IN ({placeholders})"
|
||||||
|
count_query += f" AND ct.status IN ({placeholders})"
|
||||||
|
params.update({f"status_{index}": item for index, item in enumerate(statuses)})
|
||||||
|
else:
|
||||||
query += " AND ct.status = :status"
|
query += " AND ct.status = :status"
|
||||||
count_query += " AND ct.status = :status"
|
count_query += " AND ct.status = :status"
|
||||||
params["status"] = status
|
params["status"] = statuses[0] if statuses else status
|
||||||
|
|
||||||
query += f" ORDER BY ct.created_at DESC LIMIT {page_size} OFFSET {offset}"
|
query += f" ORDER BY ct.created_at DESC LIMIT {page_size} OFFSET {offset}"
|
||||||
|
|
||||||
@@ -76,6 +84,9 @@ async def list_tasks(
|
|||||||
"phase_unit": t[13],
|
"phase_unit": t[13],
|
||||||
"total_records": t[14],
|
"total_records": t[14],
|
||||||
"progress": t[15],
|
"progress": t[15],
|
||||||
|
"task_type": t[16],
|
||||||
|
"source": t[17] or t[18],
|
||||||
|
"datasource_source": t[18],
|
||||||
}
|
}
|
||||||
for t in tasks
|
for t in tasks
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -322,6 +322,21 @@ class AISStreamCollector(BaseCollector):
|
|||||||
last_success_at=now if data else None,
|
last_success_at=now if data else None,
|
||||||
lag_seconds=max((now - latest_observed_at).total_seconds(), 0),
|
lag_seconds=max((now - latest_observed_at).total_seconds(), 0),
|
||||||
)
|
)
|
||||||
|
if snapshot_id is not None:
|
||||||
|
from app.models.data_snapshot import DataSnapshot
|
||||||
|
|
||||||
|
snapshot = await db.get(DataSnapshot, snapshot_id)
|
||||||
|
if snapshot:
|
||||||
|
snapshot.record_count = records_added
|
||||||
|
snapshot.status = "success"
|
||||||
|
snapshot.completed_at = now
|
||||||
|
snapshot.summary = {
|
||||||
|
"created": records_added,
|
||||||
|
"updated": 0,
|
||||||
|
"unchanged": 0,
|
||||||
|
"deleted": 0,
|
||||||
|
"storage": "ais_raw_observations",
|
||||||
|
}
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await self.update_progress(records_added, force=True)
|
await self.update_progress(records_added, force=True)
|
||||||
return records_added
|
return records_added
|
||||||
|
|||||||
@@ -119,6 +119,21 @@ class VesselAISCollector(BaseCollector):
|
|||||||
last_success_at=now if data else None,
|
last_success_at=now if data else None,
|
||||||
lag_seconds=max((now - latest_observed_at).total_seconds(), 0),
|
lag_seconds=max((now - latest_observed_at).total_seconds(), 0),
|
||||||
)
|
)
|
||||||
|
if snapshot_id is not None:
|
||||||
|
from app.models.data_snapshot import DataSnapshot
|
||||||
|
|
||||||
|
snapshot = await db.get(DataSnapshot, snapshot_id)
|
||||||
|
if snapshot:
|
||||||
|
snapshot.record_count = records_added
|
||||||
|
snapshot.status = "success"
|
||||||
|
snapshot.completed_at = now
|
||||||
|
snapshot.summary = {
|
||||||
|
"created": records_added,
|
||||||
|
"updated": 0,
|
||||||
|
"unchanged": 0,
|
||||||
|
"deleted": 0,
|
||||||
|
"storage": "ais_raw_observations",
|
||||||
|
}
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await self._broadcast_vessel_snapshot(data)
|
await self._broadcast_vessel_snapshot(data)
|
||||||
await self.update_progress(records_added, force=True)
|
await self.update_progress(records_added, force=True)
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ DATA_WRITE_JOB_TYPES = (JOB_TYPE_COLLECT, JOB_TYPE_CLEAR_DATA, JOB_TYPE_CLEAR_CA
|
|||||||
SOURCE_LOCK_JOB_STATUSES = (JOB_STATUS_RUNNING, JOB_STATUS_CANCELLING)
|
SOURCE_LOCK_JOB_STATUSES = (JOB_STATUS_RUNNING, JOB_STATUS_CANCELLING)
|
||||||
QUEUE_POLL_SECONDS = 0.35
|
QUEUE_POLL_SECONDS = 0.35
|
||||||
JOB_STALE_LOCK_MINUTES = 90
|
JOB_STALE_LOCK_MINUTES = 90
|
||||||
|
ORPHAN_CANCELLING_GRACE_SECONDS = 30
|
||||||
|
JOB_RECOVERY_SWEEP_SECONDS = 15
|
||||||
|
DATA_DELETE_BATCH_SIZE = 50_000
|
||||||
DEFAULT_WORKER_CONCURRENCY = 2
|
DEFAULT_WORKER_CONCURRENCY = 2
|
||||||
|
|
||||||
RUNNING_DATA_JOB_TASKS: dict[int, asyncio.Task[Any]] = {}
|
RUNNING_DATA_JOB_TASKS: dict[int, asyncio.Task[Any]] = {}
|
||||||
@@ -281,6 +284,7 @@ class DataJobWorker:
|
|||||||
self._task: asyncio.Task[None] | None = None
|
self._task: asyncio.Task[None] | None = None
|
||||||
self._stop_event: asyncio.Event | None = None
|
self._stop_event: asyncio.Event | None = None
|
||||||
self._running: set[asyncio.Task[Any]] = set()
|
self._running: set[asyncio.Task[Any]] = set()
|
||||||
|
self._last_recovery_sweep_at: datetime | None = None
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if self._task and not self._task.done():
|
if self._task and not self._task.done():
|
||||||
@@ -303,6 +307,11 @@ class DataJobWorker:
|
|||||||
await self._recover_stale_running_jobs()
|
await self._recover_stale_running_jobs()
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
self._running = {task for task in self._running if not task.done()}
|
self._running = {task for task in self._running if not task.done()}
|
||||||
|
if (
|
||||||
|
self._last_recovery_sweep_at is None
|
||||||
|
or (_utcnow() - self._last_recovery_sweep_at).total_seconds() >= JOB_RECOVERY_SWEEP_SECONDS
|
||||||
|
):
|
||||||
|
await self._recover_stale_running_jobs()
|
||||||
if len(self._running) >= self.concurrency:
|
if len(self._running) >= self.concurrency:
|
||||||
await asyncio.sleep(QUEUE_POLL_SECONDS)
|
await asyncio.sleep(QUEUE_POLL_SECONDS)
|
||||||
continue
|
continue
|
||||||
@@ -316,7 +325,9 @@ class DataJobWorker:
|
|||||||
self._running.add(runner)
|
self._running.add(runner)
|
||||||
|
|
||||||
async def _recover_stale_running_jobs(self) -> None:
|
async def _recover_stale_running_jobs(self) -> None:
|
||||||
|
self._last_recovery_sweep_at = _utcnow()
|
||||||
cutoff = _utcnow() - timedelta(minutes=JOB_STALE_LOCK_MINUTES)
|
cutoff = _utcnow() - timedelta(minutes=JOB_STALE_LOCK_MINUTES)
|
||||||
|
orphan_cancelling_cutoff = _utcnow() - timedelta(seconds=ORPHAN_CANCELLING_GRACE_SECONDS)
|
||||||
async with async_session_factory() as db:
|
async with async_session_factory() as db:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(CollectionTask)
|
select(CollectionTask)
|
||||||
@@ -332,6 +343,21 @@ class DataJobWorker:
|
|||||||
job.error_message = "Marked failed after stale data job lock timeout"
|
job.error_message = "Marked failed after stale data job lock timeout"
|
||||||
if stale_jobs:
|
if stale_jobs:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
orphan_result = await db.execute(
|
||||||
|
select(CollectionTask)
|
||||||
|
.where(CollectionTask.status == JOB_STATUS_CANCELLING)
|
||||||
|
.where(CollectionTask.locked_at.is_(None))
|
||||||
|
.where(CollectionTask.requested_cancel_at.is_not(None))
|
||||||
|
.where(CollectionTask.requested_cancel_at < orphan_cancelling_cutoff)
|
||||||
|
)
|
||||||
|
for job in orphan_result.scalars().all():
|
||||||
|
if job.id in RUNNING_DATA_JOB_TASKS:
|
||||||
|
continue
|
||||||
|
await _cancel_task_without_runner(
|
||||||
|
db,
|
||||||
|
job,
|
||||||
|
reason=job.cancel_reason or "cancelled_after_orphaned_runner",
|
||||||
|
)
|
||||||
|
|
||||||
async def _claim_next_job(self) -> int | None:
|
async def _claim_next_job(self) -> int | None:
|
||||||
async with async_session_factory() as db:
|
async with async_session_factory() as db:
|
||||||
@@ -477,15 +503,22 @@ async def _run_clear_data_job(db: AsyncSession, task: CollectionTask) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await _broadcast_task_update(task)
|
await _broadcast_task_update(task)
|
||||||
|
|
||||||
count_result = await db.execute(
|
deleted_count = await _delete_table_rows_by_source(
|
||||||
select(CollectedData.id).where(CollectedData.source == source)
|
db,
|
||||||
|
task,
|
||||||
|
table_name="collected_data",
|
||||||
|
source_column="source",
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
derived_deleted_counts = await _clear_derived_datasource_data_in_batches(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
source,
|
||||||
|
progress_offset=deleted_count,
|
||||||
)
|
)
|
||||||
collected_ids = [row[0] for row in count_result.all()]
|
|
||||||
derived_deleted_counts = await clear_derived_datasource_data(db, source)
|
|
||||||
if collected_ids:
|
|
||||||
await db.execute(CollectedData.__table__.delete().where(CollectedData.id.in_(collected_ids)))
|
|
||||||
deleted_count = len(collected_ids)
|
|
||||||
derived_deleted_count = sum(derived_deleted_counts.values())
|
derived_deleted_count = sum(derived_deleted_counts.values())
|
||||||
|
if any(key.startswith("ais_") for key in derived_deleted_counts):
|
||||||
|
await db.execute(text("ANALYZE ais_raw_observations"))
|
||||||
|
|
||||||
task.records_processed = deleted_count + derived_deleted_count
|
task.records_processed = deleted_count + derived_deleted_count
|
||||||
task.total_records = task.records_processed
|
task.total_records = task.records_processed
|
||||||
@@ -504,10 +537,99 @@ async def _run_clear_data_job(db: AsyncSession, task: CollectionTask) -> None:
|
|||||||
task.phase = "completed"
|
task.phase = "completed"
|
||||||
task.phase_message = "数据库数据已清理"
|
task.phase_message = "数据库数据已清理"
|
||||||
task.completed_at = _utcnow()
|
task.completed_at = _utcnow()
|
||||||
|
datasource = await db.get(DataSource, task.datasource_id)
|
||||||
|
if datasource is not None:
|
||||||
|
datasource.last_status = JOB_STATUS_SUCCESS
|
||||||
|
datasource.last_run_at = task.completed_at
|
||||||
|
await db.execute(
|
||||||
|
DataSnapshot.__table__.update()
|
||||||
|
.where(DataSnapshot.source == source)
|
||||||
|
.values(is_current=False)
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await _broadcast_task_update(task)
|
await _broadcast_task_update(task)
|
||||||
|
|
||||||
|
|
||||||
|
async def _delete_table_rows_by_source(
|
||||||
|
db: AsyncSession,
|
||||||
|
task: CollectionTask,
|
||||||
|
*,
|
||||||
|
table_name: str,
|
||||||
|
source_column: str,
|
||||||
|
source: str,
|
||||||
|
progress_offset: int = 0,
|
||||||
|
) -> int:
|
||||||
|
deleted = 0
|
||||||
|
while True:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
WITH doomed AS (
|
||||||
|
SELECT ctid
|
||||||
|
FROM {table_name}
|
||||||
|
WHERE {source_column} = :source
|
||||||
|
LIMIT :batch_size
|
||||||
|
),
|
||||||
|
deleted_rows AS (
|
||||||
|
DELETE FROM {table_name}
|
||||||
|
USING doomed
|
||||||
|
WHERE {table_name}.ctid = doomed.ctid
|
||||||
|
RETURNING 1
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) FROM deleted_rows
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"source": source, "batch_size": DATA_DELETE_BATCH_SIZE},
|
||||||
|
)
|
||||||
|
batch_deleted = max(int(result.scalar_one() or 0), 0)
|
||||||
|
if batch_deleted <= 0:
|
||||||
|
break
|
||||||
|
deleted += batch_deleted
|
||||||
|
task.records_processed = progress_offset + deleted
|
||||||
|
task.phase_current = task.records_processed
|
||||||
|
task.phase_unit = "records"
|
||||||
|
task.phase_message = f"正在删除数据:{task.records_processed} 条"
|
||||||
|
await db.commit()
|
||||||
|
await _broadcast_task_update(task)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def _clear_derived_datasource_data_in_batches(
|
||||||
|
db: AsyncSession,
|
||||||
|
task: CollectionTask,
|
||||||
|
source: str,
|
||||||
|
progress_offset: int = 0,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
deleted_counts: dict[str, int] = {}
|
||||||
|
if source in {"barentswatch_vessels", "aisstream_vessels"}:
|
||||||
|
deleted_counts["ais_conflict_records"] = await _delete_table_rows_by_source(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
table_name="ais_conflict_records",
|
||||||
|
source_column="selected_source",
|
||||||
|
source=source,
|
||||||
|
progress_offset=progress_offset + sum(deleted_counts.values()),
|
||||||
|
)
|
||||||
|
deleted_counts["ais_source_health"] = await _delete_table_rows_by_source(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
table_name="ais_source_health",
|
||||||
|
source_column="source",
|
||||||
|
source=source,
|
||||||
|
progress_offset=progress_offset + sum(deleted_counts.values()),
|
||||||
|
)
|
||||||
|
deleted_counts["ais_raw_observations"] = await _delete_table_rows_by_source(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
table_name="ais_raw_observations",
|
||||||
|
source_column="source",
|
||||||
|
source=source,
|
||||||
|
progress_offset=progress_offset + sum(deleted_counts.values()),
|
||||||
|
)
|
||||||
|
return deleted_counts
|
||||||
|
return await clear_derived_datasource_data(db, source)
|
||||||
|
|
||||||
|
|
||||||
async def _run_clear_cache_job(db: AsyncSession, task: CollectionTask) -> None:
|
async def _run_clear_cache_job(db: AsyncSession, task: CollectionTask) -> None:
|
||||||
source = str(task.source or (task.payload or {}).get("source") or "").strip()
|
source = str(task.source or (task.payload or {}).get("source") or "").strip()
|
||||||
if not source:
|
if not source:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ EARTH_LAYER_ADAPTERS: tuple[EarthLayerAdapter, ...] = (
|
|||||||
tables=frozenset({"vessel_static", "vessel_position", "ais_raw_observations", "ais_source_health"}),
|
tables=frozenset({"vessel_static", "vessel_position", "ais_raw_observations", "ais_source_health"}),
|
||||||
layers=("vessels",),
|
layers=("vessels",),
|
||||||
cache_patterns=("vessels*", "summary*"),
|
cache_patterns=("vessels*", "summary*"),
|
||||||
|
derived_models=("ais_raw_observations", "ais_conflict_records", "ais_source_health"),
|
||||||
),
|
),
|
||||||
EarthLayerAdapter(
|
EarthLayerAdapter(
|
||||||
sources=frozenset(
|
sources=frozenset(
|
||||||
@@ -163,17 +164,24 @@ async def clear_derived_datasource_data(db: AsyncSession, source: str) -> dict[s
|
|||||||
from app.models.bgp_anomaly import BGPAnomaly
|
from app.models.bgp_anomaly import BGPAnomaly
|
||||||
from app.models.bgp_incident import BGPIncident
|
from app.models.bgp_incident import BGPIncident
|
||||||
from app.models.bgp_observation import BGPObservation
|
from app.models.bgp_observation import BGPObservation
|
||||||
|
from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth
|
||||||
|
|
||||||
model_by_key: dict[str, Any] = {
|
model_by_key: dict[str, Any] = {
|
||||||
"bgp_observations": BGPObservation,
|
"bgp_observations": BGPObservation,
|
||||||
"bgp_anomalies": BGPAnomaly,
|
"bgp_anomalies": BGPAnomaly,
|
||||||
"bgp_incidents": BGPIncident,
|
"bgp_incidents": BGPIncident,
|
||||||
|
"ais_raw_observations": AISRawObservation,
|
||||||
|
"ais_conflict_records": AISConflictRecord,
|
||||||
|
"ais_source_health": AISSourceHealth,
|
||||||
}
|
}
|
||||||
deleted_counts: dict[str, int] = {}
|
deleted_counts: dict[str, int] = {}
|
||||||
for key in adapter.derived_models:
|
for key in adapter.derived_models:
|
||||||
model = model_by_key.get(key)
|
model = model_by_key.get(key)
|
||||||
if model is None:
|
if model is None:
|
||||||
continue
|
continue
|
||||||
|
if key == "ais_conflict_records":
|
||||||
|
result = await db.execute(model.__table__.delete().where(model.selected_source == source))
|
||||||
|
else:
|
||||||
result = await db.execute(model.__table__.delete().where(model.source == source))
|
result = await db.execute(model.__table__.delete().where(model.source == source))
|
||||||
deleted_counts[key] = int(result.rowcount or 0)
|
deleted_counts[key] = int(result.rowcount or 0)
|
||||||
return deleted_counts
|
return deleted_counts
|
||||||
|
|||||||
@@ -8,6 +8,23 @@ This project follows the repository versioning rule:
|
|||||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||||
- `bugfix` -> `+0.0.1`
|
- `bugfix` -> `+0.0.1`
|
||||||
|
|
||||||
|
## [0.68.0] — 2026-05-28
|
||||||
|
|
||||||
|
Released: 2026-05-28
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
- 新增数据源任务队列的实时指标校准和批量删除进度,让大表清理、取消和完成状态在控制台中可感知。
|
||||||
|
- 新增智能星球 interactable 可插拔聚类策略,支持稳定 3D 球面聚类、动态屏幕聚类和 250% 以上自动散开。
|
||||||
|
- 改进新设备启动流程,`planet.sh` 会在启动前同步前端依赖,避免缺失依赖导致控制台动态导入失败。
|
||||||
|
|
||||||
|
### Added / Fixed / Improved
|
||||||
|
- 数据源列表改为中文记录数指标,并对 AIS 大表使用统计估算 + 单条详情精确校准,降低首次加载成本。
|
||||||
|
- 数据删除任务改为分批删除并广播进度,清理 AIS 衍生表后自动 `ANALYZE`,同时修复取消中任务恢复和状态文案。
|
||||||
|
- Earth BGP、算力中心和 interactable 图层默认使用 `stable-spherical` 聚类,船舶实时层保留 `dynamic-screen`。
|
||||||
|
- 新增中英文 Earth interactable clustering 文档,并补充采集队列、后端删除语义和前端依赖同步说明。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.67.0] — 2026-05-27
|
## [0.67.0] — 2026-05-27
|
||||||
|
|
||||||
Released: 2026-05-27
|
Released: 2026-05-27
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ This is the current Intelligent Planet documentation entry point. Docs are organ
|
|||||||
- [Earth Satellite Footprint Policy](/home/ray/dev/linkong/planet/docs/technical/en/earth-satellite-footprint-policy.md): satellite footprint display boundaries and strategy
|
- [Earth Satellite Footprint Policy](/home/ray/dev/linkong/planet/docs/technical/en/earth-satellite-footprint-policy.md): satellite footprint display boundaries and strategy
|
||||||
- [BGP Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-bgp-context.md): BGP rendering, aggregation, and collector implementation in Earth
|
- [BGP Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-bgp-context.md): BGP rendering, aggregation, and collector implementation in Earth
|
||||||
- [Earth Interactable Usage](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-usage.md): `Interactable` API, lifecycle, and integration examples
|
- [Earth Interactable Usage](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-usage.md): `Interactable` API, lifecycle, and integration examples
|
||||||
|
- [Earth Interactable Clustering](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-clustering.md): pluggable cluster strategies, stable spherical clustering, and dynamic screen clustering boundaries
|
||||||
- [Earth Toolbar and Overlay Coordination](/home/ray/dev/linkong/planet/docs/technical/en/earth-toolbar-overlay-coordination.md): close matrix for toolbar buttons, search, settings, news, and layer overlays
|
- [Earth Toolbar and Overlay Coordination](/home/ray/dev/linkong/planet/docs/technical/en/earth-toolbar-overlay-coordination.md): close matrix for toolbar buttons, search, settings, news, and layer overlays
|
||||||
|
|
||||||
## Frontend Implementation
|
## Frontend Implementation
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ async def run(self, db):
|
|||||||
|
|
||||||
Manual trigger, data clearing, and cache clearing now enter the PostgreSQL data job queue. `collection_tasks` remains the task ledger. Collectors only own `fetch -> transform -> save`; the `data_jobs.py` worker claims `collect` / `clear_data` / `clear_cache` / `earth_refresh` jobs and writes progress back. Earth layer refresh relationships live in `earth_layer_adapters.py`; do not hand-code cache invalidation or WebSocket broadcasts inside individual collectors or buttons.
|
Manual trigger, data clearing, and cache clearing now enter the PostgreSQL data job queue. `collection_tasks` remains the task ledger. Collectors only own `fetch -> transform -> save`; the `data_jobs.py` worker claims `collect` / `clear_data` / `clear_cache` / `earth_refresh` jobs and writes progress back. Earth layer refresh relationships live in `earth_layer_adapters.py`; do not hand-code cache invalidation or WebSocket broadcasts inside individual collectors or buttons.
|
||||||
|
|
||||||
|
Data deletion runs in batches so AIS-scale tables are not locked by one huge statement. A `clear_data` job clears `collected_data`, then source-specific AIS derived tables, and broadcasts `records_processed` as it goes; the console queue renders only user-facing text such as `Deleting data` and `Delete complete`, while internal table names remain in logs and raw task details. After AIS cleanup, the backend runs `ANALYZE ais_raw_observations` so datasource-list estimates converge quickly. The datasource directory uses PostgreSQL statistics for AIS record counts by default to avoid a cold-start `count(*)`; opening a single datasource detail row requests the exact count for that source.
|
||||||
|
|
||||||
## III. Collector List
|
## III. Collector List
|
||||||
|
|
||||||
| Collector | Data type | Content | Frequency |
|
| Collector | Data type | Content | Frequency |
|
||||||
|
|||||||
@@ -317,6 +317,8 @@ If future cable, satellite, or news cruise is added, do not copy a new set of `m
|
|||||||
|
|
||||||
[controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) owns the Earth zoom state, and every zoom entry point must ultimately call `setZoomLevel()` to write the camera distance. Do not write `camera.position.z` from other modules, or the zoom percentage, drag sensitivity, and Interactable clustering thresholds will diverge again.
|
[controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) owns the Earth zoom state, and every zoom entry point must ultimately call `setZoomLevel()` to write the camera distance. Do not write `camera.position.z` from other modules, or the zoom percentage, drag sensitivity, and Interactable clustering thresholds will diverge again.
|
||||||
|
|
||||||
|
Interactable clustering is selected per layer through `cluster.strategy`. `stable-spherical` uses discrete zoom bands and local 3D bucket clustering, so BGP, compute centers, and Earth interactables do not regroup while the globe rotates inside the same band. `dynamic-screen` keeps the projection-based behavior for high-frequency realtime layers such as vessels, and `none` disables clustering. Stable cluster dots stay rigidly aligned to their 3D centroid projection and do not participate in 2D avoidance. See [Earth Interactable Clustering](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-clustering.md) for strategy configuration and tuning.
|
||||||
|
|
||||||
Wheel input has two paths. Traditional mouse wheels keep the 10% step and short animation, using `wheelZoomTarget` as the logical base for continuous wheel input. Trackpads and high-precision wheels use the pixel delta for continuous zoom and call `setZoomLevel()` directly instead of passing through the 10% stepped animation. The trackpad path also filters a short-window, old-direction residual delta after a real direction change so inertia tails do not pull a just-reversed zoom back in the previous direction.
|
Wheel input has two paths. Traditional mouse wheels keep the 10% step and short animation, using `wheelZoomTarget` as the logical base for continuous wheel input. Trackpads and high-precision wheels use the pixel delta for continuous zoom and call `setZoomLevel()` directly instead of passing through the 10% stepped animation. The trackpad path also filters a short-window, old-direction residual delta after a real direction change so inertia tails do not pull a just-reversed zoom back in the previous direction.
|
||||||
|
|
||||||
The gesture capsule updates at most once every 90ms and fades after 760ms. It is view feedback, not data loading progress, and should not be written into layer loading state.
|
The gesture capsule updates at most once every 90ms and fades after 760ms. It is view feedback, not data loading progress, and should not be written into layer loading state.
|
||||||
|
|||||||
84
docs/technical/en/earth-interactable-clustering.md
Normal file
84
docs/technical/en/earth-interactable-clustering.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Intelligent Planet Interactable Clustering
|
||||||
|
|
||||||
|
Earth interactable icons are managed by `createInteractableLayer`. Rendering still uses Three.js `Points`, and picking, hover, locked state, tooltips, and cruise focus still depend on marker `userData`; the clustering strategy only decides which markers are represented by a cluster dot.
|
||||||
|
|
||||||
|
## Strategies
|
||||||
|
|
||||||
|
`cluster.strategy` supports three modes:
|
||||||
|
|
||||||
|
- `stable-spherical`: stable spherical clustering. It clusters by local 3D positions on the globe and caches topology by zoom band. Rotation and small zoom changes within the same band only update projection and size. Use it for semi-static layers such as BGP, compute centers, and Earth interactables.
|
||||||
|
- `dynamic-screen`: dynamic screen-space clustering. This keeps the previous projection-based behavior and evaluates visible relationships per frame. Use it for high-frequency realtime layers such as vessels, or as a fallback.
|
||||||
|
- `none`: no clustering. Every marker is shown independently. Use it for low-count layers or precision-first views.
|
||||||
|
|
||||||
|
`cluster: false` is equivalent to `strategy: "none"`. If a layer enables clustering without declaring a strategy, it keeps the compatible `dynamic-screen` behavior.
|
||||||
|
|
||||||
|
## Stable Spherical
|
||||||
|
|
||||||
|
`stable-spherical` moves cluster identity from screen distance to globe distance:
|
||||||
|
|
||||||
|
- Each marker uses `icon_base_position` as its geographic anchor.
|
||||||
|
- By default, zoom levels above `2.5` force clustering off and show every marker as its original icon.
|
||||||
|
- The current zoom maps to a discrete band; rotation and small zoom changes inside the same band do not recompute topology.
|
||||||
|
- Clusters are recomputed only when the band, data revision, visibility, or filter state changes.
|
||||||
|
- A cluster centroid is computed from member 3D positions and normalized back to the globe shell, so the cluster dot stays rigidly aligned to its geographic center.
|
||||||
|
- Cluster dots do not participate in 2D avoidance, so screen-space repulsion cannot push them away from their real geographic projection.
|
||||||
|
- Band changes use a small hysteresis margin so zooming at a boundary does not repeatedly bounce between two bands.
|
||||||
|
- Newly created marker and cluster dots run a short scale + opacity ease. This is only a rendering transition; it does not change marker coordinates, picking objects, or locked state.
|
||||||
|
|
||||||
|
The stable strategy uses spherical bucket/hash neighbor lookup and must not use an all-pairs loop. Most frames only pay projection and material-size cost; topology cost is paid only on band or data changes.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```js
|
||||||
|
const computeCenterIconLayer = createInteractableLayer({
|
||||||
|
id: "computeCenters",
|
||||||
|
// ...
|
||||||
|
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
||||||
|
cluster: {
|
||||||
|
strategy: "stable-spherical",
|
||||||
|
minCount: 2,
|
||||||
|
maxMarkersPerDot: 14,
|
||||||
|
transitionMs: 220,
|
||||||
|
bandHysteresis: 0.08,
|
||||||
|
disableAboveZoom: 2.5,
|
||||||
|
bands: [
|
||||||
|
{ key: "far", maxZoom: 1.7, distance: 15 },
|
||||||
|
{ key: "mid", maxZoom: 2.6, distance: 8 },
|
||||||
|
{ key: "near", maxZoom: 3.5, distance: 4 },
|
||||||
|
{ key: "detail", maxZoom: Infinity, distance: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Realtime layers can stay dynamic:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const vesselIconLayer = createInteractableLayer({
|
||||||
|
id: "vessels",
|
||||||
|
// ...
|
||||||
|
cluster: {
|
||||||
|
strategy: "dynamic-screen",
|
||||||
|
enabled: true,
|
||||||
|
maxMarkersPerDot: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tuning
|
||||||
|
|
||||||
|
- `distance` is the 3D globe-distance threshold and uses the same unit as `CONFIG.earthRadius`. Larger values cluster more aggressively.
|
||||||
|
- The farthest band usually uses a larger `distance`; the nearest detail band usually uses `0` to split clusters into original icons.
|
||||||
|
- `bandHysteresis` controls band-boundary stickiness. Too little can flicker near thresholds; too much can make band changes feel late.
|
||||||
|
- `transitionMs` controls cluster split/merge easing. Keep it around 160-260ms; longer durations can make realtime layers feel sluggish.
|
||||||
|
- `disableAboveZoom` controls the precision-view threshold. It defaults to `2.5`; above that zoom, no cluster dots are generated. Set it to `false` to disable the hard threshold.
|
||||||
|
- High-frequency realtime layers should prefer `dynamic-screen` so frequent data changes do not trigger stable topology recomputation.
|
||||||
|
- If a layer behaves poorly, switch it back to `dynamic-screen` or use `cluster: false`.
|
||||||
|
|
||||||
|
## Acceptance Checks
|
||||||
|
|
||||||
|
- Rotating the globe inside one zoom band should not cause clusters to flicker or regroup.
|
||||||
|
- Cluster dots should stay aligned with the globe-surface centroid and should not be pushed by avoidance.
|
||||||
|
- Zooming into the detail band should restore the layer's original icon textures and click behavior.
|
||||||
|
- Zoom levels above 250% should not show cluster dots.
|
||||||
|
- Realtime layers such as vessels should reflect incoming updates immediately.
|
||||||
@@ -286,6 +286,8 @@ bun run build
|
|||||||
|
|
||||||
Do not use `npm run ...`. In the WSL / Windows mixed environment Bun avoids Node/npm path inconsistencies.
|
Do not use `npm run ...`. In the WSL / Windows mixed environment Bun avoids Node/npm path inconsistencies.
|
||||||
|
|
||||||
|
`./planet.sh start` / `init` now runs `bun install` before startup instead of only checking whether the Vite entry file exists. This keeps new devices, cleaned `node_modules`, and lockfile changes synchronized before the console loads, avoiding dynamic-import 500s caused by missing frontend dependencies.
|
||||||
|
|
||||||
Validate the frontend build:
|
Validate the frontend build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
- [智能星球卫星覆盖策略](/home/ray/dev/linkong/planet/docs/technical/zh/earth-satellite-footprint-policy.md):卫星 footprint 的显示边界和策略
|
- [智能星球卫星覆盖策略](/home/ray/dev/linkong/planet/docs/technical/zh/earth-satellite-footprint-policy.md):卫星 footprint 的显示边界和策略
|
||||||
- [BGP 态势上下文](/home/ray/dev/linkong/planet/docs/technical/zh/earth-bgp-context.md):BGP 在智能星球中的渲染、聚合和观测站实现
|
- [BGP 态势上下文](/home/ray/dev/linkong/planet/docs/technical/zh/earth-bgp-context.md):BGP 在智能星球中的渲染、聚合和观测站实现
|
||||||
- [智能星球可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md):`Interactable` 的接口、生命周期和接入示例
|
- [智能星球可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md):`Interactable` 的接口、生命周期和接入示例
|
||||||
|
- [智能星球可交互图标聚类策略](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-clustering.md):可插拔 cluster strategy、稳定球面聚类和动态屏幕聚类的适用边界
|
||||||
- [智能星球工具栏与浮层协同](/home/ray/dev/linkong/planet/docs/technical/zh/earth-toolbar-overlay-coordination.md):工具栏按钮与搜索、设置、新闻、图层浮层的关闭矩阵
|
- [智能星球工具栏与浮层协同](/home/ray/dev/linkong/planet/docs/technical/zh/earth-toolbar-overlay-coordination.md):工具栏按钮与搜索、设置、新闻、图层浮层的关闭矩阵
|
||||||
|
|
||||||
## 前端技术实现
|
## 前端技术实现
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ async def run(self, db):
|
|||||||
|
|
||||||
手动触发、删除数据、清理缓存现在统一进入 PostgreSQL 数据作业队列,任务账本仍是 `collection_tasks`。采集器只负责 `fetch -> transform -> save`,由 `data_jobs.py` worker 领取 `collect` / `clear_data` / `clear_cache` / `earth_refresh` 任务并回写进度。Earth 图层刷新关系集中在 `earth_layer_adapters.py`,不要再在单个采集器或按钮里手写缓存失效和 WebSocket 广播。
|
手动触发、删除数据、清理缓存现在统一进入 PostgreSQL 数据作业队列,任务账本仍是 `collection_tasks`。采集器只负责 `fetch -> transform -> save`,由 `data_jobs.py` worker 领取 `collect` / `clear_data` / `clear_cache` / `earth_refresh` 任务并回写进度。Earth 图层刷新关系集中在 `earth_layer_adapters.py`,不要再在单个采集器或按钮里手写缓存失效和 WebSocket 广播。
|
||||||
|
|
||||||
|
删除数据任务按批次执行,避免 AIS 这类千万级表一次性锁表。`clear_data` 会先清 `collected_data`,再按来源清理 AIS 衍生表,并持续广播 `records_processed`;前端任务队列只展示“正在删除数据 / 删除完成”,内部表名只保留在日志和原始任务详情。删除结束后后端会 `ANALYZE ais_raw_observations`,让数据源列表的估算指标尽快收敛。数据源目录页默认使用 PostgreSQL 统计信息估算 AIS 大表记录数,避免冷启动做 `count(*)`;打开单条详情时再用精确计数校准当前数据源。
|
||||||
|
|
||||||
## 三、采集器列表
|
## 三、采集器列表
|
||||||
|
|
||||||
| 采集器 | 数据类型 | 数据内容 | 采集频率 |
|
| 采集器 | 数据类型 | 数据内容 | 采集频率 |
|
||||||
|
|||||||
@@ -383,11 +383,12 @@ asset 图标大小由 `Interactable` 的 `icon.fitSize` 控制。SVG / 图片文
|
|||||||
|
|
||||||
跨 Interactable 的同坐标关系也在公共层记录,但真实位置必须始终以 `icon_base_position` 为准。缩放、避让、聚合和后续 spiderfy 展开都只能改变屏幕表现,不能写回 `marker.position` 或 `THREE.Points` 里的业务锚点;巡航定位、详情卡、搜索定位和 picking 返回对象都必须落回真实经纬度。多个图标归入同一个经纬度 key 时,公共层只写 `icon_avoidance_*` 元数据,供业务层弱化 halo 或显示聚合提示;真正的低缩放聚合应通过独立 cluster glyph / screen layout 层实现,而不是把对象沿地表切平面挪开。
|
跨 Interactable 的同坐标关系也在公共层记录,但真实位置必须始终以 `icon_base_position` 为准。缩放、避让、聚合和后续 spiderfy 展开都只能改变屏幕表现,不能写回 `marker.position` 或 `THREE.Points` 里的业务锚点;巡航定位、详情卡、搜索定位和 picking 返回对象都必须落回真实经纬度。多个图标归入同一个经纬度 key 时,公共层只写 `icon_avoidance_*` 元数据,供业务层弱化 halo 或显示聚合提示;真正的低缩放聚合应通过独立 cluster glyph / screen layout 层实现,而不是把对象沿地表切平面挪开。
|
||||||
|
|
||||||
`Interactable` 的单点显示只由全局地图缩放决定:170% 及以下强制显示小圆点,超过 170% 显示原图标。cluster 判定使用离散 zoom band 推导出的球面邻近半径,而不是当前屏幕投影距离;同一 band 内同一组地理位置不应因为旋转角度或 100% 到 199% 的连续缩放而改变聚合语义,只有跨过 band 边界才允许拆成更小集群。170% 以上会按 band 收紧聚合阈值,轻微擦边直接拆成图标,避免高缩放下仍然到处是圆点。cluster 每帧从当前可见 marker 重新计算,不使用上一帧聚合状态,避免缩放来回后不同地区被粘成一组。cluster 不使用无限连通分量,避免 A 重叠 B、B 重叠 C 一路串成跨区域大组;圆点展示位置使用局部成员中心,但业务坐标仍以成员真实经纬度为准。cluster 圆点大小随包含对象数量增长,数量过多时按稳定地理顺序拆成多个较小圆点;数量默认只在 hover tooltip 中显示。这个过程只设置 `icon_cluster_*` 展示元数据和重建渲染 Points,不改变每个 marker 的真实经纬度。当前默认只对启用同坐标关系记录的图层开启 cluster,船只这类高频动态层继续关闭。
|
`Interactable` 的单点显示只由全局地图缩放决定:170% 及以下强制显示小圆点,超过 170% 显示原图标。cluster 现在由 `cluster.strategy` 决定:`stable-spherical` 使用离散 zoom band 和 3D 球面分桶,BGP、算力中心和 Earth interactable 在同一 band 内旋转或细微缩放时不会重新计算聚合拓扑;`dynamic-screen` 保留屏幕空间聚类,适合船只这类实时高频图层;`none` 关闭聚类。稳定球面聚类的 cluster 圆点刚性落在成员 3D 质心投影上,不参与 2D 避让,避免缩放时被推离真实地理位置。cluster 圆点大小随包含对象数量增长,数量过多时按稳定地理顺序拆成多个较小圆点;数量默认只在 hover tooltip 中显示。这个过程只设置 `icon_cluster_*` 展示元数据和重建渲染 Points,不改变每个 marker 的真实经纬度。
|
||||||
|
|
||||||
接口细节、生命周期和接入示例见:
|
接口细节、生命周期和接入示例见:
|
||||||
|
|
||||||
- [智能星球可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md)
|
- [智能星球可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md)
|
||||||
|
- [智能星球可交互图标聚类策略](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-clustering.md)
|
||||||
|
|
||||||
### 视角控制反馈
|
### 视角控制反馈
|
||||||
|
|
||||||
|
|||||||
84
docs/technical/zh/earth-interactable-clustering.md
Normal file
84
docs/technical/zh/earth-interactable-clustering.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# 智能星球可交互图标聚类策略
|
||||||
|
|
||||||
|
智能星球的可交互图标由 `createInteractableLayer` 统一管理。图标仍然使用 Three.js `Points` 渲染,拾取、hover、locked、tooltip 和巡航焦点继续依赖 marker 的 `userData`;聚类策略只决定“哪些 marker 被合成一个 cluster dot”。
|
||||||
|
|
||||||
|
## 策略
|
||||||
|
|
||||||
|
`cluster.strategy` 支持三种模式:
|
||||||
|
|
||||||
|
- `stable-spherical`:稳定球面聚类。按地球局部 3D 坐标聚合,并用 zoom band 缓存拓扑;旋转和同档缩放只更新投影和尺寸,不重算谁和谁聚在一起。适合 BGP、超算/GPU 中心和 Earth interactable 这类半静态图层。
|
||||||
|
- `dynamic-screen`:动态屏幕聚类。保留原来的屏幕空间聚类逻辑,每帧按可见投影关系判断。适合船舶等高频实时图层,也可作为稳定策略的回退。
|
||||||
|
- `none`:不聚类。所有 marker 独立显示,适合低数量或需要精确展示的图层。
|
||||||
|
|
||||||
|
`cluster: false` 等价于 `strategy: "none"`。未显式声明 `strategy` 时,保持兼容行为:开启聚类的旧图层继续走 `dynamic-screen`。
|
||||||
|
|
||||||
|
## Stable Spherical
|
||||||
|
|
||||||
|
`stable-spherical` 的核心是把聚类身份从屏幕距离迁到球面距离:
|
||||||
|
|
||||||
|
- 每个 marker 使用 `icon_base_position` 作为真实地理锚点。
|
||||||
|
- 默认 zoom 超过 `2.5` 时强制关闭聚类,所有 marker 展开为原图标。
|
||||||
|
- 当前 zoom 只映射到离散 band;同一 band 内旋转地球或细微缩放不会重算拓扑。
|
||||||
|
- 跨 band、数据变更、图层显隐变化时才重新计算 cluster。
|
||||||
|
- cluster 质心由成员 3D 坐标平均后 normalize 回球壳半径,因此 cluster dot 刚性贴在地理质心投影上。
|
||||||
|
- cluster dot 不参与 2D 避让,避免被屏幕排斥推离真实地理位置。
|
||||||
|
- band 切换带有少量 hysteresis,避免缩放停在临界点时在两个 band 之间来回跳。
|
||||||
|
- 新生成的 marker / cluster dot 会执行短 scale + opacity ease;这只是渲染过渡,不改变 marker 的真实经纬度、拾取对象或 locked 状态。
|
||||||
|
|
||||||
|
稳定策略使用球面 bucket/hash 邻域查询,禁止用全量双循环。这样大多数帧只承担投影与材质尺寸更新,聚类成本只在 band 或数据版本变化时支付。
|
||||||
|
|
||||||
|
## 配置示例
|
||||||
|
|
||||||
|
```js
|
||||||
|
const computeCenterIconLayer = createInteractableLayer({
|
||||||
|
id: "computeCenters",
|
||||||
|
// ...
|
||||||
|
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
||||||
|
cluster: {
|
||||||
|
strategy: "stable-spherical",
|
||||||
|
minCount: 2,
|
||||||
|
maxMarkersPerDot: 14,
|
||||||
|
transitionMs: 220,
|
||||||
|
bandHysteresis: 0.08,
|
||||||
|
disableAboveZoom: 2.5,
|
||||||
|
bands: [
|
||||||
|
{ key: "far", maxZoom: 1.7, distance: 15 },
|
||||||
|
{ key: "mid", maxZoom: 2.6, distance: 8 },
|
||||||
|
{ key: "near", maxZoom: 3.5, distance: 4 },
|
||||||
|
{ key: "detail", maxZoom: Infinity, distance: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
实时层可以保留动态策略:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const vesselIconLayer = createInteractableLayer({
|
||||||
|
id: "vessels",
|
||||||
|
// ...
|
||||||
|
cluster: {
|
||||||
|
strategy: "dynamic-screen",
|
||||||
|
enabled: true,
|
||||||
|
maxMarkersPerDot: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调参原则
|
||||||
|
|
||||||
|
- `distance` 是球面 3D 距离阈值,单位与 `CONFIG.earthRadius` 一致。值越大,越容易聚合。
|
||||||
|
- 最远 band 用较大的 `distance` 降低视觉密度;最近 band 通常设为 `0`,让图标完全解散。
|
||||||
|
- `bandHysteresis` 控制 band 边界滞回。值太小容易临界闪烁,值太大会让切换略显迟钝。
|
||||||
|
- `transitionMs` 控制聚散过渡时间。建议保持在 160-260ms,过长会让实时层显得拖泥带水。
|
||||||
|
- `disableAboveZoom` 控制精细查看阈值。默认 `2.5`,超过后不再生成 cluster;设为 `false` 可关闭这个硬阈值。
|
||||||
|
- 高实时性图层优先用 `dynamic-screen`,避免数据频繁变更时触发稳定策略的拓扑重算。
|
||||||
|
- 若某图层出现异常,可临时切回 `dynamic-screen` 或 `cluster: false`。
|
||||||
|
|
||||||
|
## 验收重点
|
||||||
|
|
||||||
|
- 同一 zoom band 内旋转地球,cluster 不应闪烁或重新聚散。
|
||||||
|
- cluster dot 应跟随地球表面质心,不被避让逻辑推开。
|
||||||
|
- 放大到 detail band 后,应恢复该图层原本的图标纹理和点击行为。
|
||||||
|
- zoom 超过 250% 后不应再显示 cluster dot。
|
||||||
|
- vessel 等实时层更新后,图标和 cluster 应立即反映最新数据。
|
||||||
@@ -286,6 +286,8 @@ bun run build
|
|||||||
|
|
||||||
不要使用 `npm run ...`。项目在 WSL / Windows 混合环境优先依赖 Bun,避免 Node/npm 路径差异。
|
不要使用 `npm run ...`。项目在 WSL / Windows 混合环境优先依赖 Bun,避免 Node/npm 路径差异。
|
||||||
|
|
||||||
|
`./planet.sh start` / `init` 会在启动前执行一次 `bun install`,而不是只检查 Vite 入口文件是否存在。这样新设备、清过 `node_modules` 的环境或 lockfile 已变更的环境,都能在进入控制台前同步前端依赖,避免动态 import 因缺失依赖返回 500。
|
||||||
|
|
||||||
验证前端构建:
|
验证前端构建:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -16,12 +16,13 @@
|
|||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
- `main` 当前主线历史推导到:`0.16.5`
|
- `main` 当前主线历史推导到:`0.16.5`
|
||||||
- `dev` 当前开发分支历史推导到:`0.67.0`
|
- `dev` 当前开发分支历史推导到:`0.68.0`
|
||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
| Version | Type | Branch | Commit | Summary |
|
| Version | Type | Branch | Commit | Summary |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `0.68.0` | feature | `dev` | `pending` | 新增数据源任务队列实时指标、AIS 大表分批删除和智能星球可插拔聚类策略,并让新设备启动前同步前端依赖 |
|
||||||
| `0.67.0` | feature | `dev` | `pending` | 新增控制台日志实时跟随和运行时错误上报,重构智能星球 Interactable 聚合、wheel 缩放输入、国界壳半径和开发脚本锁文件保护 |
|
| `0.67.0` | feature | `dev` | `pending` | 新增控制台日志实时跟随和运行时错误上报,重构智能星球 Interactable 聚合、wheel 缩放输入、国界壳半径和开发脚本锁文件保护 |
|
||||||
| `0.66.3` | bugfix | `dev` | `pending` | 补上 Admin utility module 并放开前端源码 lib 例外,修复控制台动态导入 500 与 Mermaid 包解析失败 |
|
| `0.66.3` | bugfix | `dev` | `pending` | 补上 Admin utility module 并放开前端源码 lib 例外,修复控制台动态导入 500 与 Mermaid 包解析失败 |
|
||||||
| `0.66.2` | bugfix | `dev` | `pending` | 统一智能星球、控制台和文档的中文显示命名,清理 Docs catalog、使用手册、控制台入口和智能星球设置中的中英混排文案 |
|
| `0.66.2` | bugfix | `dev` | `pending` | 统一智能星球、控制台和文档的中文显示命名,清理 Docs catalog、使用手册、控制台入口和智能星球设置中的中英混排文案 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.67.0",
|
"version": "0.68.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1",
|
"packageManager": "bun@1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -333,6 +333,9 @@ const bgpEventIconLayer = createInteractableLayer({
|
|||||||
pulseOffset: Math.random() * Math.PI * 2,
|
pulseOffset: Math.random() * Math.PI * 2,
|
||||||
}),
|
}),
|
||||||
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
||||||
|
cluster: {
|
||||||
|
strategy: "stable-spherical",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const bgpCollectorIconLayer = createInteractableLayer({
|
const bgpCollectorIconLayer = createInteractableLayer({
|
||||||
@@ -402,6 +405,9 @@ const bgpCollectorIconLayer = createInteractableLayer({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
||||||
|
cluster: {
|
||||||
|
strategy: "stable-spherical",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function clamp(value, min, max) {
|
function clamp(value, min, max) {
|
||||||
|
|||||||
@@ -247,6 +247,9 @@ const computeCenterIconLayer = createInteractableLayer({
|
|||||||
pulseOffset: Math.random() * Math.PI * 2,
|
pulseOffset: Math.random() * Math.PI * 2,
|
||||||
}),
|
}),
|
||||||
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
||||||
|
cluster: {
|
||||||
|
strategy: "stable-spherical",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function formatComputeCenterTypeLabel(siteType) {
|
export function formatComputeCenterTypeLabel(siteType) {
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ const earthInteractableLayer = createInteractableLayer({
|
|||||||
...item,
|
...item,
|
||||||
type: "earth_interactable",
|
type: "earth_interactable",
|
||||||
}),
|
}),
|
||||||
|
cluster: {
|
||||||
|
strategy: "stable-spherical",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function loadEarthInteractables(earth, { silent = false } = {}) {
|
export async function loadEarthInteractables(earth, { silent = false } = {}) {
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ const CLUSTER_MAX_MARKERS_PER_DOT = 14;
|
|||||||
const CLUSTER_MAX_POINT_SIZE = 32;
|
const CLUSTER_MAX_POINT_SIZE = 32;
|
||||||
const CLUSTER_MAX_SCREEN_DIAMETER_PX = 96;
|
const CLUSTER_MAX_SCREEN_DIAMETER_PX = 96;
|
||||||
const CLUSTER_SEED_DISTANCE_FACTOR = 1.8;
|
const CLUSTER_SEED_DISTANCE_FACTOR = 1.8;
|
||||||
|
const CLUSTER_TRANSITION_MS = 220;
|
||||||
|
const CLUSTER_BAND_HYSTERESIS = 0.08;
|
||||||
|
const CLUSTER_DISABLE_ABOVE_ZOOM = 2.5;
|
||||||
|
const CLUSTER_STRATEGY_DYNAMIC_SCREEN = "dynamic-screen";
|
||||||
|
const CLUSTER_STRATEGY_STABLE_SPHERICAL = "stable-spherical";
|
||||||
|
const CLUSTER_STRATEGY_NONE = "none";
|
||||||
const CLUSTER_ZOOM_BANDS = Object.freeze([
|
const CLUSTER_ZOOM_BANDS = Object.freeze([
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
maxZoom: 2.0,
|
maxZoom: 2.0,
|
||||||
@@ -37,6 +43,12 @@ const CLUSTER_ZOOM_BANDS = Object.freeze([
|
|||||||
maxDiameterPx: 48,
|
maxDiameterPx: 48,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
const SPHERICAL_CLUSTER_BANDS = Object.freeze([
|
||||||
|
Object.freeze({ key: "far", maxZoom: 1.7, distance: 15 }),
|
||||||
|
Object.freeze({ key: "mid", maxZoom: 2.6, distance: 8 }),
|
||||||
|
Object.freeze({ key: "near", maxZoom: 3.5, distance: 4 }),
|
||||||
|
Object.freeze({ key: "detail", maxZoom: Number.POSITIVE_INFINITY, distance: 0 }),
|
||||||
|
]);
|
||||||
let compactDotsEnabled = true;
|
let compactDotsEnabled = true;
|
||||||
let screenAvoidanceRevision = 0;
|
let screenAvoidanceRevision = 0;
|
||||||
|
|
||||||
@@ -90,15 +102,33 @@ function normalizeClusterConfig(cluster, avoidanceConfig) {
|
|||||||
if (cluster === false || cluster === null) {
|
if (cluster === false || cluster === null) {
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
strategy: CLUSTER_STRATEGY_NONE,
|
||||||
overlapFactor: CLUSTER_OVERLAP_FACTOR,
|
overlapFactor: CLUSTER_OVERLAP_FACTOR,
|
||||||
minCount: CLUSTER_MIN_COUNT,
|
minCount: CLUSTER_MIN_COUNT,
|
||||||
maxMarkersPerDot: CLUSTER_MAX_MARKERS_PER_DOT,
|
maxMarkersPerDot: CLUSTER_MAX_MARKERS_PER_DOT,
|
||||||
|
bands: SPHERICAL_CLUSTER_BANDS,
|
||||||
|
transitionMs: CLUSTER_TRANSITION_MS,
|
||||||
|
bandHysteresis: CLUSTER_BAND_HYSTERESIS,
|
||||||
|
disableAboveZoom: CLUSTER_DISABLE_ABOVE_ZOOM,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const customConfig = typeof cluster === "object" ? cluster : {};
|
const customConfig = typeof cluster === "object" ? cluster : {};
|
||||||
|
const requestedStrategy = String(customConfig.strategy || "").trim();
|
||||||
|
const enabled = customConfig.enabled ?? avoidanceConfig.enabled;
|
||||||
|
const strategy =
|
||||||
|
enabled === false
|
||||||
|
? CLUSTER_STRATEGY_NONE
|
||||||
|
: [
|
||||||
|
CLUSTER_STRATEGY_DYNAMIC_SCREEN,
|
||||||
|
CLUSTER_STRATEGY_STABLE_SPHERICAL,
|
||||||
|
CLUSTER_STRATEGY_NONE,
|
||||||
|
].includes(requestedStrategy)
|
||||||
|
? requestedStrategy
|
||||||
|
: CLUSTER_STRATEGY_DYNAMIC_SCREEN;
|
||||||
return {
|
return {
|
||||||
enabled: customConfig.enabled ?? avoidanceConfig.enabled,
|
enabled: enabled !== false && strategy !== CLUSTER_STRATEGY_NONE,
|
||||||
|
strategy,
|
||||||
overlapFactor: Number.isFinite(Number(customConfig.overlapFactor))
|
overlapFactor: Number.isFinite(Number(customConfig.overlapFactor))
|
||||||
? Number(customConfig.overlapFactor)
|
? Number(customConfig.overlapFactor)
|
||||||
: CLUSTER_OVERLAP_FACTOR,
|
: CLUSTER_OVERLAP_FACTOR,
|
||||||
@@ -107,9 +137,40 @@ function normalizeClusterConfig(cluster, avoidanceConfig) {
|
|||||||
2,
|
2,
|
||||||
Math.round(Number(customConfig.maxMarkersPerDot ?? CLUSTER_MAX_MARKERS_PER_DOT)),
|
Math.round(Number(customConfig.maxMarkersPerDot ?? CLUSTER_MAX_MARKERS_PER_DOT)),
|
||||||
),
|
),
|
||||||
|
bands: normalizeSphericalClusterBands(customConfig.bands),
|
||||||
|
transitionMs: Math.max(
|
||||||
|
0,
|
||||||
|
Math.round(Number(customConfig.transitionMs ?? CLUSTER_TRANSITION_MS)),
|
||||||
|
),
|
||||||
|
bandHysteresis: Math.max(
|
||||||
|
0,
|
||||||
|
Number(customConfig.bandHysteresis ?? CLUSTER_BAND_HYSTERESIS),
|
||||||
|
),
|
||||||
|
disableAboveZoom:
|
||||||
|
customConfig.disableAboveZoom === false || customConfig.disableAboveZoom === null
|
||||||
|
? Number.POSITIVE_INFINITY
|
||||||
|
: Number.isFinite(Number(customConfig.disableAboveZoom))
|
||||||
|
? Number(customConfig.disableAboveZoom)
|
||||||
|
: CLUSTER_DISABLE_ABOVE_ZOOM,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSphericalClusterBands(bands) {
|
||||||
|
if (!Array.isArray(bands) || bands.length === 0) return SPHERICAL_CLUSTER_BANDS;
|
||||||
|
const normalizedBands = bands
|
||||||
|
.map((band, index) => {
|
||||||
|
const maxZoom = Number(band?.maxZoom);
|
||||||
|
const distance = Number(band?.distance);
|
||||||
|
return {
|
||||||
|
key: String(band?.key || `band-${index}`),
|
||||||
|
maxZoom: Number.isFinite(maxZoom) ? maxZoom : Number.POSITIVE_INFINITY,
|
||||||
|
distance: Number.isFinite(distance) ? Math.max(0, distance) : 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.maxZoom - b.maxZoom);
|
||||||
|
return normalizedBands.length > 0 ? normalizedBands : SPHERICAL_CLUSTER_BANDS;
|
||||||
|
}
|
||||||
|
|
||||||
function getClusterPointSize(count) {
|
function getClusterPointSize(count) {
|
||||||
const safeCount = Math.max(2, Number(count) || 2);
|
const safeCount = Math.max(2, Number(count) || 2);
|
||||||
return Math.min(
|
return Math.min(
|
||||||
@@ -125,6 +186,40 @@ function getClusterZoomBand(zoom) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSphericalClusterBand(config, zoom, previousBandKey = null) {
|
||||||
|
const bands = config?.bands || SPHERICAL_CLUSTER_BANDS;
|
||||||
|
const previousIndex = previousBandKey
|
||||||
|
? bands.findIndex((band) => band.key === previousBandKey)
|
||||||
|
: -1;
|
||||||
|
const hysteresis = Number(config?.bandHysteresis) || 0;
|
||||||
|
if (previousIndex >= 0 && hysteresis > 0) {
|
||||||
|
const previousBand = bands[previousIndex];
|
||||||
|
const lowerBoundary =
|
||||||
|
previousIndex > 0 ? bands[previousIndex - 1].maxZoom : Number.NEGATIVE_INFINITY;
|
||||||
|
const upperBoundary = previousBand.maxZoom;
|
||||||
|
if (zoom > lowerBoundary - hysteresis && zoom <= upperBoundary + hysteresis) {
|
||||||
|
return previousBand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bands.find((band) => zoom <= band.maxZoom) || bands[bands.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNowMs() {
|
||||||
|
return typeof performance !== "undefined" && typeof performance.now === "function"
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeOutCubic(value) {
|
||||||
|
const t = Math.max(0, Math.min(1, value));
|
||||||
|
return 1 - Math.pow(1 - t, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTransitionProgress(startedAt, durationMs) {
|
||||||
|
if (!startedAt || !durationMs || durationMs <= 0) return 1;
|
||||||
|
return easeOutCubic((getNowMs() - startedAt) / durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
function getNominalGlobePixelsPerWorldUnit(camera, referenceZoom) {
|
function getNominalGlobePixelsPerWorldUnit(camera, referenceZoom) {
|
||||||
if (!camera) return 1;
|
if (!camera) return 1;
|
||||||
const viewportHeight = window.innerHeight || 1;
|
const viewportHeight = window.innerHeight || 1;
|
||||||
@@ -150,6 +245,55 @@ function getClusterOverlapFactorForBand(band, baseFactor = CLUSTER_OVERLAP_FACTO
|
|||||||
return Math.min(baseFactor, band.overlapFactor);
|
return Math.min(baseFactor, band.overlapFactor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSphericalBucketKey(latIndex, lonIndex) {
|
||||||
|
return `${latIndex}:${lonIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSphericalLongitudeIndex(lonIndex, lonBucketCount) {
|
||||||
|
if (!Number.isFinite(lonBucketCount) || lonBucketCount <= 0) return lonIndex;
|
||||||
|
return ((lonIndex % lonBucketCount) + lonBucketCount) % lonBucketCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNeighboringSphericalBucketKeys(latIndex, lonIndex, lonBucketCount) {
|
||||||
|
const keys = [];
|
||||||
|
for (let latOffset = -1; latOffset <= 1; latOffset += 1) {
|
||||||
|
for (let lonOffset = -1; lonOffset <= 1; lonOffset += 1) {
|
||||||
|
keys.push(
|
||||||
|
getSphericalBucketKey(
|
||||||
|
latIndex + latOffset,
|
||||||
|
normalizeSphericalLongitudeIndex(lonIndex + lonOffset, lonBucketCount),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntrySphericalCoordinates(entry) {
|
||||||
|
const direction = entry.direction;
|
||||||
|
const lat = Math.asin(Math.max(-1, Math.min(1, direction.y)));
|
||||||
|
const lon = Math.atan2(direction.z, direction.x);
|
||||||
|
return { lat, lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashStableIds(entries) {
|
||||||
|
let hash = 2166136261;
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const value = entry.stableId;
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
hash ^= value.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return (hash >>> 0).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClusterRecordId(entries) {
|
||||||
|
const sortedEntries = [...entries].sort((a, b) => a.stableId.localeCompare(b.stableId));
|
||||||
|
const anchorId = sortedEntries[0]?.stableId || "unknown";
|
||||||
|
return `cluster:${anchorId}:${sortedEntries.length}:${hashStableIds(sortedEntries)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function disposeGroupChildren(group) {
|
function disposeGroupChildren(group) {
|
||||||
for (let index = group.children.length - 1; index >= 0; index -= 1) {
|
for (let index = group.children.length - 1; index >= 0; index -= 1) {
|
||||||
const child = group.children[index];
|
const child = group.children[index];
|
||||||
@@ -302,13 +446,23 @@ function getMarkerStaticAvoidancePosition(marker) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectScreenAvoidanceEntries(camera) {
|
function collectClusterUpdates(camera) {
|
||||||
if (!camera) return [];
|
if (!camera) return [];
|
||||||
const entries = [];
|
const updates = [];
|
||||||
interactableLayerControllers.forEach((controller) => {
|
interactableLayerControllers.forEach((controller) => {
|
||||||
entries.push(...(controller.collectClusterEntries?.(camera) || []));
|
const update = controller.collectClusterEntries?.(camera);
|
||||||
|
if (!update) return;
|
||||||
|
if (Array.isArray(update)) {
|
||||||
|
updates.push({
|
||||||
|
strategy: CLUSTER_STRATEGY_DYNAMIC_SCREEN,
|
||||||
|
entries: update,
|
||||||
|
records: [],
|
||||||
});
|
});
|
||||||
return entries.sort((a, b) => a.stableId.localeCompare(b.stableId));
|
return;
|
||||||
|
}
|
||||||
|
updates.push(update);
|
||||||
|
});
|
||||||
|
return updates;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findScreenClusterGroups(entries) {
|
function findScreenClusterGroups(entries) {
|
||||||
@@ -419,6 +573,77 @@ function splitLargeScreenClusterGroup(groupEntries) {
|
|||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeSphericalClusterRecords(entries, config, band) {
|
||||||
|
if (!entries.length || !band || band.distance <= 0) return [];
|
||||||
|
|
||||||
|
const radius =
|
||||||
|
entries.find((entry) => Number.isFinite(entry.radiusWorld))?.radiusWorld ||
|
||||||
|
CONFIG.earthRadius;
|
||||||
|
const angularThreshold = Math.max(0.00001, band.distance / Math.max(1, radius));
|
||||||
|
const lonBucketCount = Math.max(1, Math.ceil((Math.PI * 2) / angularThreshold));
|
||||||
|
const buckets = new Map();
|
||||||
|
const entryState = entries.map((entry) => {
|
||||||
|
const { lat, lon } = getEntrySphericalCoordinates(entry);
|
||||||
|
const latIndex = Math.floor(lat / angularThreshold);
|
||||||
|
const lonIndex = normalizeSphericalLongitudeIndex(
|
||||||
|
Math.floor(lon / angularThreshold),
|
||||||
|
lonBucketCount,
|
||||||
|
);
|
||||||
|
const state = {
|
||||||
|
...entry,
|
||||||
|
latIndex,
|
||||||
|
lonIndex,
|
||||||
|
};
|
||||||
|
const key = getSphericalBucketKey(latIndex, lonIndex);
|
||||||
|
if (!buckets.has(key)) buckets.set(key, []);
|
||||||
|
buckets.get(key).push(state);
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
|
||||||
|
const unvisited = new Set(entryState);
|
||||||
|
const groups = [];
|
||||||
|
entryState
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.stableId.localeCompare(b.stableId))
|
||||||
|
.forEach((seed) => {
|
||||||
|
if (!unvisited.has(seed)) return;
|
||||||
|
const groupEntries = [seed];
|
||||||
|
unvisited.delete(seed);
|
||||||
|
const neighborKeys = getNeighboringSphericalBucketKeys(
|
||||||
|
seed.latIndex,
|
||||||
|
seed.lonIndex,
|
||||||
|
lonBucketCount,
|
||||||
|
);
|
||||||
|
const candidates = neighborKeys
|
||||||
|
.flatMap((key) => buckets.get(key) || [])
|
||||||
|
.filter((candidate) => unvisited.has(candidate))
|
||||||
|
.sort((a, b) => getAngularDistance(seed, a) - getAngularDistance(seed, b) || a.stableId.localeCompare(b.stableId));
|
||||||
|
|
||||||
|
candidates.forEach((candidate) => {
|
||||||
|
if (!unvisited.has(candidate)) return;
|
||||||
|
if (getAngularDistance(seed, candidate) > angularThreshold) return;
|
||||||
|
const overlapsGroup = groupEntries.some(
|
||||||
|
(entry) => getAngularDistance(candidate, entry) <= angularThreshold,
|
||||||
|
);
|
||||||
|
if (!overlapsGroup) return;
|
||||||
|
groupEntries.push(candidate);
|
||||||
|
unvisited.delete(candidate);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (groupEntries.length >= config.minCount) {
|
||||||
|
groups.push(groupEntries.sort((a, b) => a.stableId.localeCompare(b.stableId)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupEntries.forEach((entry) => unvisited.add(entry));
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups
|
||||||
|
.flatMap(splitLargeScreenClusterGroup)
|
||||||
|
.map(createScreenClusterRecord)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
function compareEntriesByStableGeography(a, b) {
|
function compareEntriesByStableGeography(a, b) {
|
||||||
const lonA = Math.atan2(a.direction.z, a.direction.x);
|
const lonA = Math.atan2(a.direction.z, a.direction.x);
|
||||||
const lonB = Math.atan2(b.direction.z, b.direction.x);
|
const lonB = Math.atan2(b.direction.z, b.direction.x);
|
||||||
@@ -455,7 +680,7 @@ function createScreenClusterRecord(groupEntries) {
|
|||||||
});
|
});
|
||||||
center.x /= sortedEntries.length;
|
center.x /= sortedEntries.length;
|
||||||
center.y /= sortedEntries.length;
|
center.y /= sortedEntries.length;
|
||||||
const clusterId = `cluster:${sortedEntries.map((entry) => entry.stableId).join("+")}`;
|
const clusterId = getClusterRecordId(sortedEntries);
|
||||||
return {
|
return {
|
||||||
clusterId,
|
clusterId,
|
||||||
markers: sortedEntries.map((entry) => entry.marker),
|
markers: sortedEntries.map((entry) => entry.marker),
|
||||||
@@ -463,9 +688,10 @@ function createScreenClusterRecord(groupEntries) {
|
|||||||
position: clusterPosition,
|
position: clusterPosition,
|
||||||
pointSize: getClusterPointSize(sortedEntries.length),
|
pointSize: getClusterPointSize(sortedEntries.length),
|
||||||
anchorStableId: anchorEntry.stableId,
|
anchorStableId: anchorEntry.stableId,
|
||||||
clusterZoomBand: anchorEntry.clusterZoomBand,
|
clusterZoomBand: anchorEntry.clusterBandKey || anchorEntry.clusterZoomBand,
|
||||||
screenX: center.x,
|
screenX: center.x,
|
||||||
screenY: center.y,
|
screenY: center.y,
|
||||||
|
transitionStartedAt: getNowMs(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +701,15 @@ function recomputeScreenAvoidance(camera) {
|
|||||||
interactableLayerControllers.forEach((controller) => {
|
interactableLayerControllers.forEach((controller) => {
|
||||||
controller.beginClusterUpdate?.();
|
controller.beginClusterUpdate?.();
|
||||||
});
|
});
|
||||||
const entries = collectScreenAvoidanceEntries(camera);
|
const updates = collectClusterUpdates(camera);
|
||||||
|
const dynamicEntries = updates
|
||||||
|
.filter((update) => update.strategy === CLUSTER_STRATEGY_DYNAMIC_SCREEN)
|
||||||
|
.flatMap((update) => update.entries || [])
|
||||||
|
.sort((a, b) => a.stableId.localeCompare(b.stableId));
|
||||||
|
const stableRecords = updates
|
||||||
|
.filter((update) => update.strategy === CLUSTER_STRATEGY_STABLE_SPHERICAL)
|
||||||
|
.flatMap((update) => update.records || []);
|
||||||
|
const entries = dynamicEntries;
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
const basePosition = getMarkerStaticAvoidancePosition(entry.marker);
|
const basePosition = getMarkerStaticAvoidancePosition(entry.marker);
|
||||||
@@ -491,6 +725,7 @@ function recomputeScreenAvoidance(camera) {
|
|||||||
.flatMap(splitLargeScreenClusterGroup)
|
.flatMap(splitLargeScreenClusterGroup)
|
||||||
.map(createScreenClusterRecord)
|
.map(createScreenClusterRecord)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
.concat(stableRecords)
|
||||||
.forEach((record) => {
|
.forEach((record) => {
|
||||||
record.owner.addOwnedCluster?.(record);
|
record.owner.addOwnedCluster?.(record);
|
||||||
record.markers.forEach((marker) => {
|
record.markers.forEach((marker) => {
|
||||||
@@ -589,6 +824,10 @@ export function createInteractableLayer(options = {}) {
|
|||||||
let pendingClusterSignature = "";
|
let pendingClusterSignature = "";
|
||||||
let clusterUpdateActive = false;
|
let clusterUpdateActive = false;
|
||||||
let visualStateVersion = 0;
|
let visualStateVersion = 0;
|
||||||
|
let clusterTopologyRevision = 0;
|
||||||
|
let lastStableClusterKey = "";
|
||||||
|
let lastStableClusterBandKey = "";
|
||||||
|
let stableClusterRecords = [];
|
||||||
const ownedClusterRecords = [];
|
const ownedClusterRecords = [];
|
||||||
const scratchDirection = new THREE.Vector3();
|
const scratchDirection = new THREE.Vector3();
|
||||||
const scratchCameraLocal = new THREE.Vector3();
|
const scratchCameraLocal = new THREE.Vector3();
|
||||||
@@ -626,6 +865,12 @@ export function createInteractableLayer(options = {}) {
|
|||||||
lastVisualStateKey = "";
|
lastVisualStateKey = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function invalidateClusterTopology() {
|
||||||
|
clusterTopologyRevision += 1;
|
||||||
|
lastStableClusterKey = "";
|
||||||
|
lastStableClusterBandKey = "";
|
||||||
|
}
|
||||||
|
|
||||||
function refreshViewportSize() {
|
function refreshViewportSize() {
|
||||||
const pixelRatio = window.devicePixelRatio || 1;
|
const pixelRatio = window.devicePixelRatio || 1;
|
||||||
viewportSize.set(
|
viewportSize.set(
|
||||||
@@ -910,10 +1155,29 @@ export function createInteractableLayer(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function collectClusterEntries(camera) {
|
function collectClusterEntries(camera) {
|
||||||
if (!visible || !clusterConfig.enabled || !camera || markers.length === 0) return [];
|
if (!visible || !clusterConfig.enabled || !camera || markers.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const cameraScale = getCameraScale(camera);
|
const cameraScale = getCameraScale(camera);
|
||||||
const zoom = getCameraZoom(camera);
|
const zoom = getCameraZoom(camera);
|
||||||
|
if (zoom > clusterConfig.disableAboveZoom) {
|
||||||
|
lastStableClusterBandKey = "";
|
||||||
|
return {
|
||||||
|
strategy: CLUSTER_STRATEGY_DYNAMIC_SCREEN,
|
||||||
|
entries: [],
|
||||||
|
records: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
const clusterZoomBand = getClusterZoomBand(zoom);
|
const clusterZoomBand = getClusterZoomBand(zoom);
|
||||||
|
const sphericalBand = getSphericalClusterBand(
|
||||||
|
clusterConfig,
|
||||||
|
zoom,
|
||||||
|
lastStableClusterBandKey,
|
||||||
|
);
|
||||||
|
if (clusterConfig.strategy === CLUSTER_STRATEGY_STABLE_SPHERICAL && sphericalBand?.key) {
|
||||||
|
lastStableClusterBandKey = sphericalBand.key;
|
||||||
|
}
|
||||||
|
const stableSpherical = clusterConfig.strategy === CLUSTER_STRATEGY_STABLE_SPHERICAL;
|
||||||
const visualPointSize = shouldUseCompactDots(camera)
|
const visualPointSize = shouldUseCompactDots(camera)
|
||||||
? COMPACT_DOT_POINT_SIZE
|
? COMPACT_DOT_POINT_SIZE
|
||||||
: pointSize;
|
: pointSize;
|
||||||
@@ -925,11 +1189,12 @@ export function createInteractableLayer(options = {}) {
|
|||||||
clusterCameraLocalScratch.normalize();
|
clusterCameraLocalScratch.normalize();
|
||||||
group.updateMatrixWorld?.(true);
|
group.updateMatrixWorld?.(true);
|
||||||
|
|
||||||
return markers
|
const entries = markers
|
||||||
.map((marker, index) => {
|
.map((marker, index) => {
|
||||||
const basePosition = marker.userData?.icon_base_position || marker.position;
|
const basePosition = marker.userData?.icon_base_position || marker.position;
|
||||||
if (!(basePosition instanceof THREE.Vector3)) return null;
|
if (!(basePosition instanceof THREE.Vector3)) return null;
|
||||||
const direction = clusterDirectionScratch.copy(basePosition).normalize().clone();
|
const direction = clusterDirectionScratch.copy(basePosition).normalize().clone();
|
||||||
|
if (!stableSpherical) {
|
||||||
if (clusterCameraLocalScratch.dot(clusterDirectionScratch) <= 0) return null;
|
if (clusterCameraLocalScratch.dot(clusterDirectionScratch) <= 0) return null;
|
||||||
|
|
||||||
clusterWorldScratch.copy(basePosition);
|
clusterWorldScratch.copy(basePosition);
|
||||||
@@ -938,14 +1203,15 @@ export function createInteractableLayer(options = {}) {
|
|||||||
if (clusterProjectedScratch.z < -1 || clusterProjectedScratch.z > 1) {
|
if (clusterProjectedScratch.z < -1 || clusterProjectedScratch.z > 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pointSizeMultiplier = getPointSizeMultiplier(marker) * cameraScale;
|
const pointSizeMultiplier = getPointSizeMultiplier(marker) * cameraScale;
|
||||||
const sizePx = visualPointSize * pointSizeMultiplier;
|
const sizePx = visualPointSize * pointSizeMultiplier;
|
||||||
const screenX =
|
const screenX =
|
||||||
(clusterProjectedScratch.x * 0.5 + 0.5) * width +
|
(stableSpherical ? 0 : (clusterProjectedScratch.x * 0.5 + 0.5) * width) +
|
||||||
(0.5 - iconAnchor.x) * sizePx;
|
(0.5 - iconAnchor.x) * sizePx;
|
||||||
const screenY =
|
const screenY =
|
||||||
(-clusterProjectedScratch.y * 0.5 + 0.5) * height +
|
(stableSpherical ? 0 : (-clusterProjectedScratch.y * 0.5 + 0.5) * height) +
|
||||||
(0.5 - iconAnchor.y) * sizePx;
|
(0.5 - iconAnchor.y) * sizePx;
|
||||||
const nominalRadiusPx = Math.max(8, visualPointSize * 0.5);
|
const nominalRadiusPx = Math.max(8, visualPointSize * 0.5);
|
||||||
const angularRadius = getAngularRadiusFromPixels(
|
const angularRadius = getAngularRadiusFromPixels(
|
||||||
@@ -976,10 +1242,41 @@ export function createInteractableLayer(options = {}) {
|
|||||||
),
|
),
|
||||||
maxMarkersPerDot: clusterConfig.maxMarkersPerDot,
|
maxMarkersPerDot: clusterConfig.maxMarkersPerDot,
|
||||||
clusterZoomBand: clusterZoomBand.referenceZoom,
|
clusterZoomBand: clusterZoomBand.referenceZoom,
|
||||||
|
clusterBandKey: sphericalBand?.key || String(clusterZoomBand.referenceZoom),
|
||||||
|
radiusWorld: basePosition.length(),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.sort((a, b) => a.stableId.localeCompare(b.stableId));
|
.sort((a, b) => a.stableId.localeCompare(b.stableId));
|
||||||
|
|
||||||
|
if (clusterConfig.strategy !== CLUSTER_STRATEGY_STABLE_SPHERICAL) {
|
||||||
|
return {
|
||||||
|
strategy: CLUSTER_STRATEGY_DYNAMIC_SCREEN,
|
||||||
|
entries,
|
||||||
|
records: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stableKey = [
|
||||||
|
id,
|
||||||
|
sphericalBand?.key || "unknown",
|
||||||
|
sphericalBand?.distance ?? 0,
|
||||||
|
clusterTopologyRevision,
|
||||||
|
entries.length,
|
||||||
|
].join(":");
|
||||||
|
if (stableKey !== lastStableClusterKey) {
|
||||||
|
stableClusterRecords = computeSphericalClusterRecords(
|
||||||
|
entries,
|
||||||
|
clusterConfig,
|
||||||
|
sphericalBand,
|
||||||
|
);
|
||||||
|
lastStableClusterKey = stableKey;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
strategy: CLUSTER_STRATEGY_STABLE_SPHERICAL,
|
||||||
|
entries: [],
|
||||||
|
records: stableClusterRecords,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function addOwnedCluster(record) {
|
function addOwnedCluster(record) {
|
||||||
@@ -1010,6 +1307,17 @@ export function createInteractableLayer(options = {}) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasActiveRenderTransitions() {
|
||||||
|
if (clusterConfig.transitionMs <= 0) return false;
|
||||||
|
const now = getNowMs();
|
||||||
|
return pointObjects
|
||||||
|
.concat(clusterPointObjects)
|
||||||
|
.some((points) => {
|
||||||
|
const startedAt = points.userData?.transitionStartedAt;
|
||||||
|
return startedAt && now - startedAt < clusterConfig.transitionMs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function disposePointsGroup() {
|
function disposePointsGroup() {
|
||||||
if (pointsGroup?.parent) {
|
if (pointsGroup?.parent) {
|
||||||
pointsGroup.parent.remove(pointsGroup);
|
pointsGroup.parent.remove(pointsGroup);
|
||||||
@@ -1131,6 +1439,7 @@ export function createInteractableLayer(options = {}) {
|
|||||||
bucketKey,
|
bucketKey,
|
||||||
markers: bucketMarkers,
|
markers: bucketMarkers,
|
||||||
pointSizeMultiplier: getPointSizeMultiplier(bucketMarkers[0]),
|
pointSizeMultiplier: getPointSizeMultiplier(bucketMarkers[0]),
|
||||||
|
transitionStartedAt: getNowMs(),
|
||||||
};
|
};
|
||||||
pointObjects.push(points);
|
pointObjects.push(points);
|
||||||
pointsGroup.add(points);
|
pointsGroup.add(points);
|
||||||
@@ -1184,6 +1493,7 @@ export function createInteractableLayer(options = {}) {
|
|||||||
clusterId: record.clusterId,
|
clusterId: record.clusterId,
|
||||||
markers: record.markers,
|
markers: record.markers,
|
||||||
clusterPointSize: record.pointSize,
|
clusterPointSize: record.pointSize,
|
||||||
|
transitionStartedAt: record.transitionStartedAt || getNowMs(),
|
||||||
};
|
};
|
||||||
clusterPointObjects.push(points);
|
clusterPointObjects.push(points);
|
||||||
clusterGroup.add(points);
|
clusterGroup.add(points);
|
||||||
@@ -1290,6 +1600,7 @@ export function createInteractableLayer(options = {}) {
|
|||||||
function refreshVisuals() {
|
function refreshVisuals() {
|
||||||
invalidateScreenAvoidance();
|
invalidateScreenAvoidance();
|
||||||
invalidateVisualState();
|
invalidateVisualState();
|
||||||
|
invalidateClusterTopology();
|
||||||
if (!pointsGroup && !clusterGroup) return;
|
if (!pointsGroup && !clusterGroup) return;
|
||||||
rebuildPointLayers();
|
rebuildPointLayers();
|
||||||
group.visible = visible;
|
group.visible = visible;
|
||||||
@@ -1298,6 +1609,7 @@ export function createInteractableLayer(options = {}) {
|
|||||||
function setData(items = []) {
|
function setData(items = []) {
|
||||||
invalidateScreenAvoidance();
|
invalidateScreenAvoidance();
|
||||||
invalidateVisualState();
|
invalidateVisualState();
|
||||||
|
invalidateClusterTopology();
|
||||||
unregisterLayerAvoidance(id);
|
unregisterLayerAvoidance(id);
|
||||||
markers.length = 0;
|
markers.length = 0;
|
||||||
clearRenderObjects();
|
clearRenderObjects();
|
||||||
@@ -1407,6 +1719,7 @@ export function createInteractableLayer(options = {}) {
|
|||||||
function clearData(parent) {
|
function clearData(parent) {
|
||||||
invalidateScreenAvoidance();
|
invalidateScreenAvoidance();
|
||||||
invalidateVisualState();
|
invalidateVisualState();
|
||||||
|
invalidateClusterTopology();
|
||||||
unregisterLayerAvoidance(id);
|
unregisterLayerAvoidance(id);
|
||||||
markers.length = 0;
|
markers.length = 0;
|
||||||
clearRenderObjects();
|
clearRenderObjects();
|
||||||
@@ -1427,6 +1740,7 @@ export function createInteractableLayer(options = {}) {
|
|||||||
visible = Boolean(nextVisible);
|
visible = Boolean(nextVisible);
|
||||||
invalidateScreenAvoidance();
|
invalidateScreenAvoidance();
|
||||||
invalidateVisualState();
|
invalidateVisualState();
|
||||||
|
invalidateClusterTopology();
|
||||||
group.visible = visible;
|
group.visible = visible;
|
||||||
if (pointsGroup) {
|
if (pointsGroup) {
|
||||||
pointsGroup.visible = visible;
|
pointsGroup.visible = visible;
|
||||||
@@ -1478,7 +1792,8 @@ export function createInteractableLayer(options = {}) {
|
|||||||
if (
|
if (
|
||||||
nextStateKey === lastVisualStateKey &&
|
nextStateKey === lastVisualStateKey &&
|
||||||
!(pulse.enabled && hasFocus) &&
|
!(pulse.enabled && hasFocus) &&
|
||||||
!dynamicVisuals
|
!dynamicVisuals &&
|
||||||
|
!hasActiveRenderTransitions()
|
||||||
) return;
|
) return;
|
||||||
lastVisualStateKey = nextStateKey;
|
lastVisualStateKey = nextStateKey;
|
||||||
|
|
||||||
@@ -1493,21 +1808,35 @@ export function createInteractableLayer(options = {}) {
|
|||||||
}
|
}
|
||||||
updatePointColors(points, compactDotMode);
|
updatePointColors(points, compactDotMode);
|
||||||
points.visible = visible;
|
points.visible = visible;
|
||||||
|
const transitionProgress = getTransitionProgress(
|
||||||
|
points.userData?.transitionStartedAt,
|
||||||
|
clusterConfig.transitionMs,
|
||||||
|
);
|
||||||
|
const transitionScale = 0.82 + transitionProgress * 0.18;
|
||||||
points.material.opacity =
|
points.material.opacity =
|
||||||
getPointOpacity?.(sampleMarker) ??
|
getPointOpacity?.(sampleMarker) ??
|
||||||
(hasFocus ? dimmedOpacity : baseOpacity);
|
(hasFocus ? dimmedOpacity : baseOpacity) * transitionProgress;
|
||||||
points.material.size =
|
points.material.size =
|
||||||
(compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) *
|
(compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) *
|
||||||
getPointSizeMultiplier(sampleMarker) *
|
getPointSizeMultiplier(sampleMarker) *
|
||||||
cameraScale *
|
cameraScale *
|
||||||
(hasFocus ? dimmedScale : 1);
|
(hasFocus ? dimmedScale : 1) *
|
||||||
|
transitionScale;
|
||||||
});
|
});
|
||||||
clusterPointObjects.forEach((points) => {
|
clusterPointObjects.forEach((points) => {
|
||||||
points.visible = visible;
|
points.visible = visible;
|
||||||
points.material.opacity = hasFocus ? dimmedOpacity : baseOpacity;
|
const transitionProgress = getTransitionProgress(
|
||||||
|
points.userData?.transitionStartedAt,
|
||||||
|
clusterConfig.transitionMs,
|
||||||
|
);
|
||||||
|
const transitionScale = 0.68 + transitionProgress * 0.32;
|
||||||
|
const pulseScale = 1 + Math.sin(Date.now() / 260) * 0.018;
|
||||||
|
points.material.opacity = (hasFocus ? dimmedOpacity : baseOpacity) * transitionProgress;
|
||||||
points.material.size =
|
points.material.size =
|
||||||
(points.userData?.clusterPointSize || COMPACT_DOT_POINT_SIZE) *
|
(points.userData?.clusterPointSize || COMPACT_DOT_POINT_SIZE) *
|
||||||
(hasFocus ? dimmedScale : 1);
|
(hasFocus ? dimmedScale : 1) *
|
||||||
|
transitionScale *
|
||||||
|
pulseScale;
|
||||||
});
|
});
|
||||||
|
|
||||||
const hoverMarker = markers.find(
|
const hoverMarker = markers.find(
|
||||||
|
|||||||
@@ -257,6 +257,11 @@ const vesselIconLayer = createInteractableLayer({
|
|||||||
vessel_kind: item.type,
|
vessel_kind: item.type,
|
||||||
baseScale: VESSEL_CONFIG.marker.baseScale,
|
baseScale: VESSEL_CONFIG.marker.baseScale,
|
||||||
}),
|
}),
|
||||||
|
cluster: {
|
||||||
|
strategy: "dynamic-screen",
|
||||||
|
enabled: true,
|
||||||
|
maxMarkersPerDot: 10,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_VESSEL_VIEWPORT = {
|
const DEFAULT_VESSEL_VIEWPORT = {
|
||||||
|
|||||||
@@ -122,6 +122,14 @@ type CollectionQueueItem = {
|
|||||||
completedAt?: number
|
completedAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DatasourceMetricBaseline = {
|
||||||
|
taskId: string
|
||||||
|
sourceId: string
|
||||||
|
source: string
|
||||||
|
taskType: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_DATASOURCE_FILTERS: DatasourceFilters = {
|
const DEFAULT_DATASOURCE_FILTERS: DatasourceFilters = {
|
||||||
product: '',
|
product: '',
|
||||||
module: '',
|
module: '',
|
||||||
@@ -130,9 +138,12 @@ const DEFAULT_DATASOURCE_FILTERS: DatasourceFilters = {
|
|||||||
dataStatus: '',
|
dataStatus: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATASOURCE_TERMINAL_STATUSES = new Set(['success', 'completed', 'failed', 'cancelled'])
|
const DATASOURCE_FILTER_STORAGE_KEY = 'planet.admin.datasource.filters'
|
||||||
|
const DATASOURCE_FILTER_QUERY_KEYS = ['product', 'module', 'is_active', 'run_status', 'data_status']
|
||||||
|
const DATASOURCE_TERMINAL_STATUSES = new Set(['success', 'completed', 'failed', 'cancelled', 'canceled', 'stopped'])
|
||||||
const COLLECTION_QUEUE_ACTIVE_STATUSES = new Set<CollectionQueueStatus>(['queued', 'running', 'cancelling'])
|
const COLLECTION_QUEUE_ACTIVE_STATUSES = new Set<CollectionQueueStatus>(['queued', 'running', 'cancelling'])
|
||||||
const TASK_ACTIVE_STATUSES = new Set(['queued', 'pending', 'running', 'cancelling'])
|
const TASK_ACTIVE_STATUSES = new Set(['queued', 'pending', 'running', 'cancelling'])
|
||||||
|
const TASK_INACTIVE_STATUSES = new Set(['success', 'completed', 'failed', 'error', 'cancelled', 'canceled', 'stopped', 'idle'])
|
||||||
|
|
||||||
interface PlaygroundApiMessage {
|
interface PlaygroundApiMessage {
|
||||||
id: string
|
id: string
|
||||||
@@ -213,15 +224,33 @@ function pick(record: AnyRecord, keys: string[], fallback = '-') {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCountZh(value: number) {
|
||||||
|
if (!Number.isFinite(value)) return '-'
|
||||||
|
const count = Math.max(0, Math.round(value))
|
||||||
|
if (count >= 100000000) return `${(count / 100000000).toFixed(count >= 1000000000 ? 1 : 2).replace(/\.0+$/, '')} 亿条`
|
||||||
|
if (count >= 10000) return `${(count / 10000).toFixed(count >= 100000 ? 1 : 2).replace(/\.0+$/, '')} 万条`
|
||||||
|
return `${count.toLocaleString('zh-CN')} 条`
|
||||||
|
}
|
||||||
|
|
||||||
|
function datasourceRecordCount(record: AnyRecord) {
|
||||||
|
const candidates = [record.__metric_count, record.collected_records, record.record_count, record.records, record.count, record.total]
|
||||||
|
for (const value of candidates) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||||
|
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function statusTone(value: string): Tone {
|
function statusTone(value: string): Tone {
|
||||||
const lower = value.toLowerCase()
|
const lower = value.toLowerCase()
|
||||||
if (/(默认|default)/.test(lower)) return 'info'
|
if (/(默认|default)/.test(lower)) return 'info'
|
||||||
if (/(配置错误|校验失败|连接失败|failed|error|critical|down|danger|unresolved)/.test(lower)) return 'danger'
|
if (/(失败|配置错误|校验失败|连接失败|failed|error|critical|down|danger|unresolved)/.test(lower)) return 'danger'
|
||||||
|
if (/(取消|停止|已停止|cancelled|canceled|stopped)/.test(lower)) return 'neutral'
|
||||||
if (/(未配置|未启用|停用|禁用|disabled|false|missing|empty|none|可选|optional|-)/.test(lower)) return 'neutral'
|
if (/(未配置|未启用|停用|禁用|disabled|false|missing|empty|none|可选|optional|-)/.test(lower)) return 'neutral'
|
||||||
if (/(已配置|configured|running|active|enabled|success|ok|healthy|connected|resolved|ack|true|valid|已读取|已上传|已提交|可用|启用)/.test(lower)) return 'success'
|
if (/(运行中|采集中|同步中|加载中|排队中|pending|queued|loading|sync|collect|live|stream|删除中|清缓存中|刷新中|任务中|cancelling)/.test(lower)) return 'running'
|
||||||
|
if (/(成功|完成|已完成|已配置|configured|running|active|enabled|success|completed|done|ok|healthy|connected|resolved|ack|true|valid|已读取|已上传|已提交|可用|启用)/.test(lower)) return 'success'
|
||||||
if (/(pending|queued|warning|degraded|partial|waiting|unknown)/.test(lower)) return 'warning'
|
if (/(pending|queued|warning|degraded|partial|waiting|unknown)/.test(lower)) return 'warning'
|
||||||
if (/(ai|brief|model|provider|prompt)/.test(lower)) return 'ai'
|
if (/(ai|brief|model|provider|prompt)/.test(lower)) return 'ai'
|
||||||
if (/(loading|sync|collect|live|stream|删除中|清缓存中|刷新中|任务中|cancelling)/.test(lower)) return 'running'
|
|
||||||
return 'neutral'
|
return 'neutral'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,11 +279,15 @@ function recordMetric(record: AnyRecord) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function datasourceMetric(record: AnyRecord) {
|
function datasourceMetric(record: AnyRecord) {
|
||||||
if (typeof record.collected_records === 'number') return `${record.collected_records} records`
|
if (typeof record.__metric === 'string' && record.__metric) return record.__metric
|
||||||
|
if (typeof record.__metric_count === 'number') return formatCountZh(record.__metric_count)
|
||||||
|
if (typeof record.collected_records === 'number') return formatCountZh(record.collected_records)
|
||||||
if (typeof record.records_processed === 'number' && typeof record.total_records === 'number') {
|
if (typeof record.records_processed === 'number' && typeof record.total_records === 'number') {
|
||||||
return `${record.records_processed}/${record.total_records} records`
|
return `${formatCountZh(record.records_processed)} / ${formatCountZh(record.total_records)}`
|
||||||
}
|
}
|
||||||
if (typeof record.records_processed === 'number') return `${record.records_processed} records`
|
if (typeof record.records_processed === 'number') return formatCountZh(record.records_processed)
|
||||||
|
const count = datasourceRecordCount(record)
|
||||||
|
if (count !== null) return formatCountZh(count)
|
||||||
return pick(record, ['record_count', 'count', 'total', 'value', 'records'], '-')
|
return pick(record, ['record_count', 'count', 'total', 'value', 'records'], '-')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,11 +296,20 @@ function activeDatasourceTaskType(record: AnyRecord) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function activeDatasourceTaskStatus(record: AnyRecord) {
|
function activeDatasourceTaskStatus(record: AnyRecord) {
|
||||||
return text(record.task_status || record.status || record.phase, '').toLowerCase()
|
const candidates = [record.task_status, record.phase, record.status]
|
||||||
|
.map((value) => text(value, '').toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
return candidates.find((status) => TASK_INACTIVE_STATUSES.has(status))
|
||||||
|
|| candidates.find((status) => TASK_ACTIVE_STATUSES.has(status))
|
||||||
|
|| text(record.last_status, '').toLowerCase()
|
||||||
|
|| candidates[0]
|
||||||
|
|| ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasActiveDatasourceTask(record: AnyRecord) {
|
function hasActiveDatasourceTask(record: AnyRecord) {
|
||||||
return record.is_task_active === true || TASK_ACTIVE_STATUSES.has(activeDatasourceTaskStatus(record))
|
const status = activeDatasourceTaskStatus(record)
|
||||||
|
if (TASK_INACTIVE_STATUSES.has(status)) return false
|
||||||
|
return record.is_task_active === true || TASK_ACTIVE_STATUSES.has(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCollectTaskActive(record: AnyRecord) {
|
function isCollectTaskActive(record: AnyRecord) {
|
||||||
@@ -276,24 +318,43 @@ function isCollectTaskActive(record: AnyRecord) {
|
|||||||
|
|
||||||
function datasourceStatus(record: AnyRecord) {
|
function datasourceStatus(record: AnyRecord) {
|
||||||
if (isCollectTaskActive(record)) return 'running'
|
if (isCollectTaskActive(record)) return 'running'
|
||||||
|
const status = [record.task_status, record.phase, record.status, record.last_status]
|
||||||
|
.map((value) => text(value, '').toLowerCase())
|
||||||
|
.find((value) => value && TASK_INACTIVE_STATUSES.has(value))
|
||||||
|
if (status) return status
|
||||||
return text(record.last_status || record.status, record.is_active === false ? 'disabled' : 'idle')
|
return text(record.last_status || record.status, record.is_active === false ? 'disabled' : 'idle')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function taskTerminalDisplayStatus(taskType: string, status: string) {
|
||||||
|
const type = text(taskType, 'collect')
|
||||||
|
const lower = text(status, '').toLowerCase()
|
||||||
|
const noun = taskTypeLabel(type)
|
||||||
|
if (lower === 'success' || lower === 'completed') return `${noun}成功`
|
||||||
|
if (lower === 'failed' || lower === 'error') return `${noun}失败`
|
||||||
|
if (lower === 'cancelled' || lower === 'canceled') return `${noun}已取消`
|
||||||
|
if (lower === 'stopped') return `${noun}已停止`
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
function datasourceDisplayStatus(record: AnyRecord) {
|
function datasourceDisplayStatus(record: AnyRecord) {
|
||||||
if (!hasActiveDatasourceTask(record)) return datasourceStatus(record)
|
|
||||||
const taskType = activeDatasourceTaskType(record)
|
const taskType = activeDatasourceTaskType(record)
|
||||||
|
const status = activeDatasourceTaskStatus(record) || text(datasourceStatus(record), '').toLowerCase()
|
||||||
|
if (status === 'queued' || status === 'pending') return `${taskTypeLabel(taskType)}排队中`
|
||||||
|
if (status === 'running' || status === 'collecting') return `${taskTypeLabel(taskType)}中`
|
||||||
|
if (status === 'cancelling') return `停止${taskTypeLabel(taskType)}中`
|
||||||
|
if (!hasActiveDatasourceTask(record)) return datasourceStatus(record)
|
||||||
if (taskType === 'clear_data') return '删除中'
|
if (taskType === 'clear_data') return '删除中'
|
||||||
if (taskType === 'clear_cache') return '清缓存中'
|
if (taskType === 'clear_cache') return '清缓存中'
|
||||||
if (taskType === 'earth_refresh') return '刷新中'
|
if (taskType === 'earth_refresh') return '刷新中'
|
||||||
if (taskType === 'collect') return activeDatasourceTaskStatus(record) === 'queued' ? '排队中' : '运行中'
|
if (taskType === 'collect') return '采集中'
|
||||||
return '任务中'
|
return `${taskTypeLabel(taskType)}中`
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueStatusFromTask(statusValue: unknown, isRunning?: unknown): CollectionQueueStatus {
|
function queueStatusFromTask(statusValue: unknown, isRunning?: unknown): CollectionQueueStatus {
|
||||||
const status = text(statusValue, '').toLowerCase()
|
const status = text(statusValue, '').toLowerCase()
|
||||||
if (status === 'success' || status === 'completed') return 'success'
|
if (status === 'success' || status === 'completed') return 'success'
|
||||||
if (status === 'failed' || status === 'error') return 'failed'
|
if (status === 'failed' || status === 'error') return 'failed'
|
||||||
if (status === 'cancelled' || status === 'canceled') return 'cancelled'
|
if (status === 'cancelled' || status === 'canceled' || status === 'stopped') return 'cancelled'
|
||||||
if (status === 'skipped') return 'skipped'
|
if (status === 'skipped') return 'skipped'
|
||||||
if (status === 'queued' || status === 'pending') return 'queued'
|
if (status === 'queued' || status === 'pending') return 'queued'
|
||||||
if (status === 'cancelling') return 'cancelling'
|
if (status === 'cancelling') return 'cancelling'
|
||||||
@@ -302,12 +363,17 @@ function queueStatusFromTask(statusValue: unknown, isRunning?: unknown): Collect
|
|||||||
}
|
}
|
||||||
|
|
||||||
function queueItemKey(item: AnyRecord) {
|
function queueItemKey(item: AnyRecord) {
|
||||||
|
const taskType = text(item.task_type || item.taskType, 'collect')
|
||||||
const taskId = text(item.task_id || item.taskId, '')
|
const taskId = text(item.task_id || item.taskId, '')
|
||||||
if (taskId) return `task:${taskId}`
|
if (taskId) return `task:${taskType}:${taskId}`
|
||||||
const sourceId = text(item.datasource_id || item.source_id || item.id || item.sourceId, '')
|
const sourceId = text(item.datasource_id || item.source_id || item.id || item.sourceId, '')
|
||||||
if (sourceId) return `source:${sourceId}`
|
if (sourceId) return `source:${taskType}:${sourceId}`
|
||||||
const source = text(item.collector_name || item.source, '')
|
const source = text(item.collector_name || item.source, '')
|
||||||
return source ? `source-name:${source}` : `queue:${Date.now()}`
|
return source ? `source-name:${taskType}:${source}` : `queue:${taskType}:${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameQueueTaskType(left?: string, right?: string) {
|
||||||
|
return text(left, 'collect') === text(right, 'collect')
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActiveQueueStatus(status: CollectionQueueStatus) {
|
function isActiveQueueStatus(status: CollectionQueueStatus) {
|
||||||
@@ -330,21 +396,69 @@ function taskTypeLabel(taskType?: string) {
|
|||||||
return labels[text(taskType, 'collect')] || '任务'
|
return labels[text(taskType, 'collect')] || '任务'
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueStatusLabel(status: CollectionQueueStatus, taskType?: string) {
|
function queueStatusLabel(status: CollectionQueueStatus | string, taskType?: string) {
|
||||||
const noun = taskTypeLabel(taskType)
|
const noun = taskTypeLabel(taskType)
|
||||||
if (status === 'queued') return `${noun}排队中`
|
if (status === 'queued') return `${noun}排队中`
|
||||||
if (status === 'running') return `${noun}中`
|
if (status === 'running') return `${noun}中`
|
||||||
if (status === 'cancelling') return `停止${noun}中`
|
if (status === 'cancelling') return `停止${noun}中`
|
||||||
const labels: Record<CollectionQueueStatus, string> = {
|
const labels: Record<string, string> = {
|
||||||
queued: `${noun}排队中`,
|
queued: `${noun}排队中`,
|
||||||
running: `${noun}中`,
|
running: `${noun}中`,
|
||||||
cancelling: `停止${noun}中`,
|
cancelling: `停止${noun}中`,
|
||||||
success: '已完成',
|
success: `${noun}成功`,
|
||||||
failed: '失败',
|
completed: `${noun}成功`,
|
||||||
|
failed: `${noun}失败`,
|
||||||
|
error: `${noun}失败`,
|
||||||
skipped: '跳过',
|
skipped: '跳过',
|
||||||
cancelled: '已取消',
|
cancelled: `${noun}已取消`,
|
||||||
|
canceled: `${noun}已取消`,
|
||||||
|
stopped: `${noun}已停止`,
|
||||||
|
idle: '空闲',
|
||||||
}
|
}
|
||||||
return labels[status]
|
return labels[status] || semanticLabel(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuePrimaryMessage(item: CollectionQueueItem) {
|
||||||
|
const type = text(item.taskType, 'collect')
|
||||||
|
if (item.status === 'queued') return queueStatusLabel('queued', type)
|
||||||
|
if (item.status === 'running') {
|
||||||
|
if (type === 'clear_data') return '正在删除数据'
|
||||||
|
if (type === 'clear_cache') return '正在清理缓存'
|
||||||
|
if (type === 'earth_refresh') return '正在刷新图层'
|
||||||
|
return '正在采集'
|
||||||
|
}
|
||||||
|
if (item.status === 'cancelling') {
|
||||||
|
if (type === 'clear_data') return '正在取消删除'
|
||||||
|
if (type === 'collect') return '正在停止采集'
|
||||||
|
return '正在取消任务'
|
||||||
|
}
|
||||||
|
if (item.status === 'success') {
|
||||||
|
if (type === 'clear_data') return '删除完成'
|
||||||
|
if (type === 'clear_cache') return '清缓存完成'
|
||||||
|
if (type === 'earth_refresh') return '刷新完成'
|
||||||
|
return '采集完成'
|
||||||
|
}
|
||||||
|
if (item.status === 'failed') {
|
||||||
|
if (type === 'clear_data') return '删除失败'
|
||||||
|
if (type === 'clear_cache') return '清缓存失败'
|
||||||
|
if (type === 'earth_refresh') return '刷新失败'
|
||||||
|
return '采集失败'
|
||||||
|
}
|
||||||
|
if (item.status === 'cancelled') {
|
||||||
|
if (type === 'clear_data') return '删除已取消'
|
||||||
|
if (type === 'collect') return '采集已取消'
|
||||||
|
return '任务已取消'
|
||||||
|
}
|
||||||
|
if (item.status === 'skipped') return item.reason ? queueReasonLabel(item.reason) : '已跳过'
|
||||||
|
return queueStatusLabel(item.status, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotStatus(record: AnyRecord) {
|
||||||
|
const status = text(record.status, '').toLowerCase()
|
||||||
|
if (status === 'running' && text(record.completed_at || record.completedAt, '')) return 'success'
|
||||||
|
if (status) return status
|
||||||
|
if (record.is_current === true) return '当前'
|
||||||
|
return '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueReasonLabel(reason = '') {
|
function queueReasonLabel(reason = '') {
|
||||||
@@ -365,15 +479,27 @@ function formatDuration(startedAt: number, endedAt = Date.now()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function datasourceTableRow(row: AnyRecord) {
|
function datasourceTableRow(row: AnyRecord) {
|
||||||
|
const displayStatus = datasourceDisplayStatus(row)
|
||||||
|
const taskStatus = text(row.task_status || row.phase || row.status || row.last_status, '')
|
||||||
|
const terminalStatus = taskTerminalDisplayStatus(activeDatasourceTaskType(row), taskStatus)
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
__module: pick(row, ['module', 'source'], '数据源'),
|
__module: pick(row, ['module', 'source'], '数据源'),
|
||||||
__status: datasourceDisplayStatus(row),
|
__status: terminalStatus || displayStatus,
|
||||||
__metric: datasourceMetric(row),
|
__metric: datasourceMetric(row),
|
||||||
__time: pick(row, ['last_run_at', 'last_run'], '-'),
|
__time: pick(row, ['last_run_at', 'last_run'], '-'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordDisplayStatus(record: AnyRecord) {
|
||||||
|
const endpointKey = text(record.__endpointKey, '')
|
||||||
|
if (endpointKey === 'builtin' || record.is_task_active !== undefined || record.task_type || record.task_status) {
|
||||||
|
const taskStatus = text(record.task_status || record.phase || record.status || record.last_status, '')
|
||||||
|
return taskTerminalDisplayStatus(activeDatasourceTaskType(record), taskStatus) || datasourceDisplayStatus(record)
|
||||||
|
}
|
||||||
|
return semanticLabel(recordStatus(record))
|
||||||
|
}
|
||||||
|
|
||||||
function makeAction(label: string, icon: ReactNode, to: string) {
|
function makeAction(label: string, icon: ReactNode, to: string) {
|
||||||
return { label, icon, to }
|
return { label, icon, to }
|
||||||
}
|
}
|
||||||
@@ -566,7 +692,10 @@ function defaultColumns(onSelect: (record: TableRecord) => void): Array<ColumnDe
|
|||||||
id: 'status',
|
id: 'status',
|
||||||
header: '状态',
|
header: '状态',
|
||||||
size: 130,
|
size: 130,
|
||||||
cell: ({ row }) => <StatusText tone={statusTone(recordStatus(row.original))}>{recordStatus(row.original)}</StatusText>,
|
cell: ({ row }) => {
|
||||||
|
const status = recordDisplayStatus(row.original)
|
||||||
|
return <StatusText tone={statusTone(status)}>{status}</StatusText>
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ id: 'metric', header: '指标', size: 220, cell: ({ row }) => <span className="an-muted-text">{semanticLabel(recordMetric(row.original))}</span> },
|
{ id: 'metric', header: '指标', size: 220, cell: ({ row }) => <span className="an-muted-text">{semanticLabel(recordMetric(row.original))}</span> },
|
||||||
{ id: 'updated', header: '更新时间', size: 180, cell: ({ row }) => row.original.__time },
|
{ id: 'updated', header: '更新时间', size: 180, cell: ({ row }) => row.original.__time },
|
||||||
@@ -824,7 +953,9 @@ function semanticLabel(value: unknown) {
|
|||||||
const lower = raw.toLowerCase()
|
const lower = raw.toLowerCase()
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
success: '成功',
|
success: '成功',
|
||||||
|
completed: '完成',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
|
error: '失败',
|
||||||
running: '运行中',
|
running: '运行中',
|
||||||
pending: '等待中',
|
pending: '等待中',
|
||||||
queued: '排队中',
|
queued: '排队中',
|
||||||
@@ -997,6 +1128,59 @@ function datasourceFiltersFromSearch(search: string): DatasourceFilters {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasDatasourceFilterSearch(search: string) {
|
||||||
|
const params = new URLSearchParams(search)
|
||||||
|
return DATASOURCE_FILTER_QUERY_KEYS.some((key) => params.has(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDatasourceFilters(value: Partial<DatasourceFilters> | null | undefined): DatasourceFilters {
|
||||||
|
return {
|
||||||
|
product: text(value?.product, DEFAULT_DATASOURCE_FILTERS.product),
|
||||||
|
module: text(value?.module, DEFAULT_DATASOURCE_FILTERS.module),
|
||||||
|
isActive: ['true', 'false', ''].includes(text(value?.isActive, '')) ? text(value?.isActive, DEFAULT_DATASOURCE_FILTERS.isActive) : DEFAULT_DATASOURCE_FILTERS.isActive,
|
||||||
|
runStatus: text(value?.runStatus, DEFAULT_DATASOURCE_FILTERS.runStatus),
|
||||||
|
dataStatus: text(value?.dataStatus, DEFAULT_DATASOURCE_FILTERS.dataStatus),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStoredDatasourceFilters(): DatasourceFilters {
|
||||||
|
if (typeof window === 'undefined') return DEFAULT_DATASOURCE_FILTERS
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(DATASOURCE_FILTER_STORAGE_KEY)
|
||||||
|
if (!raw) return DEFAULT_DATASOURCE_FILTERS
|
||||||
|
return normalizeDatasourceFilters(JSON.parse(raw) as Partial<DatasourceFilters>)
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_DATASOURCE_FILTERS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeDatasourceFilters(filters: DatasourceFilters) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem(DATASOURCE_FILTER_STORAGE_KEY, JSON.stringify(filters))
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialDatasourceFilters(search: string): DatasourceFilters {
|
||||||
|
return hasDatasourceFilterSearch(search) ? datasourceFiltersFromSearch(search) : loadStoredDatasourceFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
function datasourceFiltersEqual(left: DatasourceFilters, right: DatasourceFilters) {
|
||||||
|
return left.product === right.product &&
|
||||||
|
left.module === right.module &&
|
||||||
|
left.isActive === right.isActive &&
|
||||||
|
left.runStatus === right.runStatus &&
|
||||||
|
left.dataStatus === right.dataStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
function datasourceFiltersSearch(filters: DatasourceFilters) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('product', filters.product)
|
||||||
|
params.set('module', filters.module)
|
||||||
|
params.set('is_active', filters.isActive)
|
||||||
|
params.set('run_status', filters.runStatus)
|
||||||
|
params.set('data_status', filters.dataStatus)
|
||||||
|
return `?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
function datasourceFiltersToParams(filters: DatasourceFilters) {
|
function datasourceFiltersToParams(filters: DatasourceFilters) {
|
||||||
const params: AnyRecord = { include_endpoint: false }
|
const params: AnyRecord = { include_endpoint: false }
|
||||||
if (filters.product) params.product = filters.product
|
if (filters.product) params.product = filters.product
|
||||||
@@ -1029,8 +1213,8 @@ function snapshotRows(payload: unknown) {
|
|||||||
...row,
|
...row,
|
||||||
__title: pick(row, ['source', 'datasource_name', 'id'], '采集快照'),
|
__title: pick(row, ['source', 'datasource_name', 'id'], '采集快照'),
|
||||||
__module: '采集快照',
|
__module: '采集快照',
|
||||||
__status: pick(row, ['status', 'is_current'], '-'),
|
__status: snapshotStatus(row),
|
||||||
__metric: typeof row.record_count === 'number' ? `${row.record_count} records` : pick(row, ['record_count'], '-'),
|
__metric: typeof row.record_count === 'number' ? formatCountZh(row.record_count) : pick(row, ['record_count'], '-'),
|
||||||
__time: pick(row, ['completed_at', 'started_at', 'created_at'], '-'),
|
__time: pick(row, ['completed_at', 'started_at', 'created_at'], '-'),
|
||||||
}))
|
}))
|
||||||
const grouped = new Map<string, AnyRecord[]>()
|
const grouped = new Map<string, AnyRecord[]>()
|
||||||
@@ -1049,7 +1233,7 @@ function snapshotRows(payload: unknown) {
|
|||||||
__rowId: `snapshot-source-${source}`,
|
__rowId: `snapshot-source-${source}`,
|
||||||
__title: title,
|
__title: title,
|
||||||
__module: '采集快照',
|
__module: '采集快照',
|
||||||
__status: pick(current, ['status', 'is_current'], '-'),
|
__status: snapshotStatus(current),
|
||||||
__metric: `${ordered.length} 个快照`,
|
__metric: `${ordered.length} 个快照`,
|
||||||
__time: pick(current, ['completed_at', 'started_at', 'created_at'], '-'),
|
__time: pick(current, ['completed_at', 'started_at', 'created_at'], '-'),
|
||||||
__snapshots: ordered,
|
__snapshots: ordered,
|
||||||
@@ -1096,8 +1280,8 @@ function formatSnapshotTime(record: AnyRecord) {
|
|||||||
|
|
||||||
function snapshotOptionLabel(record: AnyRecord) {
|
function snapshotOptionLabel(record: AnyRecord) {
|
||||||
const current = record.is_current === true ? '当前 · ' : ''
|
const current = record.is_current === true ? '当前 · ' : ''
|
||||||
const status = semanticLabel(recordStatus(record))
|
const status = semanticLabel(snapshotStatus(record))
|
||||||
const count = typeof record.record_count === 'number' ? `${record.record_count} records` : pick(record, ['record_count'], '0 records')
|
const count = typeof record.record_count === 'number' ? formatCountZh(record.record_count) : pick(record, ['record_count'], '0 条')
|
||||||
return `${current}${formatSnapshotTime(record)} · ${status} · ${count}`
|
return `${current}${formatSnapshotTime(record)} · ${status} · ${count}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2098,7 +2282,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [states, setStates] = useState<SectionState[]>([])
|
const [states, setStates] = useState<SectionState[]>([])
|
||||||
const [activeSectionKey, setActiveSectionKey] = useState(config.sections[0]?.key || '')
|
const [activeSectionKey, setActiveSectionKey] = useState(config.sections[0]?.key || '')
|
||||||
const [datasourceFilters, setDatasourceFilters] = useState<DatasourceFilters>(() => datasourceFiltersFromSearch(location.search))
|
const [datasourceFilters, setDatasourceFilters] = useState<DatasourceFilters>(() => initialDatasourceFilters(location.search))
|
||||||
const datasourceFiltersRef = useRef(datasourceFilters)
|
const datasourceFiltersRef = useRef(datasourceFilters)
|
||||||
const [activeGroupKey, setActiveGroupKey] = useState('')
|
const [activeGroupKey, setActiveGroupKey] = useState('')
|
||||||
const [hierarchyDraft, setHierarchyDraft] = useState('')
|
const [hierarchyDraft, setHierarchyDraft] = useState('')
|
||||||
@@ -2114,6 +2298,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
const [datasourceSelectedRowIds, setDatasourceSelectedRowIds] = useState<Set<string>>(() => new Set())
|
const [datasourceSelectedRowIds, setDatasourceSelectedRowIds] = useState<Set<string>>(() => new Set())
|
||||||
const pendingDatasourceTasksRef = useRef<Record<string, { sourceId: string; source?: string; name: string; taskId?: number | string | null; completed?: boolean }>>({})
|
const pendingDatasourceTasksRef = useRef<Record<string, { sourceId: string; source?: string; name: string; taskId?: number | string | null; completed?: boolean }>>({})
|
||||||
const completedDatasourceTasksRef = useRef<Set<string>>(new Set())
|
const completedDatasourceTasksRef = useRef<Set<string>>(new Set())
|
||||||
|
const datasourceMetricBaselinesRef = useRef<Record<string, DatasourceMetricBaseline>>({})
|
||||||
const datasourcePollTimersRef = useRef<Record<string, number>>({})
|
const datasourcePollTimersRef = useRef<Record<string, number>>({})
|
||||||
const [selected, setSelected] = useState<TableRecord | null>(null)
|
const [selected, setSelected] = useState<TableRecord | null>(null)
|
||||||
const [selectedHistory, setSelectedHistory] = useState<TableRecord[]>([])
|
const [selectedHistory, setSelectedHistory] = useState<TableRecord[]>([])
|
||||||
@@ -2205,14 +2390,11 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config !== configs.datasources) return
|
if (config !== configs.datasources) return
|
||||||
const next = datasourceFiltersFromSearch(location.search)
|
const next = initialDatasourceFilters(location.search)
|
||||||
const current = datasourceFiltersRef.current
|
const current = datasourceFiltersRef.current
|
||||||
const unchanged = current.product === next.product &&
|
const unchanged = datasourceFiltersEqual(current, next)
|
||||||
current.module === next.module &&
|
|
||||||
current.isActive === next.isActive &&
|
|
||||||
current.runStatus === next.runStatus &&
|
|
||||||
current.dataStatus === next.dataStatus
|
|
||||||
if (!unchanged) setDatasourceSelectedRowIds(new Set())
|
if (!unchanged) setDatasourceSelectedRowIds(new Set())
|
||||||
|
storeDatasourceFilters(next)
|
||||||
setDatasourceFilters((filters) => unchanged ? filters : next)
|
setDatasourceFilters((filters) => unchanged ? filters : next)
|
||||||
}, [config, location.search])
|
}, [config, location.search])
|
||||||
|
|
||||||
@@ -2220,25 +2402,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
const next = { ...datasourceFiltersRef.current, [key]: value }
|
const next = { ...datasourceFiltersRef.current, [key]: value }
|
||||||
setDatasourceSelectedRowIds(new Set())
|
setDatasourceSelectedRowIds(new Set())
|
||||||
setDatasourceFilters(next)
|
setDatasourceFilters(next)
|
||||||
const params = new URLSearchParams(location.search)
|
storeDatasourceFilters(next)
|
||||||
const queryKeyByFilter: Record<keyof DatasourceFilters, string> = {
|
navigate({ pathname: location.pathname, search: datasourceFiltersSearch(next) }, { replace: true })
|
||||||
product: 'product',
|
|
||||||
module: 'module',
|
|
||||||
isActive: 'is_active',
|
|
||||||
runStatus: 'run_status',
|
|
||||||
dataStatus: 'data_status',
|
|
||||||
}
|
|
||||||
;(Object.keys(queryKeyByFilter) as Array<keyof DatasourceFilters>).forEach((filterKey) => {
|
|
||||||
const queryKey = queryKeyByFilter[filterKey]
|
|
||||||
const defaultValue = DEFAULT_DATASOURCE_FILTERS[filterKey]
|
|
||||||
const nextValue = next[filterKey]
|
|
||||||
if (!nextValue || nextValue === defaultValue) {
|
|
||||||
params.delete(queryKey)
|
|
||||||
} else {
|
|
||||||
params.set(queryKey, nextValue)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionRequestParams = useCallback((section: SectionConfig, baseParams?: AnyRecord) => {
|
const sectionRequestParams = useCallback((section: SectionConfig, baseParams?: AnyRecord) => {
|
||||||
@@ -2350,8 +2515,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
const index = current.findIndex((existing) => (
|
const index = current.findIndex((existing) => (
|
||||||
existing.key === item.key
|
existing.key === item.key
|
||||||
|| (item.taskId && existing.taskId === item.taskId)
|
|| (item.taskId && existing.taskId === item.taskId)
|
||||||
|| (isActiveQueueStatus(existing.status) && item.sourceId && existing.sourceId === item.sourceId)
|
|| (isActiveQueueStatus(existing.status) && isSameQueueTaskType(existing.taskType, item.taskType) && item.sourceId && existing.sourceId === item.sourceId)
|
||||||
|| (isActiveQueueStatus(existing.status) && item.source && existing.source === item.source)
|
|| (isActiveQueueStatus(existing.status) && isSameQueueTaskType(existing.taskType, item.taskType) && item.source && existing.source === item.source)
|
||||||
))
|
))
|
||||||
if (index < 0) return [item, ...current]
|
if (index < 0) return [item, ...current]
|
||||||
const next = [...current]
|
const next = [...current]
|
||||||
@@ -2365,8 +2530,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
const source = text(payload.collector_name || payload.source, '')
|
const source = text(payload.collector_name || payload.source, '')
|
||||||
const taskId = payload.task_id as number | string | null | undefined
|
const taskId = payload.task_id as number | string | null | undefined
|
||||||
const status = queueStatusFromTask(payload.status || payload.phase, payload.is_running)
|
const status = queueStatusFromTask(payload.status || payload.phase, payload.is_running)
|
||||||
|
const payloadTaskType = text(payload.task_type, '')
|
||||||
setCollectionQueue((current) => current.map((item) => {
|
setCollectionQueue((current) => current.map((item) => {
|
||||||
const matched = (taskId && item.taskId === taskId) || (sourceId && item.sourceId === sourceId) || (source && item.source === source)
|
const taskTypeMatched = !payloadTaskType || isSameQueueTaskType(item.taskType, payloadTaskType)
|
||||||
|
const matched = Boolean(taskId && item.taskId === taskId)
|
||||||
|
|| (taskTypeMatched && Boolean(sourceId && item.sourceId === sourceId))
|
||||||
|
|| (taskTypeMatched && Boolean(source && item.source === source))
|
||||||
if (!matched) return item
|
if (!matched) return item
|
||||||
const terminal = ['success', 'failed', 'cancelled', 'skipped'].includes(status)
|
const terminal = ['success', 'failed', 'cancelled', 'skipped'].includes(status)
|
||||||
return {
|
return {
|
||||||
@@ -2472,19 +2641,58 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
error_message: payload.error_message,
|
error_message: payload.error_message,
|
||||||
last_run_at: payload.completed_at || payload.started_at,
|
last_run_at: payload.completed_at || payload.started_at,
|
||||||
}
|
}
|
||||||
|
const metricKey = text(payload.task_id, '') || sourceId || source
|
||||||
|
const recordsProcessed = typeof payload.records_processed === 'number'
|
||||||
|
? payload.records_processed
|
||||||
|
: Number.isFinite(Number(payload.records_processed))
|
||||||
|
? Number(payload.records_processed)
|
||||||
|
: null
|
||||||
|
const applyLiveMetric = (row: AnyRecord, next: AnyRecord) => {
|
||||||
|
if (!metricKey || recordsProcessed === null || recordsProcessed < 0) return next
|
||||||
|
if (taskType === 'clear_data') {
|
||||||
|
let baseline = datasourceMetricBaselinesRef.current[metricKey]
|
||||||
|
if (!baseline) {
|
||||||
|
baseline = {
|
||||||
|
taskId: metricKey,
|
||||||
|
sourceId,
|
||||||
|
source,
|
||||||
|
taskType,
|
||||||
|
count: datasourceRecordCount(row) ?? 0,
|
||||||
|
}
|
||||||
|
datasourceMetricBaselinesRef.current[metricKey] = baseline
|
||||||
|
}
|
||||||
|
const nextCount = Math.max(0, baseline.count - recordsProcessed)
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
__metric_count: nextCount,
|
||||||
|
__metric: formatCountZh(nextCount),
|
||||||
|
collected_records: nextCount,
|
||||||
|
has_collected_data: nextCount > 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (taskType === 'collect' && taskActive) {
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
__metric: `已处理 ${formatCountZh(recordsProcessed)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
setStates((currentStates) => currentStates.map((state) => {
|
setStates((currentStates) => currentStates.map((state) => {
|
||||||
if (state.section.key !== 'builtin') return state
|
if (state.section.key !== 'builtin') return state
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
rows: state.rows.map((row) => {
|
rows: state.rows.map((row) => {
|
||||||
if (!isSameDatasourceRow(row, sourceId, source)) return row
|
if (!isSameDatasourceRow(row, sourceId, source)) return row
|
||||||
return normalizeDatasourceTableRecord({ ...row, ...rowPatch, id: row.id, source: row.source })
|
const merged = applyLiveMetric(row, { ...row, ...rowPatch, id: row.id, source: row.source })
|
||||||
|
return normalizeDatasourceTableRecord(merged)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
setSelected((current) => {
|
setSelected((current) => {
|
||||||
if (!current || !isSameDatasourceRow(current, sourceId, source)) return current
|
if (!current || !isSameDatasourceRow(current, sourceId, source)) return current
|
||||||
return normalizeDatasourceTableRecord({ ...current, ...rowPatch, id: current.id, source: current.source })
|
const merged = applyLiveMetric(current, { ...current, ...rowPatch, id: current.id, source: current.source })
|
||||||
|
return normalizeDatasourceTableRecord(merged)
|
||||||
})
|
})
|
||||||
patchCollectionQueueFromTask(payload)
|
patchCollectionQueueFromTask(payload)
|
||||||
}, [config, isSameDatasourceRow, normalizeDatasourceTableRecord, patchCollectionQueueFromTask])
|
}, [config, isSameDatasourceRow, normalizeDatasourceTableRecord, patchCollectionQueueFromTask])
|
||||||
@@ -2525,6 +2733,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
toast({ title: `${titleName} 采集已取消` })
|
toast({ title: `${titleName} 采集已取消` })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
delete datasourceMetricBaselinesRef.current[text(payload.task_id, '') || sourceId || source]
|
||||||
delete pendingDatasourceTasksRef.current[pendingEntry?.[0] || pendingKey]
|
delete pendingDatasourceTasksRef.current[pendingEntry?.[0] || pendingKey]
|
||||||
}, [isSameDatasourceRow, toast, updateDatasourceRow])
|
}, [isSameDatasourceRow, toast, updateDatasourceRow])
|
||||||
|
|
||||||
@@ -2847,7 +3056,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
...item,
|
...item,
|
||||||
status: 'cancelling',
|
status: 'cancelling',
|
||||||
phase: 'cancelling',
|
phase: 'cancelling',
|
||||||
phaseMessage: '正在停止任务',
|
phaseMessage: `正在停止${taskTypeLabel(item.taskType)}`,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -2931,6 +3140,61 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
datasourcePollTimersRef.current[pollKey] = window.setTimeout(poll, datasourceSocket.connected ? 3000 : 900)
|
datasourcePollTimersRef.current[pollKey] = window.setTimeout(poll, datasourceSocket.connected ? 3000 : 900)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const restoreActiveCollectionQueueTasks = useCallback(async () => {
|
||||||
|
if (config !== configs.datasources) return
|
||||||
|
try {
|
||||||
|
const response = await axios.get(apiPath('/tasks'), {
|
||||||
|
params: {
|
||||||
|
status: 'queued,running,cancelling',
|
||||||
|
page_size: 200,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dataArray(response.data).forEach((task) => {
|
||||||
|
const sourceId = text(task.datasource_id || task.source_id, '')
|
||||||
|
if (!sourceId) return
|
||||||
|
const taskId = task.id as number | string | null | undefined
|
||||||
|
const source = text(task.source || task.datasource_source, '')
|
||||||
|
const taskType = text(task.task_type, 'collect')
|
||||||
|
const record = {
|
||||||
|
id: sourceId,
|
||||||
|
source,
|
||||||
|
collector_name: source,
|
||||||
|
name: text(task.datasource_name || task.name || source, '数据源'),
|
||||||
|
task_id: taskId,
|
||||||
|
task_type: taskType,
|
||||||
|
task_status: task.status,
|
||||||
|
status: task.status,
|
||||||
|
phase: task.phase,
|
||||||
|
phase_message: task.phase_message,
|
||||||
|
progress: task.progress,
|
||||||
|
records_processed: task.records_processed,
|
||||||
|
total_records: task.total_records,
|
||||||
|
error_message: task.error_message,
|
||||||
|
__endpointKey: 'builtin',
|
||||||
|
__endpointLabel: '内置源',
|
||||||
|
__rowId: `active-task-${sourceId}-${taskId || taskType}`,
|
||||||
|
__title: text(task.datasource_name || task.name || source, '数据源'),
|
||||||
|
__module: '数据源任务',
|
||||||
|
__status: queueStatusLabel(queueStatusFromTask(task.status || task.phase, true), taskType),
|
||||||
|
__metric: taskId ? `task ${taskId}` : '-',
|
||||||
|
__time: text(task.started_at || task.completed_at, '-'),
|
||||||
|
}
|
||||||
|
upsertCollectionQueueItem(queueItemFromDatasourceRow(record, taskId, {
|
||||||
|
taskType,
|
||||||
|
status: queueStatusFromTask(task.status || task.phase, true),
|
||||||
|
phase: text(task.phase, text(task.status, '')),
|
||||||
|
phaseMessage: text(task.phase_message, ''),
|
||||||
|
progress: typeof task.progress === 'number' ? task.progress : 0,
|
||||||
|
recordsProcessed: typeof task.records_processed === 'number' ? task.records_processed : undefined,
|
||||||
|
totalRecords: typeof task.total_records === 'number' ? task.total_records : undefined,
|
||||||
|
}))
|
||||||
|
scheduleDatasourceTaskPoll(record, taskId)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Queue restore is best-effort; the table and explicit refresh still load normally.
|
||||||
|
}
|
||||||
|
}, [config, upsertCollectionQueueItem])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config !== configs.datasources) return
|
if (config !== configs.datasources) return
|
||||||
const builtinState = states.find((state) => state.section.key === 'builtin')
|
const builtinState = states.find((state) => state.section.key === 'builtin')
|
||||||
@@ -2943,7 +3207,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
const taskId = row.task_id as number | string | null | undefined
|
const taskId = row.task_id as number | string | null | undefined
|
||||||
const taskType = text(row.task_type, 'collect')
|
const taskType = text(row.task_type, 'collect')
|
||||||
upsertCollectionQueueItem({
|
upsertCollectionQueueItem({
|
||||||
key: queueItemKey({ id: sourceId, source, task_id: taskId }),
|
key: queueItemKey({ id: sourceId, source, task_id: taskId, task_type: taskType }),
|
||||||
sourceId,
|
sourceId,
|
||||||
source,
|
source,
|
||||||
name: recordTitle(row),
|
name: recordTitle(row),
|
||||||
@@ -2960,6 +3224,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
})
|
})
|
||||||
}, [config, states, upsertCollectionQueueItem])
|
}, [config, states, upsertCollectionQueueItem])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void restoreActiveCollectionQueueTasks()
|
||||||
|
}, [restoreActiveCollectionQueueTasks])
|
||||||
|
|
||||||
const clearDatasourceData = async (record: TableRecord) => {
|
const clearDatasourceData = async (record: TableRecord) => {
|
||||||
const id = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
const id = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@@ -4263,7 +4531,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
key: row.__rowId,
|
key: row.__rowId,
|
||||||
label: recordTitle(row),
|
label: recordTitle(row),
|
||||||
description: '采集快照',
|
description: '采集快照',
|
||||||
status: normalizeStatusLabel(row.is_current === true ? '当前' : recordStatus(row)),
|
status: normalizeStatusLabel(snapshotStatus(row)),
|
||||||
count: Array.isArray(row.__snapshots) ? row.__snapshots.length : 1,
|
count: Array.isArray(row.__snapshots) ? row.__snapshots.length : 1,
|
||||||
record: { ...cleanRecord(row), __sourceEndpoint: row.__endpointKey, __sourceLabel: row.__endpointLabel, __snapshots: row.__snapshots, __snapshotSourceKey: row.__snapshotSourceKey },
|
record: { ...cleanRecord(row), __sourceEndpoint: row.__endpointKey, __sourceLabel: row.__endpointLabel, __snapshots: row.__snapshots, __snapshotSourceKey: row.__snapshotSourceKey },
|
||||||
}))
|
}))
|
||||||
@@ -4796,8 +5064,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{activeSnapshot ? (
|
{activeSnapshot ? (
|
||||||
<StatusText tone={statusTone(recordStatus(activeSnapshot))}>
|
<StatusText tone={statusTone(snapshotStatus(activeSnapshot))}>
|
||||||
{semanticLabel(recordStatus(activeSnapshot))}
|
{semanticLabel(snapshotStatus(activeSnapshot))}
|
||||||
</StatusText>
|
</StatusText>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -5280,7 +5548,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
<strong>采集任务</strong>
|
<strong>采集任务</strong>
|
||||||
<p>{queueItem?.phaseMessage || text(selected.last_status || selected.phase_message, '当前没有运行中的任务。')}</p>
|
<p>{queueItem?.phaseMessage || text(selected.last_status || selected.phase_message, '当前没有运行中的任务。')}</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusText tone={statusTone(status)}>{queueStatusLabel(status as CollectionQueueStatus, queueItem?.taskType || text(selected.task_type, 'collect'))}</StatusText>
|
<StatusText tone={statusTone(status)}>{queueStatusLabel(status, queueItem?.taskType || text(selected.task_type, 'collect'))}</StatusText>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>数据源</dt><dd>{source || sourceId || '-'}</dd>
|
<dt>数据源</dt><dd>{source || sourceId || '-'}</dd>
|
||||||
<dt>任务</dt><dd>{text(taskId, '-')}</dd>
|
<dt>任务</dt><dd>{text(taskId, '-')}</dd>
|
||||||
@@ -5351,13 +5619,14 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
overrides: Partial<CollectionQueueItem> = {},
|
overrides: Partial<CollectionQueueItem> = {},
|
||||||
): CollectionQueueItem => {
|
): CollectionQueueItem => {
|
||||||
const sourceId = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
const sourceId = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||||||
|
const taskType = text(overrides.taskType || record.task_type, 'collect')
|
||||||
return {
|
return {
|
||||||
key: queueItemKey({ id: sourceId, source: record.source, task_id: taskId }),
|
key: queueItemKey({ id: sourceId, source: record.source, task_id: taskId, task_type: taskType }),
|
||||||
sourceId,
|
sourceId,
|
||||||
source: text(record.source || record.collector_name, ''),
|
source: text(record.source || record.collector_name, ''),
|
||||||
name: recordTitle(record),
|
name: recordTitle(record),
|
||||||
taskId,
|
taskId,
|
||||||
taskType: text(record.task_type, 'collect'),
|
taskType,
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
phase: 'queued',
|
phase: 'queued',
|
||||||
phaseMessage: '任务已提交',
|
phaseMessage: '任务已提交',
|
||||||
@@ -5412,8 +5681,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
<article key={item.key} className={`an-collection-queue__item is-${item.status}`}>
|
<article key={item.key} className={`an-collection-queue__item is-${item.status}`}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{item.name}</strong>
|
<strong title={item.name}>{item.name}</strong>
|
||||||
<p>{item.phaseMessage || item.error || queueStatusLabel(item.status, item.taskType)}{item.taskId ? ` · task ${item.taskId}` : ''}{item.completedAt ? ` · ${formatDuration(item.createdAt, item.completedAt)}` : ''}</p>
|
<p title={`${item.error || queuePrimaryMessage(item)}${item.taskId ? ` · task ${item.taskId}` : ''}${item.completedAt ? ` · ${formatDuration(item.createdAt, item.completedAt)}` : ''}`}>
|
||||||
|
{item.error || queuePrimaryMessage(item)}{item.taskId ? ` · task ${item.taskId}` : ''}{item.completedAt ? ` · ${formatDuration(item.createdAt, item.completedAt)}` : ''}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span>{queueProgress(item)}%</span>
|
<span>{queueProgress(item)}%</span>
|
||||||
<div className="an-collection-queue__item-actions">
|
<div className="an-collection-queue__item-actions">
|
||||||
@@ -5584,7 +5855,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
|||||||
) : null}
|
) : null}
|
||||||
<h3>{recordTitle(selected)}</h3>
|
<h3>{recordTitle(selected)}</h3>
|
||||||
</div>
|
</div>
|
||||||
<StatusText tone={statusTone(recordStatus(selected))}>{recordStatus(selected)}</StatusText>
|
<StatusText tone={statusTone(recordDisplayStatus(selected))}>{recordDisplayStatus(selected)}</StatusText>
|
||||||
</header>
|
</header>
|
||||||
{renderRecordActions()}
|
{renderRecordActions()}
|
||||||
{renderDatasourceTaskSummary()}
|
{renderDatasourceTaskSummary()}
|
||||||
|
|||||||
@@ -444,8 +444,9 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.an-collection-queue__item {
|
.an-collection-queue__item {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(0, 1fr) 44px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
@@ -455,6 +456,12 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.an-collection-queue__item-actions {
|
.an-collection-queue__item-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(4px);
|
transform: translateX(4px);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -470,6 +477,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
|||||||
|
|
||||||
.an-collection-queue__item strong,
|
.an-collection-queue__item strong,
|
||||||
.an-collection-queue__item p {
|
.an-collection-queue__item p {
|
||||||
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -522,6 +530,10 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
|||||||
transform: none;
|
transform: none;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.an-collection-queue__item {
|
||||||
|
padding-right: 104px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.an-task-summary {
|
.an-task-summary {
|
||||||
|
|||||||
@@ -111,9 +111,13 @@ export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
|
|||||||
zh: { title: '智能星球可交互图标接入', group: 'Earth', order: 16 },
|
zh: { title: '智能星球可交互图标接入', group: 'Earth', order: 16 },
|
||||||
en: { title: 'Intelligent Planet Interactable Usage', group: 'Earth', order: 16 },
|
en: { title: 'Intelligent Planet Interactable Usage', group: 'Earth', order: 16 },
|
||||||
},
|
},
|
||||||
|
'earth-interactable-clustering.md': {
|
||||||
|
zh: { title: '智能星球可交互图标聚类策略', group: 'Earth', order: 17 },
|
||||||
|
en: { title: 'Intelligent Planet Interactable Clustering', group: 'Earth', order: 17 },
|
||||||
|
},
|
||||||
'earth-toolbar-overlay-coordination.md': {
|
'earth-toolbar-overlay-coordination.md': {
|
||||||
zh: { title: '智能星球工具栏与浮层协同', group: 'Earth', order: 17 },
|
zh: { title: '智能星球工具栏与浮层协同', group: 'Earth', order: 18 },
|
||||||
en: { title: 'Intelligent Planet Toolbar and Overlay Coordination', group: 'Earth', order: 17 },
|
en: { title: 'Intelligent Planet Toolbar and Overlay Coordination', group: 'Earth', order: 18 },
|
||||||
},
|
},
|
||||||
'frontend-admin-frontend-context.md': {
|
'frontend-admin-frontend-context.md': {
|
||||||
zh: { title: '控制台前端结构', group: 'Frontend', order: 20 },
|
zh: { title: '控制台前端结构', group: 'Frontend', order: 20 },
|
||||||
|
|||||||
@@ -1803,10 +1803,7 @@ ensure_frontend_deps() {
|
|||||||
cd "$SCRIPT_DIR/frontend"
|
cd "$SCRIPT_DIR/frontend"
|
||||||
: > "$log_file"
|
: > "$log_file"
|
||||||
|
|
||||||
set_wait_detail "检查 Vite Bun 入口是否已安装"
|
set_wait_detail "同步前端依赖"
|
||||||
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
|
|
||||||
log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})"
|
|
||||||
set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install"
|
|
||||||
if ! run_with_retry \
|
if ! run_with_retry \
|
||||||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||||||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||||||
@@ -1816,7 +1813,6 @@ ensure_frontend_deps() {
|
|||||||
tail -20 "$log_file" 2>/dev/null || true
|
tail -20 "$log_file" 2>/dev/null || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
|
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
|
||||||
close_wait_session_context "$owns_wait_session"
|
close_wait_session_context "$owns_wait_session"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "planet"
|
name = "planet"
|
||||||
version = "0.67.0"
|
version = "0.68.0"
|
||||||
description = "智能星球计划 - 态势感知系统"
|
description = "智能星球计划 - 态势感知系统"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
Reference in New Issue
Block a user