// ST-BME: Canvas 图谱渲染器 — 分区「神经视图」布局
// 零依赖：客观层 / 角色 POV / 用户 POV 分区内 Vogel 初值 + 一次性力导向稳定，无帧循环抖动

import { getNodeColors } from './themes.js';
import {
    isUsableGraphCanvasSize,
    remapPositionBetweenRects,
} from './graph-renderer-utils.js';
import { getGraphNodeLabel, getNodeDisplayName } from '../graph/node-labels.js';
import { normalizeMemoryScope } from '../graph/memory-scope.js';
import {
    aliasSetMatchesValue,
    buildUserPovAliasNormalizedSet,
} from '../runtime/user-alias-utils.js';
import {
    GraphNativeLayoutBridge,
    normalizeGraphNativeRuntimeOptions,
} from './graph-native-bridge.js';

/**
 * @typedef {Object} GraphNode
 * @property {string} id
 * @property {string} type
 * @property {string} name
 * @property {number} importance
 * @property {number} x
 * @property {number} y
 * @property {number} vx
 * @property {number} vy
 * @property {boolean} pinned
 */

const DEFAULT_LAYOUT_CONFIG = {
    minNodeRadius: 6,
    maxNodeRadius: 17,
    labelFontSize: 10,
    gridSpacing: 48,
    gridColor: 'rgba(255,255,255,0.028)',
    /** 主画布左侧客观区占比（余下为右侧 POV 列） */
    objectiveWidthRatio: 0.62,
    /** 分区内类神经布局：力导向迭代次数（无持续动画，仅一次性稳定） */
    neuralIterations: 120,
    neuralRepulsion: 2800,
    neuralSpringK: 0.048,
    neuralDamping: 0.88,
    neuralCenterGravity: 0.014,
    /** 节点最小间距（除半径外） */
    neuralMinGap: 12,
};

const ADAPTIVE_NEURAL_LAYOUT_POLICY = Object.freeze({
    reduceIterationsNodes: 220,
    reduceIterationsEdges: 1200,
    reduceIterationsCap: 56,
    strongReduceNodes: 360,
    strongReduceEdges: 2200,
    strongReduceCap: 24,
    skipSimulationNodes: 520,
    skipSimulationEdges: 3600,
});

const MIN_USABLE_CANVAS_DIMENSION = 48;
const RUNTIME_DEBUG_STATE_KEY = '__stBmeRuntimeDebugState';

function cloneGraphLayoutDebugValue(value, fallback = null) {
    if (value == null) return fallback;
    if (typeof globalThis.structuredClone === 'function') {
        try {
            return globalThis.structuredClone(value);
        } catch {}
    }
    try {
        return JSON.parse(JSON.stringify(value));
    } catch {
        return fallback;
    }
}

function recordGraphLayoutDebugSnapshot(snapshot = null) {
    if (!globalThis || typeof globalThis !== 'object') return;
    if (!globalThis[RUNTIME_DEBUG_STATE_KEY] || typeof globalThis[RUNTIME_DEBUG_STATE_KEY] !== 'object') {
        globalThis[RUNTIME_DEBUG_STATE_KEY] = {
            updatedAt: '',
            graphLayout: null,
        };
    }
    const state = globalThis[RUNTIME_DEBUG_STATE_KEY];
    state.graphLayout = snapshot && typeof snapshot === 'object'
        ? {
            updatedAt: new Date().toISOString(),
            ...cloneGraphLayoutDebugValue(snapshot, {}),
        }
        : null;
    state.updatedAt = new Date().toISOString();
}

/** 兼容旧版 forceConfig（召回卡片等） */
function layoutKeysFromForceConfig(fc) {
    if (!fc || typeof fc !== 'object') return {};
    const o = {};
    if (fc.minNodeRadius != null) o.minNodeRadius = fc.minNodeRadius;
    if (fc.maxNodeRadius != null) o.maxNodeRadius = fc.maxNodeRadius;
    if (fc.labelFontSize != null) o.labelFontSize = fc.labelFontSize;
    if (fc.gridSpacing != null) o.gridSpacing = fc.gridSpacing;
    if (fc.gridColor != null) o.gridColor = fc.gridColor;
    if (fc.maxIterations != null) {
        o.neuralIterations = Math.min(
            160,
            Math.max(32, Math.round(fc.maxIterations * 0.85)),
        );
    }
    return o;
}

function roundRectPath(ctx, x, y, w, h, r) {
    const W = Math.max(0, Number(w) || 0);
    const H = Math.max(0, Number(h) || 0);
    const rr = Math.max(0, Number(r) || 0);
    const radius = Math.min(rr, W / 2, H / 2);
    if (W < 1 || H < 1) {
        ctx.rect(x, y, Math.max(1, W), Math.max(1, H));
        return;
    }
    if (radius < 1e-6) {
        ctx.rect(x, y, W, H);
        return;
    }
    ctx.moveTo(x + radius, y);
    ctx.arcTo(x + W, y, x + W, y + H, radius);
    ctx.arcTo(x + W, y + H, x, y + H, radius);
    ctx.arcTo(x, y + H, x, y, radius);
    ctx.arcTo(x, y, x + W, y, radius);
    ctx.closePath();
}

const SCOPE_OUTLINE_COLORS = {
    objective: '#57c7ff',
    character: '#ffb347',
    user: '#7dff9b',
};

function hashId(id) {
    let h = 0;
    const s = String(id || '');
    for (let i = 0; i < s.length; i++) {
        h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
    }
    return h;
}

/** 与 memory-scope 中 normalizeKey 一致，用于分区键（模块内未导出故本地复制） */
function normalizeKeyForPartition(value) {
    return String(value ?? '').trim().toLowerCase();
}

function scopeMatchesHostUserAliases(scope, aliasSet) {
    if (!(aliasSet instanceof Set) || aliasSet.size === 0) return false;
    for (const field of [scope.ownerName, scope.ownerId]) {
        if (aliasSetMatchesValue(aliasSet, field)) return true;
    }
    return false;
}

function characterPovLabelFromNodes(arr) {
    if (!arr?.length) return '·';
    for (const n of arr) {
        const s = normalizeMemoryScope(n.raw?.scope);
        if (s.ownerName) return s.ownerName;
    }
    for (const n of arr) {
        const s = normalizeMemoryScope(n.raw?.scope);
        if (s.ownerId) return s.ownerId;
    }
    return '·';
}

