perf: optimize persist delta gating and diagnostics

This commit is contained in:
Youzini-afk
2026-04-13 16:11:22 +08:00
parent 8f7572b615
commit b16785e56f
30 changed files with 4495 additions and 47 deletions

199
native/stbme-core/Cargo.lock generated Normal file
View File

@@ -0,0 +1,199 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde-wasm-bindgen"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "stbme-core"
version = "0.1.0"
dependencies = [
"serde",
"serde-wasm-bindgen",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasm-bindgen"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
dependencies = [
"unicode-ident",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@@ -0,0 +1,22 @@
[package]
name = "stbme-core"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde-wasm-bindgen = "0.6"
wasm-bindgen = "0.2"
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
panic = "abort"

View File

@@ -0,0 +1,851 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map as JsonMap, Value as JsonValue};
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LayoutNode {
x: f64,
y: f64,
#[serde(default)]
vx: f64,
#[serde(default)]
vy: f64,
#[serde(default)]
pinned: bool,
#[serde(default)]
radius: f64,
#[serde(default)]
region_key: String,
#[serde(default)]
region_rect: RegionRect,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LayoutEdge {
from: usize,
to: usize,
#[serde(default = "default_strength")]
strength: f64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LayoutConfig {
#[serde(default = "default_iterations")]
iterations: u32,
#[serde(default = "default_repulsion")]
repulsion: f64,
#[serde(default = "default_spring_k")]
spring_k: f64,
#[serde(default = "default_damping")]
damping: f64,
#[serde(default = "default_center_gravity")]
center_gravity: f64,
#[serde(default = "default_min_gap")]
min_gap: f64,
#[serde(default = "default_speed_cap")]
speed_cap: f64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LayoutPayload {
#[serde(default)]
nodes: Vec<LayoutNode>,
#[serde(default)]
edges: Vec<LayoutEdge>,
#[serde(default)]
config: Option<LayoutConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegionRect {
#[serde(default)]
x: f64,
#[serde(default)]
y: f64,
#[serde(default)]
w: f64,
#[serde(default)]
h: f64,
}
impl Default for RegionRect {
fn default() -> Self {
Self {
x: 0.0,
y: 0.0,
w: 0.0,
h: 0.0,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct LayoutDiagnostics {
solver: String,
node_count: usize,
edge_count: usize,
iterations: u32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct LayoutResult {
ok: bool,
used_native: bool,
positions: Vec<f32>,
diagnostics: LayoutDiagnostics,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct PersistSnapshot {
#[serde(default)]
meta: JsonMap<String, JsonValue>,
#[serde(default)]
state: JsonMap<String, JsonValue>,
#[serde(default)]
nodes: Vec<JsonValue>,
#[serde(default)]
edges: Vec<JsonValue>,
#[serde(default)]
tombstones: Vec<JsonValue>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct PersistDeltaPayload {
#[serde(default)]
before_snapshot: PersistSnapshot,
#[serde(default)]
after_snapshot: PersistSnapshot,
#[serde(default)]
now_ms: Option<f64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PersistDeltaResult {
upsert_nodes: Vec<JsonValue>,
upsert_edges: Vec<JsonValue>,
delete_node_ids: Vec<String>,
delete_edge_ids: Vec<String>,
tombstones: Vec<JsonValue>,
runtime_meta_patch: JsonMap<String, JsonValue>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct PersistCompactRecordSet {
#[serde(default)]
ids: Vec<String>,
#[serde(default)]
serialized: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct PersistCompactTombstoneSet {
#[serde(default)]
ids: Vec<String>,
#[serde(default)]
serialized: Vec<String>,
#[serde(default)]
target_keys: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct PersistDeltaCompactPayload {
#[serde(default)]
before_nodes: PersistCompactRecordSet,
#[serde(default)]
after_nodes: PersistCompactRecordSet,
#[serde(default)]
before_edges: PersistCompactRecordSet,
#[serde(default)]
after_edges: PersistCompactRecordSet,
#[serde(default)]
before_tombstones: PersistCompactRecordSet,
#[serde(default)]
after_tombstones: PersistCompactTombstoneSet,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PersistDeltaIdResult {
upsert_node_ids: Vec<String>,
upsert_edge_ids: Vec<String>,
delete_node_ids: Vec<String>,
delete_edge_ids: Vec<String>,
upsert_tombstone_ids: Vec<String>,
}
fn default_iterations() -> u32 {
80
}
fn default_repulsion() -> f64 {
2800.0
}
fn default_spring_k() -> f64 {
0.048
}
fn default_damping() -> f64 {
0.88
}
fn default_center_gravity() -> f64 {
0.014
}
fn default_min_gap() -> f64 {
12.0
}
fn default_speed_cap() -> f64 {
3.8
}
fn default_strength() -> f64 {
0.5
}
fn clamp(value: f64, min: f64, max: f64) -> f64 {
if value < min {
min
} else if value > max {
max
} else {
value
}
}
fn clamp_node_to_region(x: &mut f64, y: &mut f64, radius: f64, rect: &RegionRect) {
let safe_radius = radius.max(1.0) + 6.0;
let min_x = rect.x + safe_radius;
let max_x = rect.x + rect.w - safe_radius;
let min_y = rect.y + safe_radius;
let max_y = rect.y + rect.h - safe_radius;
*x = clamp(*x, min_x, max_x);
*y = clamp(*y, min_y, max_y);
}
fn normalize_json_record_id(value: Option<&JsonValue>) -> String {
value
.and_then(JsonValue::as_str)
.map(|item| item.trim().to_string())
.unwrap_or_default()
}
fn normalize_json_number_i64(value: Option<&JsonValue>, fallback: i64) -> i64 {
match value {
Some(JsonValue::Number(number)) => number.as_f64().unwrap_or(fallback as f64).floor() as i64,
Some(JsonValue::String(text)) => text.parse::<f64>().ok().map(|item| item.floor() as i64).unwrap_or(fallback),
_ => fallback,
}
}
fn sanitize_json_records(records: Vec<JsonValue>) -> Vec<JsonValue> {
records
.into_iter()
.filter(|record| record.is_object())
.filter(|record| normalize_json_record_id(record.get("id")).is_empty() == false)
.collect()
}
fn sanitize_persist_snapshot(snapshot: PersistSnapshot) -> PersistSnapshot {
PersistSnapshot {
meta: snapshot.meta,
state: snapshot.state,
nodes: sanitize_json_records(snapshot.nodes),
edges: sanitize_json_records(snapshot.edges),
tombstones: sanitize_json_records(snapshot.tombstones),
}
}
fn build_json_serialized_index(records: &[JsonValue]) -> HashMap<String, String> {
let mut map = HashMap::new();
for record in records {
let id = normalize_json_record_id(record.get("id"));
if id.is_empty() {
continue;
}
let serialized = serde_json::to_string(record).unwrap_or_else(|_| "null".to_string());
map.insert(id, serialized);
}
map
}
fn build_json_value_index(records: &[JsonValue]) -> HashMap<String, JsonValue> {
let mut map = HashMap::new();
for record in records {
let id = normalize_json_record_id(record.get("id"));
if id.is_empty() {
continue;
}
map.insert(id, record.clone());
}
map
}
fn build_runtime_meta_patch(snapshot: &PersistSnapshot) -> JsonMap<String, JsonValue> {
const RESERVED_KEYS: [&str; 8] = [
"revision",
"lastModified",
"nodeCount",
"edgeCount",
"tombstoneCount",
"syncDirty",
"syncDirtyReason",
"lastMutationReason",
];
let mut patch = JsonMap::new();
for (key, value) in &snapshot.meta {
if RESERVED_KEYS.contains(&key.as_str()) {
continue;
}
patch.insert(key.clone(), value.clone());
}
patch.insert(
"lastProcessedFloor".to_string(),
JsonValue::from(normalize_json_number_i64(
snapshot.state.get("lastProcessedFloor"),
-1,
)),
);
patch.insert(
"extractionCount".to_string(),
JsonValue::from(normalize_json_number_i64(
snapshot.state.get("extractionCount"),
0,
)),
);
patch.insert("schemaVersion".to_string(), JsonValue::from(1));
patch.insert(
"chatId".to_string(),
JsonValue::from(normalize_json_record_id(snapshot.meta.get("chatId"))),
);
patch
}
fn ensure_delete_tombstone(
tombstone_map: &mut HashMap<String, JsonValue>,
kind: &str,
target_id: &str,
deleted_at: i64,
source_device_id: &str,
) {
let normalized_kind = kind.trim();
let normalized_target_id = target_id.trim();
if normalized_kind.is_empty() || normalized_target_id.is_empty() {
return;
}
let key = format!("{}:{}", normalized_kind, normalized_target_id);
if tombstone_map.contains_key(&key) {
return;
}
let mut record = JsonMap::new();
record.insert("id".to_string(), JsonValue::from(key.clone()));
record.insert("kind".to_string(), JsonValue::from(normalized_kind));
record.insert("targetId".to_string(), JsonValue::from(normalized_target_id));
record.insert("deletedAt".to_string(), JsonValue::from(deleted_at));
record.insert(
"sourceDeviceId".to_string(),
JsonValue::from(source_device_id.trim().to_string()),
);
tombstone_map.insert(key, JsonValue::Object(record));
}
fn solve_persist_delta_in_rust(payload: PersistDeltaPayload) -> PersistDeltaResult {
let before_snapshot = sanitize_persist_snapshot(payload.before_snapshot);
let after_snapshot = sanitize_persist_snapshot(payload.after_snapshot);
let now_ms = payload.now_ms.unwrap_or(0.0).floor() as i64;
let deleted_at = if now_ms > 0 { now_ms } else { 0 };
let before_node_json_by_id = build_json_serialized_index(&before_snapshot.nodes);
let after_node_json_by_id = build_json_serialized_index(&after_snapshot.nodes);
let before_edge_json_by_id = build_json_serialized_index(&before_snapshot.edges);
let after_edge_json_by_id = build_json_serialized_index(&after_snapshot.edges);
let before_tombstone_json_by_id = build_json_serialized_index(&before_snapshot.tombstones);
let after_node_by_id = build_json_value_index(&after_snapshot.nodes);
let after_edge_by_id = build_json_value_index(&after_snapshot.edges);
let after_tombstone_by_id = build_json_value_index(&after_snapshot.tombstones);
let mut upsert_nodes = Vec::new();
for (id, record) in &after_node_by_id {
if before_node_json_by_id.get(id)
!= Some(&serde_json::to_string(record).unwrap_or_else(|_| "null".to_string()))
{
upsert_nodes.push(record.clone());
}
}
let mut upsert_edges = Vec::new();
for (id, record) in &after_edge_by_id {
if before_edge_json_by_id.get(id)
!= Some(&serde_json::to_string(record).unwrap_or_else(|_| "null".to_string()))
{
upsert_edges.push(record.clone());
}
}
let mut delete_node_ids = Vec::new();
for id in before_node_json_by_id.keys() {
if !after_node_json_by_id.contains_key(id) {
delete_node_ids.push(id.clone());
}
}
let mut delete_edge_ids = Vec::new();
for id in before_edge_json_by_id.keys() {
if !after_edge_json_by_id.contains_key(id) {
delete_edge_ids.push(id.clone());
}
}
let mut tombstone_map = HashMap::new();
for (id, record) in &after_tombstone_by_id {
if before_tombstone_json_by_id.get(id)
!= Some(&serde_json::to_string(record).unwrap_or_else(|_| "null".to_string()))
{
let kind = normalize_json_record_id(record.get("kind"));
let target_id = normalize_json_record_id(record.get("targetId"));
if kind.is_empty() || target_id.is_empty() {
continue;
}
tombstone_map.insert(format!("{}:{}", kind, target_id), record.clone());
}
}
let source_device_id = normalize_json_record_id(
after_snapshot
.meta
.get("deviceId")
.or_else(|| before_snapshot.meta.get("deviceId")),
);
for node_id in &delete_node_ids {
ensure_delete_tombstone(
&mut tombstone_map,
"node",
node_id,
deleted_at,
&source_device_id,
);
}
for edge_id in &delete_edge_ids {
ensure_delete_tombstone(
&mut tombstone_map,
"edge",
edge_id,
deleted_at,
&source_device_id,
);
}
PersistDeltaResult {
upsert_nodes,
upsert_edges,
delete_node_ids,
delete_edge_ids,
tombstones: tombstone_map.into_values().collect(),
runtime_meta_patch: build_runtime_meta_patch(&after_snapshot),
}
}
fn build_compact_serialized_lookup<'a>(
ids: &'a [String],
serialized: &'a [String],
) -> HashMap<&'a str, &'a str> {
let mut map = HashMap::new();
let len = ids.len().min(serialized.len());
for index in 0..len {
let id = ids[index].trim();
if id.is_empty() {
continue;
}
map.insert(id, serialized[index].as_str());
}
map
}
fn build_compact_target_key_lookup<'a>(
ids: &'a [String],
target_keys: &'a [String],
) -> HashMap<&'a str, &'a str> {
let mut map = HashMap::new();
let len = ids.len().min(target_keys.len());
for index in 0..len {
let id = ids[index].trim();
if id.is_empty() {
continue;
}
map.insert(id, target_keys[index].trim());
}
map
}
fn solve_persist_delta_compact_in_rust(payload: PersistDeltaCompactPayload) -> PersistDeltaIdResult {
let before_node_json_by_id =
build_compact_serialized_lookup(&payload.before_nodes.ids, &payload.before_nodes.serialized);
let after_node_json_by_id =
build_compact_serialized_lookup(&payload.after_nodes.ids, &payload.after_nodes.serialized);
let before_edge_json_by_id =
build_compact_serialized_lookup(&payload.before_edges.ids, &payload.before_edges.serialized);
let after_edge_json_by_id =
build_compact_serialized_lookup(&payload.after_edges.ids, &payload.after_edges.serialized);
let before_tombstone_json_by_id = build_compact_serialized_lookup(
&payload.before_tombstones.ids,
&payload.before_tombstones.serialized,
);
let after_tombstone_target_key_by_id = build_compact_target_key_lookup(
&payload.after_tombstones.ids,
&payload.after_tombstones.target_keys,
);
let mut upsert_node_ids = Vec::new();
let after_node_len = payload
.after_nodes
.ids
.len()
.min(payload.after_nodes.serialized.len());
for index in 0..after_node_len {
let id = payload.after_nodes.ids[index].trim();
if id.is_empty() {
continue;
}
let serialized = payload.after_nodes.serialized[index].as_str();
if before_node_json_by_id.get(id) != Some(&serialized) {
upsert_node_ids.push(id.to_string());
}
}
let mut upsert_edge_ids = Vec::new();
let after_edge_len = payload
.after_edges
.ids
.len()
.min(payload.after_edges.serialized.len());
for index in 0..after_edge_len {
let id = payload.after_edges.ids[index].trim();
if id.is_empty() {
continue;
}
let serialized = payload.after_edges.serialized[index].as_str();
if before_edge_json_by_id.get(id) != Some(&serialized) {
upsert_edge_ids.push(id.to_string());
}
}
let mut delete_node_ids = Vec::new();
for id in &payload.before_nodes.ids {
let normalized_id = id.trim();
if normalized_id.is_empty() {
continue;
}
if !after_node_json_by_id.contains_key(normalized_id) {
delete_node_ids.push(normalized_id.to_string());
}
}
let mut delete_edge_ids = Vec::new();
for id in &payload.before_edges.ids {
let normalized_id = id.trim();
if normalized_id.is_empty() {
continue;
}
if !after_edge_json_by_id.contains_key(normalized_id) {
delete_edge_ids.push(normalized_id.to_string());
}
}
let mut upsert_tombstone_ids = Vec::new();
let after_tombstone_len = payload
.after_tombstones
.ids
.len()
.min(payload.after_tombstones.serialized.len());
for index in 0..after_tombstone_len {
let id = payload.after_tombstones.ids[index].trim();
if id.is_empty() {
continue;
}
let target_key = after_tombstone_target_key_by_id
.get(id)
.copied()
.unwrap_or_default();
if target_key.is_empty() {
continue;
}
let serialized = payload.after_tombstones.serialized[index].as_str();
if before_tombstone_json_by_id.get(id) != Some(&serialized) {
upsert_tombstone_ids.push(id.to_string());
}
}
PersistDeltaIdResult {
upsert_node_ids,
upsert_edge_ids,
delete_node_ids,
delete_edge_ids,
upsert_tombstone_ids,
}
}
fn build_region_buckets(nodes: &[LayoutNode]) -> HashMap<String, Vec<usize>> {
let mut region_buckets = HashMap::new();
for (index, node) in nodes.iter().enumerate() {
region_buckets
.entry(node.region_key.clone())
.or_insert_with(Vec::new)
.push(index);
}
region_buckets
}
fn build_region_spring_ideals(nodes: &[LayoutNode]) -> HashMap<String, f64> {
let mut count_by_region: HashMap<String, usize> = HashMap::new();
let mut area_by_region: HashMap<String, f64> = HashMap::new();
for node in nodes {
*count_by_region.entry(node.region_key.clone()).or_insert(0) += 1;
area_by_region
.entry(node.region_key.clone())
.or_insert_with(|| {
let area = node.region_rect.w.max(1.0) * node.region_rect.h.max(1.0);
area.max(1.0)
});
}
let mut result = HashMap::new();
for (region_key, count) in count_by_region {
let area = *area_by_region.get(&region_key).unwrap_or(&1.0);
let count_f64 = (count.max(1)) as f64;
let ideal = (0.78 * (area / count_f64).sqrt()).clamp(36.0, 92.0);
result.insert(region_key, ideal);
}
result
}
fn build_in_region_edges(nodes: &[LayoutNode], edges: &[LayoutEdge]) -> Vec<(usize, usize, f64)> {
let mut result = Vec::new();
for edge in edges {
if edge.from >= nodes.len() || edge.to >= nodes.len() || edge.from == edge.to {
continue;
}
if nodes[edge.from].region_key != nodes[edge.to].region_key {
continue;
}
result.push((edge.from, edge.to, edge.strength));
}
result
}
fn solve_layout_in_rust(payload: LayoutPayload) -> LayoutResult {
let config = payload.config.unwrap_or(LayoutConfig {
iterations: default_iterations(),
repulsion: default_repulsion(),
spring_k: default_spring_k(),
damping: default_damping(),
center_gravity: default_center_gravity(),
min_gap: default_min_gap(),
speed_cap: default_speed_cap(),
});
let mut nodes = payload.nodes;
let edge_count = payload.edges.len();
if nodes.is_empty() {
return LayoutResult {
ok: true,
used_native: true,
positions: Vec::new(),
diagnostics: LayoutDiagnostics {
solver: "rust-wasm".to_string(),
node_count: 0,
edge_count,
iterations: 0,
},
};
}
let iterations = clamp(config.iterations as f64, 8.0, 220.0) as u32;
let repulsion = clamp(config.repulsion, 100.0, 120_000.0);
let spring_k = clamp(config.spring_k, 0.001, 1.0);
let damping = clamp(config.damping, 0.1, 0.999);
let center_gravity = clamp(config.center_gravity, 0.0001, 1.0);
let min_gap = clamp(config.min_gap, 0.0, 120.0);
let speed_cap = clamp(config.speed_cap, 0.5, 20.0);
for node in &mut nodes {
node.radius = node.radius.max(1.0);
}
let region_buckets = build_region_buckets(&nodes);
let spring_ideal_by_region = build_region_spring_ideals(&nodes);
let in_region_edges = build_in_region_edges(&nodes, &payload.edges);
let mut center_x = vec![0.0_f64; nodes.len()];
let mut center_y = vec![0.0_f64; nodes.len()];
for (index, node) in nodes.iter().enumerate() {
center_x[index] = node.region_rect.x + node.region_rect.w / 2.0;
center_y[index] = node.region_rect.y + node.region_rect.h / 2.0;
}
let mut fx = vec![0.0_f64; nodes.len()];
let mut fy = vec![0.0_f64; nodes.len()];
let mut actual_iterations = 0_u32;
let mut stable_rounds = 0_u32;
for _ in 0..iterations {
actual_iterations += 1;
fx.fill(0.0);
fy.fill(0.0);
for bucket in region_buckets.values() {
for left in 0..bucket.len() {
let i = bucket[left];
for right in (left + 1)..bucket.len() {
let j = bucket[right];
let dx = nodes[j].x - nodes[i].x;
let dy = nodes[j].y - nodes[i].y;
let mut dist_sq = dx * dx + dy * dy;
if dist_sq < 0.25 {
dist_sq = 0.25;
}
let dist = dist_sq.sqrt();
let min_sep = nodes[i].radius + nodes[j].radius + min_gap;
let mut force = repulsion / dist_sq;
if dist < min_sep {
force += (min_sep - dist) * 0.22;
}
let inv_dist = if dist > 0.0 { 1.0 / dist } else { 0.0 };
let force_x = dx * inv_dist * force;
let force_y = dy * inv_dist * force;
fx[i] -= force_x;
fy[i] -= force_y;
fx[j] += force_x;
fy[j] += force_y;
}
}
}
for (from, to, strength) in &in_region_edges {
let from_index = *from;
let to_index = *to;
let strength_value = *strength;
let dx = nodes[to_index].x - nodes[from_index].x;
let dy = nodes[to_index].y - nodes[from_index].y;
let dist = (dx * dx + dy * dy).sqrt().max(0.001);
let ideal = *spring_ideal_by_region
.get(&nodes[from_index].region_key)
.unwrap_or(&68.0);
let displacement = dist - ideal * (0.82 + 0.18 * strength_value);
let force = spring_k * displacement * (0.45 + 0.55 * strength_value);
let force_x = (dx / dist) * force;
let force_y = (dy / dist) * force;
fx[from_index] += force_x;
fy[from_index] += force_y;
fx[to_index] -= force_x;
fy[to_index] -= force_y;
}
let mut max_speed = 0.0_f64;
for (index, node) in nodes.iter_mut().enumerate() {
fx[index] += (center_x[index] - node.x) * center_gravity;
fy[index] += (center_y[index] - node.y) * center_gravity;
if node.pinned {
continue;
}
node.vx = (node.vx + fx[index]) * damping;
node.vy = (node.vy + fy[index]) * damping;
let speed = (node.vx * node.vx + node.vy * node.vy).sqrt();
if speed > max_speed {
max_speed = speed;
}
if speed > speed_cap {
let scale = speed_cap / speed;
node.vx *= scale;
node.vy *= scale;
}
node.x += node.vx;
node.y += node.vy;
clamp_node_to_region(&mut node.x, &mut node.y, node.radius, &node.region_rect);
}
if max_speed < 0.015 {
stable_rounds += 1;
if stable_rounds >= 6 {
break;
}
} else {
stable_rounds = 0;
}
}
let node_count = nodes.len();
let mut positions = Vec::with_capacity(nodes.len() * 2);
for node in nodes {
positions.push(node.x as f32);
positions.push(node.y as f32);
}
LayoutResult {
ok: true,
used_native: true,
positions,
diagnostics: LayoutDiagnostics {
solver: "rust-wasm".to_string(),
node_count,
edge_count,
iterations: actual_iterations,
},
}
}
#[wasm_bindgen]
pub fn solve_layout(payload: JsValue) -> Result<JsValue, JsValue> {
let parsed: LayoutPayload = serde_wasm_bindgen::from_value(payload)
.map_err(|error| JsValue::from_str(&format!("invalid payload: {error}")))?;
let solved = solve_layout_in_rust(parsed);
serde_wasm_bindgen::to_value(&solved)
.map_err(|error| JsValue::from_str(&format!("serialize result failed: {error}")))
}
#[wasm_bindgen]
pub fn build_persist_delta(payload: JsValue) -> Result<JsValue, JsValue> {
let parsed: PersistDeltaPayload = serde_wasm_bindgen::from_value(payload)
.map_err(|error| JsValue::from_str(&format!("invalid persist payload: {error}")))?;
let solved = solve_persist_delta_in_rust(parsed);
serde_wasm_bindgen::to_value(&solved)
.map_err(|error| JsValue::from_str(&format!("serialize persist result failed: {error}")))
}
#[wasm_bindgen]
pub fn build_persist_delta_compact(payload: JsValue) -> Result<JsValue, JsValue> {
let parsed: PersistDeltaCompactPayload = serde_wasm_bindgen::from_value(payload)
.map_err(|error| JsValue::from_str(&format!("invalid compact persist payload: {error}")))?;
let solved = solve_persist_delta_compact_in_rust(parsed);
serde_wasm_bindgen::to_value(&solved).map_err(|error| {
JsValue::from_str(&format!("serialize compact persist result failed: {error}"))
})
}