function partitionNodesByScope(nodes, userPovAliasSet = null) {
    const objective = [];
    const userPov = [];
    const charMap = new Map();
    const aliasSet =
        userPovAliasSet instanceof Set ? userPovAliasSet : new Set();

    for (const node of nodes) {
        const scope = normalizeMemoryScope(node.raw?.scope);
        if (scope.layer !== 'pov') {
            objective.push(node);
            node.regionKey = 'objective';
            continue;
        }
        // 优先：宿主用户显示名与 ownerName/ownerId 一致时一律归用户 POV（修正提取阶段误标 character）
        if (scopeMatchesHostUserAliases(scope, aliasSet)) {
            userPov.push(node);
            node.regionKey = 'user';
            continue;
        }
        if (scope.ownerType === 'user') {
            userPov.push(node);
            node.regionKey = 'user';
            continue;
        }
        if (scope.ownerType === 'character') {
            // 与 UUID+姓名、仅姓名 等存法兼容：优先用展示名归并，避免同一角色拆成多个 POV 区
            const nameKey = normalizeKeyForPartition(scope.ownerName);
            const idKey = normalizeKeyForPartition(scope.ownerId);
            const key = nameKey || idKey || '·';
            if (!charMap.has(key)) charMap.set(key, []);
            charMap.get(key).push(node);
            node.regionKey = `char:${key}`;
            continue;
        }
        objective.push(node);
        node.regionKey = 'objective';
    }

    return { objective, userPov, charMap };
}

export class GraphRenderer {
    /**
     * @param {HTMLCanvasElement} canvas
     * @param {string|object} [options] - 主题名称字符串（向后兼容）或配置对象
     *   options.theme {string} - 主题名称
     *   options.layoutConfig {object} - 布局参数覆盖
     *   options.forceConfig {object} - 兼容旧力导向配置（仅读取节点半径、网格、局部松弛次数等）
     *   options.onNodeClick {function} - 节点点击回调
     *   options.onNodeDoubleClick {function} - 节点双击回调
     */
    constructor(canvas, options = 'crimson') {
        const isLegacy = typeof options === 'string';
        const themeName = isLegacy ? options : (options?.theme || 'crimson');
        const layoutOverride = isLegacy ? {} : (options?.layoutConfig || {});
        const fromForce = isLegacy ? {} : layoutKeysFromForceConfig(options?.forceConfig);
        const runtimeConfig = isLegacy ? {} : (options?.runtimeConfig || {});

        this.canvas = canvas;
        this.ctx = canvas.getContext('2d');
        this.nodes = [];
        this.edges = [];
        this.nodeMap = new Map();
        this.colors = getNodeColors(themeName);
        this.themeName = themeName;
        this.config = { ...DEFAULT_LAYOUT_CONFIG, ...fromForce, ...layoutOverride };
        this.runtimeConfig = normalizeGraphNativeRuntimeOptions(runtimeConfig);
        this._userPovAliasSet = buildUserPovAliasNormalizedSet(
            isLegacy ? null : options?.userPovAliases,
        );
        this._nativeLayoutBridge = null;
        this._layoutSolveRevision = 0;
        this._lastLayoutDiagnostics = null;
        this._lastLayoutReuseStats = { reused: 0, total: 0, ratio: 0 };

        this._regionPanels = [];
        this._lastGraph = null;
        this._lastLayoutHints = {};
        this._lastCanvasCssWidth = 0;
        this._lastCanvasCssHeight = 0;
        this._lastDevicePixelRatio = window.devicePixelRatio || 1;

        // View transform
        this.scale = 1;
        this.offsetX = 0;
        this.offsetY = 0;

        // Interaction state
        this.dragNode = null;
        this.hoveredNode = null;
        this.selectedNode = null;
        this.isDragging = false;
        this.isPanning = false;
        this.lastMouse = { x: 0, y: 0 };
        /** @type {{ startX: number, startY: number, lastX: number, lastY: number, nodeCandidate: object|null, moved: boolean } | null} */
        this._touchSession = null;
        this._suppressMouseUntil = 0;

        this.animId = null;
        this.enabled = true;

        // Callbacks
        this.onNodeSelect = isLegacy ? null : (options?.onNodeSelect || null);
        this.onNodeClick = isLegacy ? null : (options?.onNodeClick || null);
        this.onNodeDoubleClick = isLegacy ? null : (options?.onNodeDoubleClick || null);

        this._bindEvents();
        this._resizeObserver = new ResizeObserver(() => this._resize());
        this._resizeObserver.observe(canvas.parentElement);
        this._resize();
    }

    /**
     * 加载图谱数据
     * @param {object} graph - 完整的 graph state
     */
    /**
     * @param {object} graph
     * @param {{ userPovAliases?: string|string[]|object }} [layoutHints]
     */
    loadGraph(graph, layoutHints = {}) {
        const loadStartedAt = performance.now();
        const prevSelectedId = this.selectedNode?.id || null;
        const solveRevision = this._nextLayoutSolveRevision();
        const previousLayoutSeedByNodeId = this._captureLayoutSeedByNodeId();
        this._nativeLayoutBridge?.cancelPending?.('graph-load-replaced');
        this._lastGraph = graph;
        this._lastLayoutHints = layoutHints && typeof layoutHints === 'object'
            ? { ...layoutHints }
            : {};
        if (layoutHints && Object.prototype.hasOwnProperty.call(layoutHints, 'userPovAliases')) {
            this._userPovAliasSet = buildUserPovAliasNormalizedSet(
                layoutHints.userPovAliases,
            );
        }

        if (!this.enabled) {
            return;
        }

        this.nodeMap.clear();

        const dpr = window.devicePixelRatio || 1;
        const W = this.canvas.width / dpr;
        const H = this.canvas.height / dpr;

        const activeNodes = graph.nodes.filter(n => !n.archived);
        this.nodes = activeNodes.map((n) => {
            const node = {
                id: n.id,
                type: n.type || 'event',
                name: getNodeDisplayName(n),
                label: getGraphNodeLabel(n),
                importance: n.importance || 5,
                x: 0,
                y: 0,
                vx: 0,
                vy: 0,
                pinned: false,
                raw: n,
                regionKey: 'objective',
                regionRect: null,
            };
            this.nodeMap.set(n.id, node);
            return node;
        });

        this.edges = graph.edges
            .filter(e => !e.invalidAt && !e.expiredAt && this.nodeMap.has(e.fromId) && this.nodeMap.has(e.toId))
            .map(e => ({
                from: this.nodeMap.get(e.fromId),
                to: this.nodeMap.get(e.toId),
                strength: e.strength || 0.5,
                relation: e.relation || 'related',
            }));
        const prepareFinishedAt = performance.now();

        const parts = partitionNodesByScope(this.nodes, this._userPovAliasSet);
        this._regionPanels = this._computeRegionPanels(W, H, parts);
        const layoutReuse = this._applyPreviousLayoutSeed(previousLayoutSeedByNodeId);
        this._layoutAllPartitions(parts);
        const layoutFinishedAt = performance.now();
        const neuralPlan = this._resolveNeuralSimulationPlan();
        const shouldTryNativeLayout = this._shouldTryNativeLayout(
            this.nodes.length,
            this.edges.length,
        );

        let solvePath = neuralPlan.skip ? 'skipped' : 'js-main';
        let solveMs = 0;
        let nativeSolvePromise = null;

        if (!neuralPlan.skip && neuralPlan.iterations > 0) {
            if (shouldTryNativeLayout) {
                solvePath = 'native-worker-pending';
                nativeSolvePromise = this._simulateNeuralWithNativeBridge(
                    neuralPlan.iterations,
                    solveRevision,
                    {
                        loadStartedAt,
                        prepareFinishedAt,
                        layoutFinishedAt,
                        layoutReuse,
                    },
                );
            } else {
                const solveStartedAt = performance.now();
                this._simulateNeuralWithinRegions(neuralPlan.iterations);
                solveMs = Math.max(0, performance.now() - solveStartedAt);
            }
        }

        if (prevSelectedId) {
            this.selectedNode = this.nodeMap.get(prevSelectedId) || null;
        }

        this._cancelAnim();
        this._render();

        if (!nativeSolvePromise) {
            this._setLastLayoutDiagnostics({
                mode: solvePath,
                nodeCount: this.nodes.length,
                edgeCount: this.edges.length,
                prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
                layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
                solveMs,
                totalMs: Math.max(0, performance.now() - loadStartedAt),
                layoutReuseCount: Number(layoutReuse?.reused || 0),
                layoutReuseTotal: Number(layoutReuse?.total || this.nodes.length || 0),
                layoutReuseRatio: Number(layoutReuse?.ratio || 0),
                at: Date.now(),
            });
            return;
        }

        nativeSolvePromise
            .then((result) => {
                if (!result) return;
                this._setLastLayoutDiagnostics({
                    ...result.diagnostics,
                    at: Date.now(),
                });
                if (result.applied && this.enabled) {
                    this._scheduleRender();
                }
            })
            .catch(() => {
                // fail-open 路径由 bridge 内部控制
            });
    }

    /**
     * 切换主题
     */
    setTheme(themeName) {
        this.themeName = themeName;
        this.colors = getNodeColors(themeName);
        if (this.enabled) this._render();
    }

    setRuntimeConfig(runtimeConfig = {}) {
        this.runtimeConfig = normalizeGraphNativeRuntimeOptions(runtimeConfig);
        if (this._nativeLayoutBridge) {
            this._nativeLayoutBridge.updateRuntimeOptions(this.runtimeConfig);
        }
    }

    getLastLayoutDiagnostics() {
        return this._lastLayoutDiagnostics
            ? { ...this._lastLayoutDiagnostics }
            : null;
    }

    _setLastLayoutDiagnostics(diagnostics = null) {
        this._lastLayoutDiagnostics = diagnostics && typeof diagnostics === 'object'
            ? { ...diagnostics }
            : null;
        recordGraphLayoutDebugSnapshot(
            this._lastLayoutDiagnostics
                ? {
                    ...this._lastLayoutDiagnostics,
                    enabled: this.enabled !== false,
                }
                : null,
        );
    }

    /**
     * 高亮指定节点
     */
    highlightNode(nodeId) {
        this.selectedNode = this.nodeMap.get(nodeId) || null;
        if (this.enabled) this._render();
    }

    _clearCanvas() {
        const ctx = this.ctx;
        if (!ctx) return;
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        this.canvas.style.cursor = 'default';
    }

    setEnabled(enabled = true) {
        const nextEnabled = enabled !== false;
        if (this.enabled === nextEnabled) {
            if (!nextEnabled) this._clearCanvas();
            return;
        }
        this._nextLayoutSolveRevision();
        this._nativeLayoutBridge?.cancelPending?.('graph-renderer-state-changed');
        this.enabled = nextEnabled;
        if (this._lastLayoutDiagnostics) {
            this._setLastLayoutDiagnostics(this._lastLayoutDiagnostics);
        }
        this._cancelAnim();
        this.dragNode = null;
        this.isDragging = false;
        this.isPanning = false;
        this._touchSession = null;
        this._dragStartMouse = null;
        this.hoveredNode = null;
        if (!nextEnabled) {
            this.nodeMap.clear();
            this.nodes = [];
            this.edges = [];
            this._regionPanels = [];
            this._clearCanvas();
            return;
        }
        this.canvas.style.cursor = 'grab';
        if (this._lastGraph) {
            this.loadGraph(this._lastGraph, this._lastLayoutHints);
        } else {
            this._render();
        }
    }

    // ==================== 分区布局 ====================

    _computeRegionPanels(W, H, { objective, userPov, charMap }) {
        const pad = 14;
        const gutter = 10;
        const topPad = 20;
        const hasRight = userPov.length > 0 || charMap.size > 0;
        const splitX = hasRight ? W * this.config.objectiveWidthRatio : W;

        const panels = [];

        const objectivePanel = {
            x: pad,
            y: pad + 6,
            w: Math.max(
                0,
                (hasRight ? splitX : W) - pad * 2 - (hasRight ? gutter / 2 : 0),
            ),
            h: Math.max(0, H - pad * 2 - 6),
            label: '客观层',
            tint: 'rgba(26, 35, 50, 0.42)',
            key: 'objective',
        };
        panels.push(objectivePanel);

        const innerObjective = {
            x: objectivePanel.x + 10,
            y: objectivePanel.y + topPad,
            w: Math.max(1, objectivePanel.w - 20),
            h: Math.max(1, objectivePanel.h - topPad - 10),
        };
        for (const n of objective) n.regionRect = innerObjective;

        if (!hasRight) return panels;

        const rightX = splitX + gutter / 2;
        const rightW = Math.max(0, W - pad - rightX);
        const yBottom = H - pad;
        let yTop = pad + 6;

        const charEntries = [...charMap.entries()].sort((a, b) =>
            String(a[0]).localeCompare(String(b[0]), 'zh'),
        );
        const charCount = charEntries.length;
        const hasUserStrip = userPov.length > 0;

        if (charCount === 0 && hasUserStrip) {
            const fullH = yBottom - yTop;
            panels.push({
                x: rightX,
                y: yTop,
                w: rightW,
                h: fullH,
                label: '用户 POV',
                tint: 'rgba(32, 48, 40, 0.42)',
                key: 'user',
            });
            const innerU = {
                x: rightX + 10,
                y: yTop + topPad,
                w: Math.max(1, rightW - 20),
                h: Math.max(1, fullH - topPad - 8),
            };
            for (const n of userPov) n.regionRect = innerU;
            return panels;
        }

        const userStripH = hasUserStrip
            ? Math.max(72, Math.min(108, (yBottom - yTop) * 0.2))
            : 0;
        const charZoneBottom = yBottom - (hasUserStrip ? userStripH + 8 : 0);
        const gap = 6;
        const charZoneH = charZoneBottom - yTop;
        const slice = charCount > 0
            ? (charZoneH - gap * Math.max(0, charCount - 1)) / charCount
            : 0;

        let yc = yTop;
        for (let i = 0; i < charCount; i++) {
            const [key, arr] = charEntries[i];
            const ph = Math.max(52, slice);
            const displayName = characterPovLabelFromNodes(arr);
            panels.push({
                x: rightX,
                y: yc,
                w: rightW,
                h: ph,
                label: `角色 POV · ${displayName}`,
                tint: 'rgba(55, 42, 28, 0.38)',
                key: `char:${key}`,
            });
            const inner = {
                x: rightX + 10,
                y: yc + topPad,
                w: Math.max(1, rightW - 20),
                h: Math.max(1, ph - topPad - 8),
            };
            for (const n of arr) n.regionRect = inner;
            yc += ph + gap;
        }

        if (hasUserStrip) {
            const uy = yBottom - userStripH;
            panels.push({
                x: rightX,
                y: uy,
                w: rightW,
                h: userStripH,
                label: '用户 POV',
                tint: 'rgba(32, 48, 40, 0.42)',
                key: 'user',
            });
            const innerU = {
                x: rightX + 10,
                y: uy + topPad,
                w: Math.max(1, rightW - 20),
                h: Math.max(1, userStripH - topPad - 8),
            };
            for (const n of userPov) n.regionRect = innerU;
        }

        return panels;
    }

    _layoutAllPartitions({ objective, userPov, charMap }) {
        this._seedNeuralCloudInRect(
            objective.filter((node) => node._layoutSeedReused !== true),
            objective[0]?.regionRect,
        );
        if (userPov.length) {
            this._seedNeuralCloudInRect(
                userPov.filter((node) => node._layoutSeedReused !== true),
                userPov[0]?.regionRect,
            );
        }
        for (const [, arr] of charMap) {
            this._seedNeuralCloudInRect(
                arr.filter((node) => node._layoutSeedReused !== true),
                arr[0]?.regionRect,
            );
        }
    }

    _captureLayoutSeedByNodeId() {
        const seedByNodeId = new Map();
        for (const node of Array.isArray(this.nodes) ? this.nodes : []) {
            if (!node?.id) continue;
            if (!Number.isFinite(node.x) || !Number.isFinite(node.y) || !node.regionRect) {
                continue;
            }
            seedByNodeId.set(node.id, {
                x: node.x,
                y: node.y,
                regionKey: node.regionKey || 'objective',
                regionRect: {
                    x: node.regionRect.x,
                    y: node.regionRect.y,
                    w: node.regionRect.w,
                    h: node.regionRect.h,
                },
            });
        }
        return seedByNodeId;
    }

    _applyPreviousLayoutSeed(seedByNodeId = null) {
        let reused = 0;
        const total = Array.isArray(this.nodes) ? this.nodes.length : 0;
        for (const node of this.nodes) {
            node._layoutSeedReused = false;
            const previousSeed = seedByNodeId instanceof Map ? seedByNodeId.get(node.id) : null;
            if (!previousSeed?.regionRect || !node.regionRect) continue;
            const nextPosition = remapPositionBetweenRects(
                previousSeed.x,
                previousSeed.y,
                previousSeed.regionRect,
                node.regionRect,
            );
            if (!Number.isFinite(nextPosition?.x) || !Number.isFinite(nextPosition?.y)) {
                continue;
            }
            node.x = nextPosition.x;
            node.y = nextPosition.y;
            node.vx = 0;
            node.vy = 0;
            node._layoutSeedReused = true;
            this._clampNodeToRegion(node);
            reused += 1;
        }
        this._lastLayoutReuseStats = {
            reused,
            total,
            ratio: total > 0 ? reused / total : 0,
        };
        return this._lastLayoutReuseStats;
    }

    _rebuildLayoutForCurrentViewport(W, H) {
        const previousRectsByRegion = new Map();
        for (const node of this.nodes) {
            if (!node?.regionKey || previousRectsByRegion.has(node.regionKey) || !node.regionRect) {
                continue;
            }
            previousRectsByRegion.set(node.regionKey, {
                x: node.regionRect.x,
                y: node.regionRect.y,
                w: node.regionRect.w,
                h: node.regionRect.h,
            });
        }

        const parts = partitionNodesByScope(this.nodes, this._userPovAliasSet);
        this._regionPanels = this._computeRegionPanels(W, H, parts);

        for (const node of this.nodes) {
            const nextRect = node.regionRect;
            const previousRect = previousRectsByRegion.get(node.regionKey) || nextRect;
            const nextPosition = remapPositionBetweenRects(
                node.x,
                node.y,
                previousRect,
                nextRect,
            );
            node.x = nextPosition.x;
            node.y = nextPosition.y;
            node.vx = 0;
            node.vy = 0;
            this._clampNodeToRegion(node);
        }
    }

    /**
     * 椭圆 Vogel 螺旋初值：有机疏密，Deterministic，无网格感
     */
    _seedNeuralCloudInRect(nodes, rect) {
        if (!rect || !nodes.length) return;
        const pad = Math.max(10, this.config.neuralMinGap);
        const cx = rect.x + rect.w / 2;
        const cy = rect.y + rect.h / 2;
        const rx = Math.max(14, rect.w / 2 - pad);
        const ry = Math.max(14, rect.h / 2 - pad);
        const sorted = [...nodes].sort((a, b) => a.id.localeCompare(b.id));
        const n = sorted.length;
        const golden = Math.PI * (3 - Math.sqrt(5));
        sorted.forEach((node, i) => {
            const t = (i + 0.5) / Math.max(n, 1);
            const radScale = Math.sqrt(t) * 0.9;
            const phase = ((hashId(node.id) & 0x3ff) / 1024) * 0.62;
            const theta = i * golden + phase;
            node.x = cx + Math.cos(theta) * radScale * rx;
            node.y = cy + Math.sin(theta) * radScale * ry;
            node.vx = 0;
            node.vy = 0;
        });
    }

    _idealSpringLengthsByRegion() {
        const countBy = new Map();
        for (const n of this.nodes) {
            const k = n.regionKey;
            countBy.set(k, (countBy.get(k) || 0) + 1);
        }
        const ideal = new Map();
        for (const n of this.nodes) {
            if (ideal.has(n.regionKey)) continue;
            const rect = n.regionRect;
            const c = Math.max(1, countBy.get(n.regionKey) || 1);
            const area = (rect?.w || 1) * (rect?.h || 1);
            const len = Math.max(
                36,
                Math.min(92, 0.78 * Math.sqrt(area / c)),
            );
            ideal.set(n.regionKey, len);
        }
        return ideal;
    }

    _resolveNeuralSimulationPlan() {
        const nodeCount = Array.isArray(this.nodes) ? this.nodes.length : 0;
        const edgeCount = Array.isArray(this.edges) ? this.edges.length : 0;
        const reuseRatio = Math.max(0, Math.min(1, Number(this._lastLayoutReuseStats?.ratio || 0)));
        const baseIterations = Math.max(
            8,
            Math.min(220, Number(this.config.neuralIterations) || 80),
        );

        let iterations = baseIterations;
        let skip = false;

        if (
            nodeCount >= ADAPTIVE_NEURAL_LAYOUT_POLICY.skipSimulationNodes ||
            edgeCount >= ADAPTIVE_NEURAL_LAYOUT_POLICY.skipSimulationEdges
        ) {
            skip = true;
            iterations = 0;
        } else if (
            nodeCount >= ADAPTIVE_NEURAL_LAYOUT_POLICY.strongReduceNodes ||
            edgeCount >= ADAPTIVE_NEURAL_LAYOUT_POLICY.strongReduceEdges
        ) {
            iterations = Math.min(
                iterations,
                ADAPTIVE_NEURAL_LAYOUT_POLICY.strongReduceCap,
            );
        } else if (
            nodeCount >= ADAPTIVE_NEURAL_LAYOUT_POLICY.reduceIterationsNodes ||
            edgeCount >= ADAPTIVE_NEURAL_LAYOUT_POLICY.reduceIterationsEdges
        ) {
            iterations = Math.min(
                iterations,
                ADAPTIVE_NEURAL_LAYOUT_POLICY.reduceIterationsCap,
            );
        }

        if (!skip && nodeCount >= 24) {
            if (reuseRatio >= 0.9) {
                iterations = Math.min(
                    iterations,
                    Math.max(8, Math.round(baseIterations * 0.18)),
                );
            } else if (reuseRatio >= 0.65) {
                iterations = Math.min(
                    iterations,
                    Math.max(10, Math.round(baseIterations * 0.35)),
                );
            }
        }

        return {
            skip,
            iterations,
        };
    }

    _nextLayoutSolveRevision() {
        this._layoutSolveRevision = Math.max(1, Number(this._layoutSolveRevision || 0) + 1);
        return this._layoutSolveRevision;
    }

    _ensureNativeLayoutBridge() {
        if (this._nativeLayoutBridge) {
            this._nativeLayoutBridge.updateRuntimeOptions(this.runtimeConfig);
            return this._nativeLayoutBridge;
        }
        this._nativeLayoutBridge = new GraphNativeLayoutBridge(this.runtimeConfig);
        return this._nativeLayoutBridge;
    }

    _shouldTryNativeLayout(nodeCount = 0, edgeCount = 0) {
        if (this.runtimeConfig.graphNativeForceDisable) return false;
        if (!this.runtimeConfig.graphUseNativeLayout) return false;
        const bridge = this._ensureNativeLayoutBridge();
        if (!bridge) return false;
        return bridge.shouldRunForGraph(nodeCount, edgeCount);
    }

    _buildNativeLayoutPayload(iterations) {
        const nodeIndexById = new Map();
        const nodes = this.nodes.map((node, index) => {
            nodeIndexById.set(node.id, index);
            return {
                x: node.x,
                y: node.y,
                vx: node.vx,
                vy: node.vy,
                pinned: node.pinned === true,
                radius: this._nodeRadius(node),
                regionKey: node.regionKey,
                regionRect: node.regionRect
                    ? {
                        x: node.regionRect.x,
                        y: node.regionRect.y,
                        w: node.regionRect.w,
                        h: node.regionRect.h,
                    }
                    : null,
            };
        });

        const edges = this.edges
            .map((edge) => {
                const from = nodeIndexById.get(edge.from?.id);
                const to = nodeIndexById.get(edge.to?.id);
                if (!Number.isFinite(from) || !Number.isFinite(to) || from === to) {
                    return null;
                }
                return {
                    from,
                    to,
                    strength: edge.strength || 0.5,
                };
            })
            .filter(Boolean);

        return {
            nodes,
            edges,
            config: {
                iterations,
                repulsion: this.config.neuralRepulsion ?? 2800,
                springK: this.config.neuralSpringK ?? 0.048,
                damping: this.config.neuralDamping ?? 0.88,
                centerGravity: this.config.neuralCenterGravity ?? 0.014,
                minGap: this.config.neuralMinGap ?? 12,
                speedCap: 3.8,
            },
        };
    }

    _applyLayoutPositions(positions) {
        if (!(positions instanceof Float32Array)) return false;
        if (positions.length < this.nodes.length * 2) return false;

        for (let i = 0; i < this.nodes.length; i++) {
            const node = this.nodes[i];
            if (!node || node.pinned) continue;
            node.x = positions[i * 2];
            node.y = positions[i * 2 + 1];
            node.vx = 0;
            node.vy = 0;
            this._clampNodeToRegion(node);
        }
        return true;
    }

    async _simulateNeuralWithNativeBridge(iterations, solveRevision, timings = {}) {
        const loadStartedAt = Number(timings.loadStartedAt) || performance.now();
        const prepareFinishedAt = Number(timings.prepareFinishedAt) || loadStartedAt;
        const layoutFinishedAt = Number(timings.layoutFinishedAt) || prepareFinishedAt;
        const layoutReuse = timings.layoutReuse && typeof timings.layoutReuse === 'object'
            ? timings.layoutReuse
            : this._lastLayoutReuseStats;

        const bridge = this._ensureNativeLayoutBridge();
        const solveStartedAt = performance.now();
        let nativeResult = null;

        try {
            nativeResult = await bridge.solveLayout(this._buildNativeLayoutPayload(iterations), {
                timeoutMs: this.runtimeConfig.graphNativeLayoutWorkerTimeoutMs,
            });
        } catch (error) {
            nativeResult = {
                ok: false,
                skipped: true,
                reason: 'native-layout-bridge-error',
                error: error?.message || String(error),
            };
        }

        if (solveRevision !== this._layoutSolveRevision) {
            return {
                applied: false,
                diagnostics: {
                    mode: 'native-stale',
                    nodeCount: this.nodes.length,
                    edgeCount: this.edges.length,
                    prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
                    layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
                    solveMs: Math.max(0, performance.now() - solveStartedAt),
                    totalMs: Math.max(0, performance.now() - loadStartedAt),
                    layoutReuseCount: Number(layoutReuse?.reused || 0),
                    layoutReuseTotal: Number(layoutReuse?.total || this.nodes.length || 0),
                    layoutReuseRatio: Number(layoutReuse?.ratio || 0),
                    reason: 'stale-layout-result',
                },
            };
        }

        if (nativeResult?.ok && this._applyLayoutPositions(nativeResult.positions)) {
            const workerElapsedMs = Number(nativeResult?.diagnostics?.elapsedMs);
            return {
                applied: true,
                diagnostics: {
                    mode: nativeResult.usedNative ? 'rust-wasm-worker' : 'js-worker',
                    nodeCount: this.nodes.length,
                    edgeCount: this.edges.length,
                    prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
                    layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
                    solveMs: Math.max(0, performance.now() - solveStartedAt),
                    workerSolveMs: Number.isFinite(workerElapsedMs)
                        ? Math.max(0, workerElapsedMs)
                        : 0,
                    totalMs: Math.max(0, performance.now() - loadStartedAt),
                    layoutReuseCount: Number(layoutReuse?.reused || 0),
                    layoutReuseTotal: Number(layoutReuse?.total || this.nodes.length || 0),
                    layoutReuseRatio: Number(layoutReuse?.ratio || 0),
                    reason: '',
                },
            };
        }

        if (!this.runtimeConfig.nativeEngineFailOpen) {
            return {
                applied: false,
                diagnostics: {
                    mode: 'native-failed-hard',
                    nodeCount: this.nodes.length,
                    edgeCount: this.edges.length,
                    prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
                    layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
                    solveMs: Math.max(0, performance.now() - solveStartedAt),
                    totalMs: Math.max(0, performance.now() - loadStartedAt),
                    layoutReuseCount: Number(layoutReuse?.reused || 0),
                    layoutReuseTotal: Number(layoutReuse?.total || this.nodes.length || 0),
                    layoutReuseRatio: Number(layoutReuse?.ratio || 0),
                    reason: nativeResult?.reason || 'native-layout-failed',
                },
            };
        }

        const fallbackStartedAt = performance.now();
        this._simulateNeuralWithinRegions(iterations);
        const fallbackSolveMs = Math.max(0, performance.now() - fallbackStartedAt);
        return {
            applied: true,
            diagnostics: {
                mode: 'js-fallback',
                nodeCount: this.nodes.length,
                edgeCount: this.edges.length,
                prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
                layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
                solveMs: Math.max(0, performance.now() - solveStartedAt) + fallbackSolveMs,
                fallbackSolveMs,
                totalMs: Math.max(0, performance.now() - loadStartedAt),
                layoutReuseCount: Number(layoutReuse?.reused || 0),
                layoutReuseTotal: Number(layoutReuse?.total || this.nodes.length || 0),
                layoutReuseRatio: Number(layoutReuse?.ratio || 0),
                reason: nativeResult?.reason || 'native-layout-failed',
            },
        };
    }

    /**
     * 分区内一次性力导向：斥力 + 同区边弹簧 + 弱向心，稳定后停止（无帧循环）
     */
    _simulateNeuralWithinRegions(iterations) {
        const iters = Math.max(8, Math.min(220, iterations || 80));
        const repulsion = this.config.neuralRepulsion ?? 2800;
        const springK = this.config.neuralSpringK ?? 0.048;
        const damping = this.config.neuralDamping ?? 0.88;
        const cg = this.config.neuralCenterGravity ?? 0.014;
        const extraGap = this.config.neuralMinGap ?? 12;
        const springIdeal = this._idealSpringLengthsByRegion();
        const nodes = this.nodes;

        for (let it = 0; it < iters; it++) {
            for (const n of nodes) {
                n._fx = 0;
                n._fy = 0;
            }

            for (let i = 0; i < nodes.length; i++) {
                for (let j = i + 1; j < nodes.length; j++) {
                    const a = nodes[i];
                    const b = nodes[j];
                    if (a.regionKey !== b.regionKey) continue;
                    let dx = b.x - a.x;
                    let dy = b.y - a.y;
                    let distSq = dx * dx + dy * dy;
                    if (distSq < 0.25) distSq = 0.25;
                    const dist = Math.sqrt(distSq);
                    const minSep =
                        this._nodeRadius(a) + this._nodeRadius(b) + extraGap;
                    let f = repulsion / distSq;
                    if (dist < minSep) {
                        f += (minSep - dist) * 0.22;
                    }
                    const fx = (dx / dist) * f;
                    const fy = (dy / dist) * f;
                    a._fx -= fx;
                    a._fy -= fy;
                    b._fx += fx;
                    b._fy += fy;
                }
            }

            for (const edge of this.edges) {
                const { from, to, strength } = edge;
                if (from.regionKey !== to.regionKey) continue;
                const ideal =
                    springIdeal.get(from.regionKey) ?? 68;
                let dx = to.x - from.x;
                let dy = to.y - from.y;
                const dist = Math.sqrt(dx * dx + dy * dy) || 0.001;
                const displacement = dist - ideal * (0.82 + 0.18 * strength);
                const f = springK * displacement * (0.45 + 0.55 * strength);
                const fx = (dx / dist) * f;
                const fy = (dy / dist) * f;
                from._fx += fx;
                from._fy += fy;
                to._fx -= fx;
                to._fy -= fy;
            }

            for (const node of nodes) {
                const rect = node.regionRect;
                if (!rect) continue;
                const ccx = rect.x + rect.w / 2;
                const ccy = rect.y + rect.h / 2;
                node._fx += (ccx - node.x) * cg;
                node._fy += (ccy - node.y) * cg;
            }

            for (const node of nodes) {
                node.vx = (node.vx + node._fx) * damping;
                node.vy = (node.vy + node._fy) * damping;
                const sp = Math.hypot(node.vx, node.vy);
                const cap = 3.8;
                if (sp > cap) {
                    node.vx = (node.vx / sp) * cap;
                    node.vy = (node.vy / sp) * cap;
                }
                node.x += node.vx;
                node.y += node.vy;
                delete node._fx;
                delete node._fy;
                this._clampNodeToRegion(node);
            }
        }
    }

    _clampNodeToRegion(node) {
        const rect = node.regionRect;
        if (!rect) return;
        const r = this._nodeRadius(node) + 6;
        node.x = Math.max(rect.x + r, Math.min(rect.x + rect.w - r, node.x));
        node.y = Math.max(rect.y + r, Math.min(rect.y + rect.h - r, node.y));
    }

    // ==================== 渲染 ====================

    _drawRegionPanels(ctx) {
        for (const p of this._regionPanels) {
            const pw = Number(p.w) || 0;
            const ph = Number(p.h) || 0;
            if (pw < 2 || ph < 2) continue;
            ctx.beginPath();
            roundRectPath(ctx, p.x, p.y, pw, ph, 12);
            ctx.fillStyle = p.tint;
            ctx.fill();
            ctx.strokeStyle = 'rgba(87, 199, 255, 0.12)';
            ctx.lineWidth = 1;
            ctx.stroke();

            ctx.fillStyle = 'rgba(228, 225, 230, 0.55)';
            ctx.font = '600 10px Inter, sans-serif';
            ctx.textAlign = 'left';
            ctx.fillText(p.label, p.x + 12, p.y + 16);
        }
    }

    _drawSynapseEdge(ctx, edge, idx) {
        const { from, to, strength } = edge;
        const sameZone = from.regionKey === to.regionKey;
        const mx = (from.x + to.x) / 2;
        const my = (from.y + to.y) / 2;
        const dx = to.x - from.x;
        const dy = to.y - from.y;
        const len = Math.sqrt(dx * dx + dy * dy) || 1;
        const nx = -dy / len;
        const ny = dx / len;
        const sign = idx % 2 === 0 ? 1 : -1;
        let bend = sameZone ? 16 + strength * 22 : 32 + strength * 36;
        bend *= sign;
        const cx = mx + nx * bend;
        const cy = my + ny * bend;

        const alpha = sameZone ? 0.06 + strength * 0.14 : 0.05 + strength * 0.1;
        ctx.beginPath();
        ctx.moveTo(from.x, from.y);
        ctx.quadraticCurveTo(cx, cy, to.x, to.y);
        ctx.strokeStyle = `rgba(255,255,255,${alpha})`;
        ctx.lineWidth = 0.45 + strength * 1.35;
        ctx.stroke();
    }

    _render() {
        if (!this.enabled) {
            this._clearCanvas();
            return;
        }
        const ctx = this.ctx;
        const dpr = window.devicePixelRatio || 1;
        const W = this.canvas.width / dpr;
        const H = this.canvas.height / dpr;

        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        ctx.save();
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

        ctx.translate(this.offsetX, this.offsetY);
        ctx.scale(this.scale, this.scale);

        if (this._regionPanels.length) {
            this._drawRegionPanels(ctx);
        }

        this._drawGrid(W, H);

        this.edges.forEach((e, i) => this._drawSynapseEdge(ctx, e, i));

        for (const node of this.nodes) {
            const r = this._nodeRadius(node);
            const color = this.colors[node.type] || this.colors.event;
            const isSelected = node === this.selectedNode;
            const isHovered = node === this.hoveredNode;
            const scope = normalizeMemoryScope(node.raw?.scope);
            const outlineColor = scope.layer === 'pov'
                ? (scope.ownerType === 'user'
                    ? SCOPE_OUTLINE_COLORS.user
                    : SCOPE_OUTLINE_COLORS.character)
                : SCOPE_OUTLINE_COLORS.objective;

            if (isSelected || isHovered) {
                ctx.beginPath();
                ctx.arc(node.x, node.y, r + 9, 0, Math.PI * 2);
                const glow = ctx.createRadialGradient(node.x, node.y, r, node.x, node.y, r + 9);
                glow.addColorStop(0, color + '55');
                glow.addColorStop(1, color + '00');
                ctx.fillStyle = glow;
                ctx.fill();
            }

            ctx.beginPath();
            ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
            ctx.fillStyle = isSelected ? color : color + 'dd';
            ctx.fill();

            ctx.strokeStyle = isSelected ? '#fff' : outlineColor;
            ctx.lineWidth = isSelected ? 2.25 : 1.35;
            ctx.stroke();

            ctx.fillStyle = `rgba(255,255,255,${isHovered || isSelected ? 0.94 : 0.66})`;
            ctx.font = `${this.config.labelFontSize}px Inter, sans-serif`;
            ctx.textAlign = 'center';
            const rect = node.regionRect;
            let maxLabelW = 118;
            if (rect) {
                const frac =
                    node.regionKey === 'user' ? 0.4
                    : node.regionKey.startsWith('char:') ? 0.46
                    : 0.52;
                maxLabelW = Math.max(36, Math.min(220, rect.w * frac));
            }
            const labelDraw = this._ellipsisLabel(
                ctx,
                node.label || node.name,
                maxLabelW,
            );
            ctx.fillText(labelDraw, node.x, node.y + r + 14);
        }

        ctx.restore();
    }

    _scheduleRender() {
        if (!this.enabled || this.animId) return;
        this.animId = requestAnimationFrame(() => {
            this.animId = null;
            this._render();
        });
    }

    _drawGrid(W, H) {
        const sp = this.config.gridSpacing;
        if (!sp || sp <= 0) return;

        const ctx = this.ctx;
        ctx.strokeStyle = this.config.gridColor;
        ctx.lineWidth = 0.5;
        const startX = Math.floor(-this.offsetX / this.scale / sp) * sp;
        const startY = Math.floor(-this.offsetY / this.scale / sp) * sp;
        const endX = startX + W / this.scale + sp * 2;
        const endY = startY + H / this.scale + sp * 2;

        for (let x = startX; x < endX; x += sp) {
            ctx.beginPath();
            ctx.moveTo(x, startY);
            ctx.lineTo(x, endY);
            ctx.stroke();
        }
        for (let y = startY; y < endY; y += sp) {
            ctx.beginPath();
            ctx.moveTo(startX, y);
            ctx.lineTo(endX, y);
            ctx.stroke();
        }
    }

    _nodeRadius(node) {
        const min = this.config.minNodeRadius;
        const max = this.config.maxNodeRadius;
        return min + ((node.importance || 5) / 10) * (max - min);
    }

    _ellipsisLabel(ctx, text, maxW) {
        const s = String(text ?? "").trim() || "—";
        if (!maxW || maxW < 12) return s;
        if (ctx.measureText(s).width <= maxW) return s;
        const ell = "…";
        let lo = 0;
        let hi = s.length;
        while (lo < hi) {
            const mid = Math.ceil((lo + hi) / 2);
            const trial = s.slice(0, mid) + ell;
            if (ctx.measureText(trial).width <= maxW) lo = mid;
            else hi = mid - 1;
        }
        return lo <= 0 ? ell : s.slice(0, lo) + ell;
    }

    _cancelAnim() {
        if (this.animId) {
            cancelAnimationFrame(this.animId);
            this.animId = null;
        }
    }

    stopAnimation() {
        this._cancelAnim();
    }

    _bindEvents() {
        const c = this.canvas;

        c.addEventListener('mousedown', (e) => this._onMouseDown(e));
        c.addEventListener('mousemove', (e) => this._onMouseMove(e));
        c.addEventListener('mouseup', (e) => this._onMouseUp(e));
        c.addEventListener('wheel', (e) => this._onWheel(e), { passive: false });
        c.addEventListener('dblclick', (e) => this._onDoubleClick(e));

        c.addEventListener('touchstart', (e) => {
            if (!this.enabled) return;
            if (e.touches.length !== 1) {
                this._touchSession = null;
                return;
            }
            e.preventDefault();
            this._markTouchInteraction();
            this.dragNode = null;
            this.isDragging = false;
            this.isPanning = false;
            this._dragStartMouse = null;
            const t = e.touches[0];
            const { x, y } = this._canvasToWorld(t.clientX, t.clientY);
            this._touchSession = {
                startX: t.clientX,
                startY: t.clientY,
                lastX: t.clientX,
                lastY: t.clientY,
                nodeCandidate: this._findNodeAt(x, y),
                moved: false,
            };
        }, { passive: false });
        c.addEventListener('touchmove', (e) => {
            if (!this.enabled || !this._touchSession || e.touches.length !== 1) return;
            e.preventDefault();
            this._markTouchInteraction();
            const t = e.touches[0];
            const dx = t.clientX - this._touchSession.lastX;
            const dy = t.clientY - this._touchSession.lastY;
            const fromStartX = t.clientX - this._touchSession.startX;
            const fromStartY = t.clientY - this._touchSession.startY;
            if (Math.abs(fromStartX) > 5 || Math.abs(fromStartY) > 5) {
                this._touchSession.moved = true;
            }
            this.offsetX += dx;
            this.offsetY += dy;
            this._touchSession.lastX = t.clientX;
            this._touchSession.lastY = t.clientY;
            this._scheduleRender();
        }, { passive: false });
        c.addEventListener('touchend', () => {
            if (!this.enabled || !this._touchSession) return;
            this._markTouchInteraction();
            const sess = this._touchSession;
            this._touchSession = null;
            this.dragNode = null;
            this.isDragging = false;
            this.isPanning = false;
            this._dragStartMouse = null;
            if (!sess.moved && sess.nodeCandidate) {
                this.selectedNode = sess.nodeCandidate;
                if (this.onNodeSelect) this.onNodeSelect(sess.nodeCandidate);
                if (this.onNodeClick) this.onNodeClick(sess.nodeCandidate);
                this._render();
            }
        });
        c.addEventListener('touchcancel', () => {
            if (!this.enabled) return;
            this._markTouchInteraction();
            this._touchSession = null;
            this.dragNode = null;
            this.isDragging = false;
            this.isPanning = false;
            this._dragStartMouse = null;
        });
    }

    _markTouchInteraction() {
        this._suppressMouseUntil = Date.now() + 650;
    }

    _shouldIgnoreMouseEvent() {
        return !this.enabled || Date.now() < this._suppressMouseUntil;
    }

    _canvasToWorld(clientX, clientY) {
        const rect = this.canvas.getBoundingClientRect();
        const x = (clientX - rect.left - this.offsetX) / this.scale;
        const y = (clientY - rect.top - this.offsetY) / this.scale;
        return { x, y };
    }

    _findNodeAt(wx, wy) {
        for (let i = this.nodes.length - 1; i >= 0; i--) {
            const n = this.nodes[i];
            const r = this._nodeRadius(n);
            const dx = n.x - wx;
            const dy = n.y - wy;
            if (dx * dx + dy * dy <= (r + 4) * (r + 4)) return n;
        }
        return null;
    }

    _onMouseDown(e) {
        if (this._shouldIgnoreMouseEvent()) return;
        const { x, y } = this._canvasToWorld(e.clientX, e.clientY);
        const node = this._findNodeAt(x, y);
        this.lastMouse = { x: e.clientX, y: e.clientY };
        this._dragStartMouse = { x: e.clientX, y: e.clientY };

        if (node) {
            this.dragNode = node;
            node.pinned = true;
            this.isDragging = true;
        } else {
            this.isPanning = true;
        }
    }

    _onMouseMove(e) {
        if (this._shouldIgnoreMouseEvent()) return;
        const { x, y } = this._canvasToWorld(e.clientX, e.clientY);

        if (this.isDragging && this.dragNode) {
            this.dragNode.x = x;
            this.dragNode.y = y;
            this._clampNodeToRegion(this.dragNode);
            this._scheduleRender();
        } else if (this.isPanning) {
            this.offsetX += e.clientX - this.lastMouse.x;
            this.offsetY += e.clientY - this.lastMouse.y;
            this._scheduleRender();
        } else {
            const node = this._findNodeAt(x, y);
            if (node !== this.hoveredNode) {
                this.hoveredNode = node;
                this.canvas.style.cursor = node ? 'pointer' : 'grab';
                this._scheduleRender();
            }
        }
        this.lastMouse = { x: e.clientX, y: e.clientY };
    }

    _onMouseUp() {
        if (this._shouldIgnoreMouseEvent()) return;
        if (this.dragNode) {
            this._clampNodeToRegion(this.dragNode);
            this.dragNode.pinned = false;
            if (this.isDragging) {
                const start = this._dragStartMouse || { x: 0, y: 0 };
                const dx = (this.lastMouse.x - start.x);
                const dy = (this.lastMouse.y - start.y);
                const movedDistance = Math.sqrt(dx * dx + dy * dy);
                if (movedDistance < 6) {
                    this.selectedNode = this.dragNode;
                    if (this.onNodeSelect) this.onNodeSelect(this.dragNode);
                    if (this.onNodeClick) this.onNodeClick(this.dragNode);
                }
            }
        }
        this.dragNode = null;
        this.isDragging = false;
        this.isPanning = false;
        this._dragStartMouse = null;
        this._render();
    }

    _onWheel(e) {
        if (!this.enabled) return;
        e.preventDefault();
        const factor = e.deltaY > 0 ? 0.9 : 1.1;
        const newScale = Math.max(0.2, Math.min(5, this.scale * factor));

        const rect = this.canvas.getBoundingClientRect();
        const mx = e.clientX - rect.left;
        const my = e.clientY - rect.top;

        this.offsetX = mx - (mx - this.offsetX) * (newScale / this.scale);
        this.offsetY = my - (my - this.offsetY) * (newScale / this.scale);
        this.scale = newScale;
        this._render();
    }

    _onDoubleClick(e) {
        if (this._shouldIgnoreMouseEvent()) return;
        const { x, y } = this._canvasToWorld(e.clientX, e.clientY);
        const node = this._findNodeAt(x, y);
        if (node) {
            this.selectedNode = node;
            if (this.onNodeSelect) this.onNodeSelect(node);
            if (this.onNodeDoubleClick) this.onNodeDoubleClick(node);
            this._render();
        }
    }

    // ==================== 工具 ====================

    zoomIn() {
        if (!this.enabled) return;
        this.scale = Math.min(5, this.scale * 1.2);
        this._render();
    }

    zoomOut() {
        if (!this.enabled) return;
        this.scale = Math.max(0.2, this.scale * 0.8);
        this._render();
    }

    resetView() {
        if (!this.enabled) return;
        this.scale = 1;
        this.offsetX = 0;
        this.offsetY = 0;
        this._render();
    }

    _resize() {
        const dpr = window.devicePixelRatio || 1;
        const parent = this.canvas.parentElement;
        if (!parent) return;
        const w = Math.round(parent.clientWidth || 0);
        const h = Math.round(parent.clientHeight || 0);
        if (!isUsableGraphCanvasSize(w, h, MIN_USABLE_CANVAS_DIMENSION)) {
            return;
        }

        if (
            w === this._lastCanvasCssWidth
            && h === this._lastCanvasCssHeight
            && dpr === this._lastDevicePixelRatio
        ) {
            return;
        }

        this._lastCanvasCssWidth = w;
        this._lastCanvasCssHeight = h;
        this._lastDevicePixelRatio = dpr;

        this.canvas.width = w * dpr;
        this.canvas.height = h * dpr;
        this.canvas.style.width = w + 'px';
        this.canvas.style.height = h + 'px';

        if (!this.enabled) {
            this._clearCanvas();
            return;
        }

        if (this.nodes.length > 0 && this._regionPanels.length > 0) {
            this._nextLayoutSolveRevision();
            this._nativeLayoutBridge?.cancelPending?.('viewport-resize-layout-reset');
            this._rebuildLayoutForCurrentViewport(w, h);
            this._render();
        } else if (this._lastGraph) {
            this.loadGraph(this._lastGraph, this._lastLayoutHints);
        } else {
            this._render();
        }
    }

    destroy() {
        this._nextLayoutSolveRevision();
        this._cancelAnim();
        this._nativeLayoutBridge?.dispose?.();
        this._nativeLayoutBridge = null;
        recordGraphLayoutDebugSnapshot(
            this._lastLayoutDiagnostics
                ? {
                    ...this._lastLayoutDiagnostics,
                    enabled: false,
                    destroyed: true,
                }
                : {
                    enabled: false,
                    destroyed: true,
                },
        );
        this._resizeObserver?.disconnect();
    }
}
