<template>
    <div ref="graphContainer" class="ot-d3-graph interact-ui"
         style="position: relative; width: 100%; height: 100%; min-height: 400px; overflow: hidden; background: #f5f5f8; border: 1px solid #ddd; border-radius: 8px; box-sizing: border-box;"
         @pointerdown="onPanStart"
         @pointermove="onPanMove"
         @pointerup="onPanEnd"
         @pointercancel="onPanEnd"
         @wheel.prevent="onZoom">

        <!-- Grid_Layer: tile-based background grid -->
        <div v-if="validatedConfig"
             ref="gridLayerEl"
             data-grid-layer="true"
             :style="{
                 position: 'absolute', top: 0, left: 0,
                 width: 'calc(' + gridLayerDimensions.width + 'em + 0.2rem)',
                 height: 'calc(' + gridLayerDimensions.height + 'em + 0.2rem)',
                 transform: `translate(${panX}px, ${panY}px) scale(${zoom})`,
                 transformOrigin: '0 0',
                 pointerEvents: 'none',
                 overflow: 'hidden',
                 border: 'none',
                 boxSizing: 'content-box',
                 padding: '0.1rem',
                 boxShadow: gridOutlineColor !== 'transparent'
                     ? ('inset 0 0 0 ' + gridOutlineWidth + 'px ' + (isInteracting ? gridOutlineColor : 'transparent'))
                     : 'none',
                 transition: isInteracting
                     ? 'box-shadow 0.2s ease'
                     : 'box-shadow 0.5s ease',
             }">
            <!-- Span elements -->
            <div v-for="vs in visibleSpans" :key="vs.key"
                 :style="{
                     position: 'absolute',
                     left: vs.left, top: vs.top,
                     width: vs.width, height: vs.height,
                     pointerEvents: 'none',
                 }">
                <img v-if="!vs.failed" :src="vs.source"
                     style="width:100%;height:100%;object-fit:cover;pointer-events:none;user-select:none;"
                     draggable="false"
                     @error="onSpanImageError(vs.span, vs.source)" />
            </div>
            <!-- Individual tiles -->
            <template v-for="tile in visibleTiles" :key="tile.key">
                <div v-if="!tile.inSpan || failedSpans.has('span-' + (spanLookup.get(tile.col + ',' + tile.row)?.startCol) + '-' + (spanLookup.get(tile.col + ',' + tile.row)?.startRow))"
                     :style="{
                         position: 'absolute',
                         left: tile.left, top: tile.top,
                         width: (validatedConfig.tileShape === 'hexagon'
                             ? effectiveTileDimensions.width * 1.1547
                             : effectiveTileDimensions.width) + 'em',
                         height: effectiveTileDimensions.height + 'em',
                         pointerEvents: 'none',
                         border: validatedConfig.tileShape !== 'hexagon' && gridOutlineColor !== 'transparent' && !isInteracting
                             ? (gridOutlineWidth + 'px solid ' + gridOutlineColor) : 'none',
                         boxSizing: 'border-box',
                         background: isInteracting ? 'transparent' : gridTileBgColor,
                         opacity: isInteracting ? 0 : 1,
                         display: tilesVisible ? 'block' : 'none',
                         transition: isInteracting
                             ? 'opacity 0.2s ease-out'
                             : 'opacity 0.5s ease 0.15s',
                         clipPath: validatedConfig.tileShape === 'hexagon'
                             ? 'polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%)'
                             : undefined,
                     }">
                    <!-- Hexagon stroke overlay -->
                    <svg v-if="validatedConfig.tileShape === 'hexagon' && gridOutlineColor !== 'transparent' && !isInteracting"
                         style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;"
                         viewBox="0 0 100 100" preserveAspectRatio="none">
                        <polygon points="25,0 75,0 100,50 75,100 25,100 0,50"
                                 fill="none"
                                 :stroke="gridOutlineColor"
                                 :stroke-width="gridOutlineWidth * 2"
                                 vector-effect="non-scaling-stroke" />
                    </svg>
                    <img v-if="tile.source && !failedTiles.has(tile.key)"
                         :src="tile.source"
                         style="width:100%;height:100%;object-fit:cover;pointer-events:none;user-select:none;"
                         draggable="false"
                         @error="onTileImageError(tile.col, tile.row, tile.source)" />
                    <div v-else
                         :style="{
                             width: '100%', height: '100%',
                             background: validatedConfig.transparentWhenUnavailable ? 'transparent' : gridTileBgColor,
                         }">
                    </div>
                </div>
            </template>
        </div>

        <svg ref="svgContainer"
             :style="{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', transform: `translate(${panX}px, ${panY}px) scale(${zoom})`, transformOrigin: '0 0' }"></svg>
        <div v-for="node in renderedNodes" :key="node.id"
             :ref="el => setNodeRef(node.id, el)"
             class="ot-d3-graph-node-wrapper interact-ui"
             :style="{
                 position: 'absolute',
                 transform: `translate(${(node.x - 55) * zoom + panX}px, ${(node.y - 28) * zoom + panY}px) scale(${zoom})`,
                 transformOrigin: '0 0',
                 willChange: 'transform',
             }"
             @pointerdown.stop="e => onDragStart(e, node)"
             @pointermove.stop="onDragMove"
             @pointerup.stop="onDragEnd"
             @pointercancel.stop="onDragEnd">
            <component :is="node.component"
                       :prop_state="{ nodeId: node.id, nodeLabel: node.label, id: node.id, label: node.label, nodeType: node.nodeType }" />
        </div>

        <!-- Minimap -->
        <div class="ot-d3-graph-minimap interact-ui" @pointerdown.stop @pointermove.stop @pointerup.stop>
            <svg :viewBox="minimapViewBox" preserveAspectRatio="xMidYMid meet" style="width: 100%; height: 100%;">
                <line v-for="(edge, i) in minimapEdges" :key="'me'+i"
                      :x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2"
                      stroke="#ccc" stroke-width="1" />
                <rect v-for="node in renderedNodes" :key="'mn'+node.id"
                      :x="node.x - 4" :y="node.y - 3"
                      width="8" height="6" rx="1"
                      :fill="nodeTypeColor(node.nodeType)" />
                <rect :x="viewportRect.x" :y="viewportRect.y"
                      :width="viewportRect.w" :height="viewportRect.h"
                      fill="none" stroke="#6366f1" stroke-width="2" rx="1" />
            </svg>
        </div>

        <!-- Canvas controls -->
        <div class="ot-d3-graph-controls interact-ui" @pointerdown.stop @pointermove.stop @pointerup.stop>
            <div class="ot-d3-graph-controls__zoom">
                <button class="ot-d3-graph-controls__btn interact-ui" @click="zoomIn" title="Zoom in">+</button>
                <span class="ot-d3-graph-controls__zoom-level">{{ Math.round(zoom * 100) }}%</span>
                <button class="ot-d3-graph-controls__btn interact-ui" @click="zoomOut" title="Zoom out">−</button>
                <button class="ot-d3-graph-controls__btn interact-ui" @click="resetView" title="Reset view">⌂</button>
            </div>
            <div class="ot-d3-graph-controls__anchors">
                <button class="ot-d3-graph-controls__btn ot-d3-graph-controls__btn--save interact-ui"
                        @click="saveAnchor" title="Save current view as anchor">+ Anchor</button>
                <div v-for="(anchor, i) in anchors" :key="i"
                     class="ot-d3-graph-controls__anchor interact-ui"
                     @click="goToAnchor(anchor)"
                     :title="`Go to: ${anchor.name} (${Math.round(anchor.zoom * 100)}%)`">
                    <span class="ot-d3-graph-controls__anchor-name">{{ anchor.name }}</span>
                    <button class="ot-d3-graph-controls__anchor-del interact-ui"
                            @click.stop="removeAnchor(i)" title="Remove anchor">×</button>
                </div>
            </div>

            <!-- Simulation controls -->
            <div class="ot-d3-graph-controls__section">
                <button class="ot-d3-graph-controls__btn ot-d3-graph-controls__btn--toggle interact-ui"
                        @click="showSimControls = !showSimControls"
                        :title="showSimControls ? 'Hide simulation controls' : 'Show simulation controls'">
                    {{ showSimControls ? '▾' : '▸' }} Sim
                </button>
                <div v-if="showSimControls" class="ot-d3-graph-controls__panel">
                    <label class="ot-d3-graph-controls__label">
                        Charge
                        <input type="range" min="-1000" max="0" step="10"
                               :value="simCharge"
                               @input="updateSimCharge($event.target.value)"
                               class="ot-d3-graph-controls__range interact-ui" />
                        <span class="ot-d3-graph-controls__val">{{ simCharge }}</span>
                    </label>
                    <label class="ot-d3-graph-controls__label">
                        Link dist
                        <input type="range" min="10" max="500" step="5"
                               :value="simLinkDist"
                               @input="updateSimLinkDist($event.target.value)"
                               class="ot-d3-graph-controls__range interact-ui" />
                        <span class="ot-d3-graph-controls__val">{{ simLinkDist }}</span>
                    </label>
                    <button class="ot-d3-graph-controls__btn ot-d3-graph-controls__btn--save interact-ui"
                            @click="reheatSimulation" title="Reheat simulation">↻ Reheat</button>
                </div>
            </div>

            <!-- Grid controls -->
            <div class="ot-d3-graph-controls__section">
                <button class="ot-d3-graph-controls__btn ot-d3-graph-controls__btn--toggle interact-ui"
                        @click="showGridControls = !showGridControls"
                        :title="showGridControls ? 'Hide grid controls' : 'Show grid controls'">
                    {{ showGridControls ? '▾' : '▸' }} Grid
                </button>
                <div v-if="showGridControls" class="ot-d3-graph-controls__panel">
                    <label class="ot-d3-graph-controls__label">
                        <input type="checkbox"
                               :checked="gridEnabled !== null ? gridEnabled : !!prop_state?.gridConfig"
                               @change="toggleGridEnabled"
                               class="interact-ui" />
                        Enabled
                    </label>
                    <template v-if="validatedConfig">
                        <label class="ot-d3-graph-controls__label">
                            Shape
                            <select :value="validatedConfig.tileShape"
                                    @change="gridTileShape = $event.target.value"
                                    class="ot-d3-graph-controls__select interact-ui">
                                <option value="rectangle">Rectangle</option>
                                <option value="hexagon">Hexagon</option>
                            </select>
                        </label>
                        <label class="ot-d3-graph-controls__label">
                            Columns
                            <input type="range" min="1" max="100" step="1"
                                   :value="validatedConfig.columns"
                                   @input="gridColumns = Number($event.target.value)"
                                   class="ot-d3-graph-controls__range interact-ui" />
                            <span class="ot-d3-graph-controls__val">{{ validatedConfig.columns }}</span>
                        </label>
                        <label class="ot-d3-graph-controls__label">
                            Rows
                            <input type="range" min="1" max="100" step="1"
                                   :value="validatedConfig.rows"
                                   @input="gridRows = Number($event.target.value)"
                                   class="ot-d3-graph-controls__range interact-ui" />
                            <span class="ot-d3-graph-controls__val">{{ validatedConfig.rows }}</span>
                        </label>
                        <label class="ot-d3-graph-controls__label">
                            Tile W
                            <input type="range" min="0.5" max="10" step="0.25"
                                   :value="validatedConfig.tileWidth"
                                   @input="gridTileWidth = Number($event.target.value)"
                                   class="ot-d3-graph-controls__range interact-ui" />
                            <span class="ot-d3-graph-controls__val">{{ validatedConfig.tileWidth.toFixed(2) }}em</span>
                        </label>
                        <label class="ot-d3-graph-controls__label">
                            Tile H
                            <input type="range" min="0.5" max="10" step="0.25"
                                   :value="validatedConfig.tileHeight"
                                   @input="gridTileHeight = Number($event.target.value)"
                                   class="ot-d3-graph-controls__range interact-ui" />
                            <span class="ot-d3-graph-controls__val">{{ validatedConfig.tileHeight.toFixed(2) }}em</span>
                        </label>
                        <label class="ot-d3-graph-controls__label">
                            <input type="checkbox"
                                   :checked="validatedConfig.transparentWhenUnavailable"
                                   @change="gridTransparent = $event.target.checked"
                                   class="interact-ui" />
                            Transparent
                        </label>
                        <label class="ot-d3-graph-controls__label">
                            Outline
                            <select :value="gridOutlineColor"
                                    @change="gridOutlineColor = $event.target.value"
                                    class="ot-d3-graph-controls__select interact-ui">
                                <option value="transparent">None</option>
                                <option value="#333333">#333</option>
                                <option value="#666666">#666</option>
                                <option value="#999999">#999</option>
                                <option value="#ffffff">#fff</option>
                                <option value="#6366f1">Indigo</option>
                                <option value="#ef4444">Red</option>
                                <option value="#10b981">Green</option>
                            </select>
                            <input v-if="gridOutlineColor !== 'transparent'" type="color"
                                   :value="gridOutlineColor"
                                   @input="gridOutlineColor = $event.target.value"
                                   class="ot-d3-graph-controls__color interact-ui"
                                   title="Pick outline color" />
                        </label>
                        <label v-if="gridOutlineColor !== 'transparent'" class="ot-d3-graph-controls__label">
                            Width
                            <input type="range" min="1" max="4" step="1"
                                   :value="gridOutlineWidth"
                                   @input="gridOutlineWidth = Number($event.target.value)"
                                   class="ot-d3-graph-controls__range interact-ui" />
                            <span class="ot-d3-graph-controls__val">{{ gridOutlineWidth }}px</span>
                        </label>
                        <label class="ot-d3-graph-controls__label">
                            Tile BG
                            <select :value="gridTileBgColor"
                                    @change="gridTileBgColor = $event.target.value"
                                    class="ot-d3-graph-controls__select interact-ui">
                                <option value="transparent">None</option>
                                <option value="#000000">Black</option>
                                <option value="#111111">#111</option>
                                <option value="#1a1a2e">#1a1a2e</option>
                                <option value="#0f172a">Slate</option>
                                <option value="#ffffff">White</option>
                                <option value="#f5f5f8">Light</option>
                            </select>
                            <input v-if="gridTileBgColor !== 'transparent'" type="color"
                                   :value="gridTileBgColor"
                                   @input="gridTileBgColor = $event.target.value"
                                   class="ot-d3-graph-controls__color interact-ui"
                                   title="Pick tile background color" />
                        </label>
                    </template>
                </div>
            </div>
        </div>
    </div>
</template>

<script setup>
    import { ref, reactive, shallowRef, computed, provide, onMounted, onBeforeUnmount, defineAsyncComponent, markRaw, defineProps, defineExpose } from 'vue'
    import otUI from 'ot-ui'
    import fetchBase from 'ot-fetch'

    const componentName = 'ot-d3-graph'
    const componentVersion = 'v1.0.0'
    const interfaceVersion = componentVersion
    defineExpose({ componentName, componentVersion, interfaceVersion })

    const props = defineProps({
        prop_state: { type: Object, default: () => ({}) },
        graphData: { type: Object, default: () => ({ nodes: [], edges: [] }) },
        chargeStrength: { type: Number, default: -300 },
        linkDistance: { type: Number, default: 150 },
        centerX: { type: Number, default: null },
        centerY: { type: Number, default: null },
        layer: { type: String, default: 'base' },
    })

    const { externalFetch } = fetchBase
    const simulation = shallowRef(null)
    const renderedNodes = ref([])
    const destroyed = ref(false)
    const resizeObserver = ref(null)
    const graphContainer = ref(null)
    const svgContainer = ref(null)
    const d3Ref = shallowRef(null)
    const containerWidth = ref(800)
    const containerHeight = ref(400)
    const nodeElMap = new Map()
    const panX = ref(0)
    const panY = ref(0)
    const zoom = ref(1)

    // ── Controls panel state ──────────────────────────────────────────
    const showSimControls = ref(false)
    const showGridControls = ref(false)
    const isInteracting = ref(false)
    const tilesVisible = ref(true)
    let interactionTimer = null

    function startInteracting() {
        if (isInteracting.value) return  // already interacting, skip
        isInteracting.value = true
        clearTimeout(interactionTimer)
        // Hide tiles after fade-out completes
        setTimeout(() => { if (isInteracting.value) tilesVisible.value = false }, 200)
    }

    function stopInteracting() {
        clearTimeout(interactionTimer)
        interactionTimer = setTimeout(() => {
            tilesVisible.value = true  // make visible so fade-in can play
            // Next frame: set not interacting to trigger opacity 1
            requestAnimationFrame(() => { isInteracting.value = false })
        }, 150)
    }
    const simCharge = ref(-300)
    const simLinkDist = ref(150)
    const gridColumns = ref(null)   // null = use prop_state value
    const gridRows = ref(null)
    const gridTileWidth = ref(null)
    const gridTileHeight = ref(null)
    const gridTileShape = ref(null)   // null = use prop_state value; initialized from scaffold on mount
    const gridTransparent = ref(null)
    const gridEnabled = ref(null)   // null = use prop_state presence
    const gridOutlineColor = ref('#333333')
    const gridOutlineWidth = ref(1)
    const gridTileBgColor = ref('transparent')

    function updateSimCharge(val) {
        const v = Number(val)
        if (!Number.isFinite(v)) return
        simCharge.value = v
        if (simulation.value) {
            simulation.value.force('charge').strength(v)
            simulation.value.alpha(0.3).restart()
        }
    }

    function updateSimLinkDist(val) {
        const v = Number(val)
        if (!Number.isFinite(v) || v < 0) return
        simLinkDist.value = v
        if (simulation.value) {
            simulation.value.force('link').distance(v)
            simulation.value.alpha(0.3).restart()
        }
    }

    function reheatSimulation() {
        if (simulation.value) {
            simulation.value.alpha(1).restart()
        }
    }

    function toggleGridEnabled() {
        if (gridEnabled.value === null) {
            // If prop_state has gridConfig, disable it; otherwise enable with defaults
            gridEnabled.value = !props.prop_state?.gridConfig
        } else {
            gridEnabled.value = !gridEnabled.value
        }
    }

    // ── Grid background: validatedConfig ──────────────────────────────
    const validatedConfig = computed(() => {
        // Check UI override first
        if (gridEnabled.value === false) return null

        const raw = props.prop_state?.gridConfig
        // If no prop_state config and UI hasn't explicitly enabled, return null
        if (raw == null && gridEnabled.value !== true) return null

        const DEFAULTS = {
            tileWidth: 2,
            tileHeight: 2,
            columns: 10,
            rows: 10,
            tileShape: 'rectangle',
            zoomLevels: [],
            defaultTileSource: null,
            transparentWhenUnavailable: false,
            tileSpans: [],
        }

        // Shallow merge with defaults
        const cfg = { ...DEFAULTS, ...(raw || {}) }

        // Apply UI overrides from controls panel
        if (gridColumns.value !== null) cfg.columns = gridColumns.value
        if (gridRows.value !== null) cfg.rows = gridRows.value
        if (gridTileWidth.value !== null) cfg.tileWidth = gridTileWidth.value
        if (gridTileHeight.value !== null) cfg.tileHeight = gridTileHeight.value
        if (gridTileShape.value !== null) cfg.tileShape = gridTileShape.value
        if (gridTransparent.value !== null) cfg.transparentWhenUnavailable = gridTransparent.value

        // ── Step 1: Type and range validation for scalar fields ──

        // columns
        if (typeof cfg.columns !== 'number' || !Number.isFinite(cfg.columns) || cfg.columns < 1 || !Number.isInteger(cfg.columns)) {
            console.warn('[ot-d3-graph] ERR_GRID_COLUMNS_INVALID', { provided_value: cfg.columns, clamped_value: 1 })
            cfg.columns = 1
        }

        // rows
        if (typeof cfg.rows !== 'number' || !Number.isFinite(cfg.rows) || cfg.rows < 1 || !Number.isInteger(cfg.rows)) {
            console.warn('[ot-d3-graph] ERR_GRID_ROWS_INVALID', { provided_value: cfg.rows, clamped_value: 1 })
            cfg.rows = 1
        }

        // tileWidth: must be finite number > 0
        if (typeof cfg.tileWidth !== 'number' || !Number.isFinite(cfg.tileWidth) || cfg.tileWidth <= 0) {
            console.warn('[ot-d3-graph] ERR_GRID_TILE_WIDTH_INVALID', { provided_value: cfg.tileWidth, clamped_value: 2 })
            cfg.tileWidth = 2
        }
        // tileWidth: below minimum
        if (cfg.tileWidth < 0.01) {
            console.warn('[ot-d3-graph] ERR_GRID_TILE_WIDTH_BELOW_MIN', { provided_value: cfg.tileWidth, clamped_value: 0.01 })
            cfg.tileWidth = 0.01
        }

        // tileHeight: must be finite number > 0
        if (typeof cfg.tileHeight !== 'number' || !Number.isFinite(cfg.tileHeight) || cfg.tileHeight <= 0) {
            console.warn('[ot-d3-graph] ERR_GRID_TILE_HEIGHT_INVALID', { provided_value: cfg.tileHeight, clamped_value: 2 })
            cfg.tileHeight = 2
        }
        // tileHeight: below minimum
        if (cfg.tileHeight < 0.01) {
            console.warn('[ot-d3-graph] ERR_GRID_TILE_HEIGHT_BELOW_MIN', { provided_value: cfg.tileHeight, clamped_value: 0.01 })
            cfg.tileHeight = 0.01
        }

        // tileShape
        if (cfg.tileShape !== 'rectangle' && cfg.tileShape !== 'hexagon') {
            console.warn('[ot-d3-graph] ERR_GRID_TILE_SHAPE_INVALID', { provided_value: cfg.tileShape, clamped_value: 'rectangle' })
            cfg.tileShape = 'rectangle'
        }

        // defaultTileSource
        if (cfg.defaultTileSource !== null && cfg.defaultTileSource !== undefined) {
            if (typeof cfg.defaultTileSource !== 'string' || cfg.defaultTileSource === '') {
                console.warn('[ot-d3-graph] ERR_GRID_DEFAULT_SOURCE_INVALID', { provided_value: cfg.defaultTileSource })
                cfg.defaultTileSource = null
            }
        }

        // transparentWhenUnavailable
        if (typeof cfg.transparentWhenUnavailable !== 'boolean') {
            const coerced = !!cfg.transparentWhenUnavailable
            console.warn('[ot-d3-graph] ERR_GRID_TRANSPARENT_WHEN_UNAVAILABLE_INVALID', { provided_value: cfg.transparentWhenUnavailable, coerced_value: coerced })
            cfg.transparentWhenUnavailable = coerced
        }

        // ── Step 2: Tile cap validation ──
        if (cfg.columns * cfg.rows > 100000) {
            const origRows = cfg.rows
            cfg.rows = Math.floor(100000 / cfg.columns)
            console.warn('[ot-d3-graph] ERR_GRID_TILE_CAP_EXCEEDED', {
                original_columns: cfg.columns,
                original_rows: origRows,
                clamped_rows: cfg.rows,
                resulting_total: cfg.columns * cfg.rows,
            })
        }

        // ── Step 3: zoomLevels entry validation ──
        const rawZoomLevels = Array.isArray(cfg.zoomLevels) ? cfg.zoomLevels : []
        const validZoomLevels = []
        for (let i = 0; i < rawZoomLevels.length; i++) {
            const entry = rawZoomLevels[i]
            if (entry == null || typeof entry !== 'object') continue

            // minZoom
            if (typeof entry.minZoom !== 'number' || !Number.isFinite(entry.minZoom)) {
                console.warn('[ot-d3-graph] ERR_GRID_ZOOM_LEVEL_MISSING_MIN', { index: i })
                continue
            }
            // maxZoom
            if (typeof entry.maxZoom !== 'number' || !Number.isFinite(entry.maxZoom)) {
                console.warn('[ot-d3-graph] ERR_GRID_ZOOM_LEVEL_MISSING_MAX', { index: i })
                continue
            }
            // tileSource
            if (typeof entry.tileSource !== 'string' || entry.tileSource === '') {
                console.warn('[ot-d3-graph] ERR_GRID_ZOOM_LEVEL_MISSING_SOURCE', { index: i })
                continue
            }
            // range inverted
            if (entry.minZoom >= entry.maxZoom) {
                console.warn('[ot-d3-graph] ERR_GRID_ZOOM_LEVEL_RANGE_INVERTED', { index: i, minZoom: entry.minZoom, maxZoom: entry.maxZoom })
                continue
            }

            validZoomLevels.push({ ...entry, _origIndex: i })
        }

        // ── Step 4: zoomLevels overlap detection ──
        for (let i = 0; i < validZoomLevels.length; i++) {
            for (let j = i + 1; j < validZoomLevels.length; j++) {
                const a = validZoomLevels[i]
                const b = validZoomLevels[j]
                // Two [min, max) ranges overlap if a.min < b.max && b.min < a.max
                if (a.minZoom < b.maxZoom && b.minZoom < a.maxZoom) {
                    console.warn('[ot-d3-graph] ERR_GRID_ZOOM_LEVEL_OVERLAP', { index_a: a._origIndex, index_b: b._origIndex })
                }
            }
        }

        // ── Step 5: zoomLevels per-zoom tile dimension validation ──
        const finalZoomLevels = validZoomLevels.map(entry => {
            const result = {
                minZoom: entry.minZoom,
                maxZoom: entry.maxZoom,
                tileSource: entry.tileSource,
            }

            // per-zoom tileWidth
            if (entry.tileWidth !== undefined && entry.tileWidth !== null) {
                if (typeof entry.tileWidth !== 'number' || !Number.isFinite(entry.tileWidth) || entry.tileWidth <= 0) {
                    console.warn('[ot-d3-graph] ERR_GRID_ZOOM_LEVEL_TILE_WIDTH_INVALID', { index: entry._origIndex, provided_value: entry.tileWidth })
                    // ignore override — don't set result.tileWidth
                } else if (entry.tileWidth < 0.01) {
                    console.warn('[ot-d3-graph] ERR_GRID_ZOOM_LEVEL_TILE_WIDTH_BELOW_MIN', { index: entry._origIndex, provided_value: entry.tileWidth, clamped_value: 0.01 })
                    result.tileWidth = 0.01
                } else {
                    result.tileWidth = entry.tileWidth
                }
            }

            // per-zoom tileHeight
            if (entry.tileHeight !== undefined && entry.tileHeight !== null) {
                if (typeof entry.tileHeight !== 'number' || !Number.isFinite(entry.tileHeight) || entry.tileHeight <= 0) {
                    console.warn('[ot-d3-graph] ERR_GRID_ZOOM_LEVEL_TILE_HEIGHT_INVALID', { index: entry._origIndex, provided_value: entry.tileHeight })
                    // ignore override — don't set result.tileHeight
                } else if (entry.tileHeight < 0.01) {
                    console.warn('[ot-d3-graph] ERR_GRID_ZOOM_LEVEL_TILE_HEIGHT_BELOW_MIN', { index: entry._origIndex, provided_value: entry.tileHeight, clamped_value: 0.01 })
                    result.tileHeight = 0.01
                } else {
                    result.tileHeight = entry.tileHeight
                }
            }

            return result
        })

        // ── Step 6: tileSpans validation ──
        const rawSpans = Array.isArray(cfg.tileSpans) ? cfg.tileSpans : []
        const validSpans = []
        const occupiedCells = new Set()

        for (let i = 0; i < rawSpans.length; i++) {
            const span = rawSpans[i]
            if (span == null || typeof span !== 'object') continue

            const startCol = span.startCol
            const startRow = span.startRow
            const spanCols = span.spanCols
            const spanRows = span.spanRows

            // spanCols validation
            if (typeof spanCols !== 'number' || !Number.isFinite(spanCols) || spanCols < 1 || !Number.isInteger(spanCols)) {
                console.warn('[ot-d3-graph] ERR_GRID_TILE_SPAN_COLS_INVALID', { index: i, provided_value: spanCols })
                continue
            }

            // spanRows validation
            if (typeof spanRows !== 'number' || !Number.isFinite(spanRows) || spanRows < 1 || !Number.isInteger(spanRows)) {
                console.warn('[ot-d3-graph] ERR_GRID_TILE_SPAN_ROWS_INVALID', { index: i, provided_value: spanRows })
                continue
            }

            // span cap
            if (spanCols * spanRows > 100000) {
                console.warn('[ot-d3-graph] ERR_GRID_TILE_SPAN_EXCEEDS_CAP', { index: i, spanCols, spanRows, product: spanCols * spanRows })
                continue
            }

            // tileSource
            if (typeof span.tileSource !== 'string' || span.tileSource === '') {
                console.warn('[ot-d3-graph] ERR_GRID_TILE_SPAN_MISSING_SOURCE', { index: i })
                continue
            }

            // startCol / startRow basic validation (non-negative integer)
            if (typeof startCol !== 'number' || !Number.isFinite(startCol) || startCol < 0 || !Number.isInteger(startCol)) {
                continue
            }
            if (typeof startRow !== 'number' || !Number.isFinite(startRow) || startRow < 0 || !Number.isInteger(startRow)) {
                continue
            }

            // bounds check
            if (startCol + spanCols > cfg.columns || startRow + spanRows > cfg.rows) {
                console.warn('[ot-d3-graph] ERR_GRID_TILE_SPAN_EXCEEDS_GRID', {
                    index: i, startCol, startRow, spanCols, spanRows,
                    columns: cfg.columns, rows: cfg.rows,
                })
                continue
            }

            // overlap check
            let hasOverlap = false
            for (let c = startCol; c < startCol + spanCols; c++) {
                for (let r = startRow; r < startRow + spanRows; r++) {
                    if (occupiedCells.has(`${c},${r}`)) {
                        hasOverlap = true
                        break
                    }
                }
                if (hasOverlap) break
            }
            if (hasOverlap) {
                console.warn('[ot-d3-graph] ERR_GRID_TILE_SPAN_OVERLAP', { index: i, startCol, startRow, spanCols, spanRows })
                continue
            }

            // Mark cells as occupied
            for (let c = startCol; c < startCol + spanCols; c++) {
                for (let r = startRow; r < startRow + spanRows; r++) {
                    occupiedCells.add(`${c},${r}`)
                }
            }

            validSpans.push({
                startCol,
                startRow,
                spanCols,
                spanRows,
                tileSource: span.tileSource,
            })
        }

        return {
            tileWidth: cfg.tileWidth,
            tileHeight: cfg.tileHeight,
            columns: cfg.columns,
            rows: cfg.rows,
            tileShape: cfg.tileShape,
            zoomLevels: finalZoomLevels,
            defaultTileSource: cfg.defaultTileSource,
            transparentWhenUnavailable: cfg.transparentWhenUnavailable,
            tileSpans: validSpans,
        }
    })
    // ── End Grid background: validatedConfig ──────────────────────────

    // ── Grid background: activeZoomLevel ──
    const activeZoomLevel = computed(() => {
        if (!validatedConfig.value) return null
        const z = zoom.value
        for (const entry of validatedConfig.value.zoomLevels) {
            if (z >= entry.minZoom && z < entry.maxZoom) return entry
        }
        return null
    })

    // ── Grid background: effectiveTileDimensions ──
    const effectiveTileDimensions = computed(() => {
        if (!validatedConfig.value) return { width: 0, height: 0 }
        const azl = activeZoomLevel.value
        return {
            width: (azl && azl.tileWidth !== undefined) ? azl.tileWidth : validatedConfig.value.tileWidth,
            height: (azl && azl.tileHeight !== undefined) ? azl.tileHeight : validatedConfig.value.tileHeight,
        }
    })

    // ── Grid background: gridLayerDimensions ──
    const gridLayerDimensions = computed(() => {
        if (!validatedConfig.value) return { width: 0, height: 0 }
        const { columns, rows, tileShape } = validatedConfig.value
        const etw = effectiveTileDimensions.value.width
        const eth = effectiveTileDimensions.value.height
        if (tileShape === 'hexagon') {
            // Flat-top: hex bounding box is 1.1547 * tileWidth wide
            const hexW = 1.1547 * etw
            return {
                width: (columns - 1) * 0.749 * hexW + hexW,
                height: rows * eth + (columns > 1 ? 0.5 * eth : 0),
            }
        }
        return { width: columns * etw, height: rows * eth }
    })

    // ── Grid background: resolvedZoomSource ──
    const resolvedZoomSource = computed(() => {
        const azl = activeZoomLevel.value
        return azl ? azl.tileSource : null
    })

    // ── Grid background: tilePosition ──
    function tilePosition(col, row, tileShape, effectiveWidth, effectiveHeight) {
        if (tileShape === 'hexagon') {
            // Flat-top honeycomb: columns step by ~0.75 * hexWidth (where hexWidth = 1.1547 * tileWidth),
            // odd columns offset down by 0.5*h
            const hexW = 1.1547 * effectiveWidth
            return {
                left: col * 0.749 * hexW,
                top: row * effectiveHeight + (col % 2 === 1 ? 0.5 * effectiveHeight : 0),
            }
        }
        return {
            left: col * effectiveWidth,
            top: row * effectiveHeight,
        }
    }

    // ── Grid background: spanLookup ──
    const spanLookup = computed(() => {
        const map = new Map()
        if (!validatedConfig.value) return map
        for (const span of validatedConfig.value.tileSpans) {
            for (let c = span.startCol; c < span.startCol + span.spanCols; c++) {
                for (let r = span.startRow; r < span.startRow + span.spanRows; r++) {
                    map.set(`${c},${r}`, span)
                }
            }
        }
        return map
    })

    // ── Grid background: resolveTileSource ──
    function resolveTileSource(col, row, zoomSrc, config, spanLookupMap) {
        // Priority 1: zoom-level source
        if (zoomSrc) return zoomSrc
        // Priority 2: span source
        const span = spanLookupMap.get(`${col},${row}`)
        if (span) return span.tileSource
        // Priority 3: default source
        if (config.defaultTileSource) return config.defaultTileSource
        // Priority 4: placeholder (null)
        return null
    }

    // ── Grid background: gridLayerEl, failedTiles, failedSpans ──
    const gridLayerEl = ref(null)
    const failedTiles = reactive(new Set())
    const failedSpans = reactive(new Set())

    // Clear failed tiles when zoom source changes (fresh load attempt)
    let _prevZoomSource = null

    // ── Grid background: visibleTiles ──
    const visibleTiles = computed(() => {
        if (destroyed.value) return []
        const cfg = validatedConfig.value
        if (!cfg) return []
        const el = gridLayerEl.value
        if (!el) return []

        // Clear failedTiles if zoom source changed
        const currentZoomSrc = resolvedZoomSource.value
        if (currentZoomSrc !== _prevZoomSource) {
            failedTiles.clear()
            failedSpans.clear()
            _prevZoomSource = currentZoomSrc
        }

        // Em-to-px conversion
        let fontSizePx
        try {
            fontSizePx = parseFloat(getComputedStyle(el).fontSize)
        } catch (e) {
            fontSizePx = NaN
        }
        // Guard: element detached from layout or hidden produces NaN fontSize
        if (!Number.isFinite(fontSizePx) || fontSizePx <= 0) return []
        const etw = effectiveTileDimensions.value.width
        const eth = effectiveTileDimensions.value.height
        const etwPx = etw * fontSizePx
        const ethPx = eth * fontSizePx

        // NaN/Infinity guard
        if (!Number.isFinite(etwPx) || !Number.isFinite(ethPx) || etwPx <= 0 || ethPx <= 0) {
            console.warn('[ot-d3-graph] ERR_GRID_VIRTUALISATION_CALC_FAILED', { etwPx, ethPx, fontSizePx })
            return []
        }

        const { columns, rows, tileShape } = cfg
        const zoomVal = zoom.value
        const pX = panX.value
        const pY = panY.value
        const cW = containerWidth.value
        const cH = containerHeight.value
        const zoomSrc = resolvedZoomSource.value
        const slMap = spanLookup.value

        let minCol, maxCol, minRow, maxRow

        if (tileShape === 'hexagon') {
            const hexWPx = 1.1547 * etwPx
            const hStep = 0.749 * hexWPx
            const vStep = ethPx
            minCol = Math.max(0, Math.floor((-pX / zoomVal) / hStep) - 1)
            maxCol = Math.min(columns - 1, Math.ceil((cW - pX) / zoomVal / hStep) + 1)
            minRow = Math.max(0, Math.floor((-pY / zoomVal) / vStep) - 1)
            maxRow = Math.min(rows - 1, Math.ceil((cH - pY) / zoomVal / vStep) + 1)
        } else {
            minCol = Math.max(0, Math.floor((-pX / zoomVal) / etwPx) - 1)
            maxCol = Math.min(columns - 1, Math.ceil((cW - pX) / zoomVal / etwPx) + 1)
            minRow = Math.max(0, Math.floor((-pY / zoomVal) / ethPx) - 1)
            maxRow = Math.min(rows - 1, Math.ceil((cH - pY) / zoomVal / ethPx) + 1)
        }

        // NaN guard on computed ranges
        if (!Number.isFinite(minCol) || !Number.isFinite(maxCol) || !Number.isFinite(minRow) || !Number.isFinite(maxRow)) {
            console.warn('[ot-d3-graph] ERR_GRID_VIRTUALISATION_CALC_FAILED', { minCol, maxCol, minRow, maxRow })
            return []
        }

        const tiles = []
        for (let c = minCol; c <= maxCol; c++) {
            for (let r = minRow; r <= maxRow; r++) {
                const pos = tilePosition(c, r, tileShape, etw, eth)
                const source = resolveTileSource(c, r, zoomSrc, cfg, slMap)
                const spanEntry = slMap.get(`${c},${r}`)
                // Tile is in a span if: span exists AND no zoom-level source overrides
                const inSpan = !!(spanEntry && !zoomSrc)
                tiles.push({
                    col: c,
                    row: r,
                    key: `tile-${c}-${r}`,
                    left: pos.left + 'em',
                    top: pos.top + 'em',
                    source,
                    inSpan,
                })
            }
        }
        return tiles
    })

    // ── Grid background: visibleSpans ──
    const visibleSpans = computed(() => {
        const cfg = validatedConfig.value
        if (!cfg || !cfg.tileSpans.length) return []
        // If zoom-level source is active, spans are overridden — don't render them
        if (resolvedZoomSource.value) return []

        const etw = effectiveTileDimensions.value.width
        const eth = effectiveTileDimensions.value.height
        const tiles = visibleTiles.value

        // Build a Set of visible tile keys for fast lookup
        const visibleKeys = new Set()
        for (const t of tiles) visibleKeys.add(t.key)

        const spans = []
        for (const span of cfg.tileSpans) {
            // Check if any tile in the span region is visible
            let isVisible = false
            outer:
            for (let c = span.startCol; c < span.startCol + span.spanCols; c++) {
                for (let r = span.startRow; r < span.startRow + span.spanRows; r++) {
                    if (visibleKeys.has(`tile-${c}-${r}`)) {
                        isVisible = true
                        break outer
                    }
                }
            }
            if (!isVisible) continue

            const pos = tilePosition(span.startCol, span.startRow, cfg.tileShape, etw, eth)
            const spanKey = `span-${span.startCol}-${span.startRow}`
            spans.push({
                span,
                key: spanKey,
                left: pos.left + 'em',
                top: pos.top + 'em',
                width: span.spanCols * etw + 'em',
                height: span.spanRows * eth + 'em',
                source: span.tileSource,
                failed: failedSpans.has(spanKey),
            })
        }
        return spans
    })

    // ── Grid background: image error handlers ──
    function onTileImageError(col, row, src) {
        console.warn('[ot-d3-graph] ERR_GRID_IMAGE_LOAD_FAILED', { col, row, attempted_src: src })
        failedTiles.add(`tile-${col}-${row}`)
    }

    function onSpanImageError(span, src) {
        console.warn('[ot-d3-graph] ERR_GRID_TILE_SPAN_IMAGE_LOAD_FAILED', {
            startCol: span.startCol, startRow: span.startRow,
            spanCols: span.spanCols, spanRows: span.spanRows,
            attempted_src: src,
        })
        failedSpans.add(`span-${span.startCol}-${span.startRow}`)
    }

    function setNodeRef(id, el) {
        if (el) nodeElMap.set(id, el)
        else nodeElMap.delete(id)
    }

    provide('d3GraphRegister', (id, label, el) => {
    })
    provide('d3GraphUnregister', (id) => {
        console.log(`[ot-d3-graph] unregisterNode: ${id}`)
    })

    function onTick() {
        if (!simulation.value) return
        for (const node of renderedNodes.value) {
            if (node._simNode) {
                node.x = node._simNode.x || 0
                node.y = node._simNode.y || 0
            }
        }
        // Update edges
        const svg = svgContainer.value
        if (svg && d3Ref.value) {
            const links = simulation.value.force('link')?.links() || []
            const sel = d3Ref.value.select(svg).selectAll('line')
                .data(links, d => `${d.source?.id || d.source}-${d.target?.id || d.target}`)
            sel.exit().remove()
            sel.enter().append('line')
                .attr('stroke', '#bbb')
                .attr('stroke-width', 1.5)
              .merge(sel)
                .attr('x1', d => d.source.x)
                .attr('y1', d => d.source.y)
                .attr('x2', d => d.target.x)
                .attr('y2', d => d.target.y)
        }
    }

    // Drag state
    let dragNode = null
    let dragOffsetX = 0
    let dragOffsetY = 0

    function onDragStart(e, node) {
        e.target.setPointerCapture(e.pointerId)
        startInteracting()
        dragNode = node
        dragOffsetX = e.clientX - node.x * zoom.value - panX.value
        dragOffsetY = e.clientY - node.y * zoom.value - panY.value

        // Heat the simulation so it reacts to the drag
        if (simulation.value) {
            simulation.value.alphaTarget(0.3).restart()
        }

        // Fix the node so the simulation does not move it
        if (node._simNode) {
            node._simNode.fx = node._simNode.x
            node._simNode.fy = node._simNode.y
        }
    }

    function onDragMove(e) {
        if (!dragNode) return
        const x = (e.clientX - dragOffsetX - panX.value) / zoom.value
        const y = (e.clientY - dragOffsetY - panY.value) / zoom.value

        if (dragNode._simNode) {
            dragNode._simNode.fx = x
            dragNode._simNode.fy = y
        }
    }

    function onDragEnd(e) {
        if (!dragNode) return

        // Unfix the node so the simulation can move it again
        if (dragNode._simNode) {
            dragNode._simNode.fx = null
            dragNode._simNode.fy = null
        }

        // Cool down the simulation
        if (simulation.value) {
            simulation.value.alphaTarget(0)
        }

        dragNode = null
        stopInteracting()
    }

    // Pan state
    let isPanning = false
    let panStartX = 0
    let panStartY = 0
    let panOriginX = 0
    let panOriginY = 0

    function onPanStart(e) {
        // Only pan if clicking the background (not a node)
        isPanning = true
        startInteracting()
        e.target.setPointerCapture(e.pointerId)
        panStartX = e.clientX
        panStartY = e.clientY
        panOriginX = panX.value
        panOriginY = panY.value
    }

    function onPanMove(e) {
        if (!isPanning) return
        panX.value = panOriginX + (e.clientX - panStartX)
        panY.value = panOriginY + (e.clientY - panStartY)
    }

    function onPanEnd(e) {
        isPanning = false
        stopInteracting()
    }

    function onZoom(e) {
        startInteracting()
        const delta = e.deltaY > 0 ? 0.9 : 1.1
        const newZoom = Math.min(3, Math.max(0.2, zoom.value * delta))

        // Zoom toward cursor position
        const rect = graphContainer.value.getBoundingClientRect()
        const mx = e.clientX - rect.left
        const my = e.clientY - rect.top

        // Adjust pan so the point under the cursor stays fixed
        panX.value = mx - (mx - panX.value) * (newZoom / zoom.value)
        panY.value = my - (my - panY.value) * (newZoom / zoom.value)

        zoom.value = newZoom
        stopInteracting()  // debounced — will only fire after wheel events stop
    }

    const anchors = ref([])
    let anchorCounter = 0

    function zoomIn() {
        zoom.value = Math.min(3, zoom.value * 1.2)
    }

    function zoomOut() {
        zoom.value = Math.max(0.2, zoom.value / 1.2)
    }

    function resetView() {
        panX.value = 0
        panY.value = 0
        zoom.value = 1
    }

    function saveAnchor() {
        anchorCounter++
        anchors.value.push({
            name: `View ${anchorCounter}`,
            x: panX.value,
            y: panY.value,
            zoom: zoom.value,
        })
    }

    function goToAnchor(anchor) {
        panX.value = anchor.x
        panY.value = anchor.y
        zoom.value = anchor.zoom
    }

    function removeAnchor(index) {
        anchors.value.splice(index, 1)
    }

    // Minimap computations
    function nodeTypeColor(type) {
        const colors = { input: '#3b82f6', decision: '#f59e0b', process: '#6366f1', action: '#10b981', output: '#f43f5e', storage: '#64748b' }
        return colors[type] || '#94a3b8'
    }

    const minimapBounds = computed(() => {
        const nodes = renderedNodes.value
        if (!nodes.length) return { minX: 0, minY: 0, maxX: 100, maxY: 100 }
        let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
        for (const n of nodes) {
            if (n.x < minX) minX = n.x
            if (n.y < minY) minY = n.y
            if (n.x > maxX) maxX = n.x
            if (n.y > maxY) maxY = n.y
        }
        const pad = 60
        return { minX: minX - pad, minY: minY - pad, maxX: maxX + pad, maxY: maxY + pad }
    })

    const minimapViewBox = computed(() => {
        const b = minimapBounds.value
        return `${b.minX} ${b.minY} ${b.maxX - b.minX} ${b.maxY - b.minY}`
    })

    const minimapEdges = computed(() => {
        if (!simulation.value) return []
        const links = simulation.value.force('link')?.links() || []
        return links.map(l => ({
            x1: l.source.x, y1: l.source.y,
            x2: l.target.x, y2: l.target.y,
        }))
    })

    const viewportRect = computed(() => {
        const w = containerWidth.value / zoom.value
        const h = containerHeight.value / zoom.value
        const x = -panX.value / zoom.value
        const y = -panY.value / zoom.value
        return { x, y, w, h }
    })

    onMounted(async () => {
        const d3Module = await import('d3')
        d3Ref.value = d3Module
        const container = graphContainer.value
        if (!container) return
        containerWidth.value = container.clientWidth
        containerHeight.value = container.clientHeight

        // Initialize sim control refs from props
        simCharge.value = props.chargeStrength
        simLinkDist.value = props.linkDistance

        const graphData = props.graphData || { nodes: [], edges: [] }
        console.log('[ot-d3-graph] graphData:', JSON.stringify({
            nodeCount: graphData.nodes?.length, edgeCount: graphData.edges?.length
        }))

        const rawNodes = Array.isArray(graphData.nodes) ? graphData.nodes : []
        const rawEdges = Array.isArray(graphData.edges) ? graphData.edges : []

        const validNodes = []
        const seenIds = new Set()
        for (const node of rawNodes) {
            const id = node?.id
            if (!id || typeof id !== 'string' || seenIds.has(id)) continue
            seenIds.add(id)
            validNodes.push(node)
        }

        const validEdges = rawEdges.filter(e => seenIds.has(e?.source) && seenIds.has(e?.target))
            .map(e => ({ source: e.source, target: e.target }))

        // Create simulation node data objects
        const simNodeData = validNodes.map(n => ({ id: n.id, label: n.label }))

        // Build reactive rendered nodes with async components
        const cx = props.centerX ?? container.clientWidth / 2
        const cy = props.centerY ?? container.clientHeight / 2

        const nodes = validNodes.map((node, i) => {
            const simNode = simNodeData[i]
            const asyncComp = markRaw(defineAsyncComponent(async () => {
                const result = await externalFetch({
                    [`get-node-${node.id}`]: {
                        type: 'text/x-vue',
                        'component-id': node.componentTypeKey,
                    }
                })
                const def = result[`get-node-${node.id}`]
                if (!def || def instanceof Error) throw def || new Error('no component')

                // Inject scoped styles (the overlay does this for its components,
                // but we load node components directly so we must handle it here)
                if (Array.isArray(def.styles) && def.styles.length > 0) {
                    const css = def.styles.map(s => s.css).filter(Boolean).join('\n\n')
                    if (css) {
                        const styleId = `ids-${node.componentTypeKey.replace(/[\s_.\\\/\-]+/ig, '-').toLowerCase()}`
                        let sheet = document.getElementById(styleId)
                        if (!sheet) {
                            sheet = document.createElement('style')
                            sheet.id = styleId
                            sheet.type = 'text/css'
                            document.head.appendChild(sheet)
                        }
                        sheet.innerHTML = css
                    }
                }

                return def.component
            }))
            return reactive({ id: node.id, label: node.label, nodeType: node.nodeType || 'default', component: asyncComp, x: cx, y: cy, _simNode: simNode })
        })

        renderedNodes.value = nodes
        console.log('[ot-d3-graph] renderedNodes set:', nodes.length)

        // Init simulation
        const sim = d3Module.forceSimulation(simNodeData)
            .force('charge', d3Module.forceManyBody().strength(props.chargeStrength))
            .force('link', d3Module.forceLink(validEdges).id(d => d.id).distance(props.linkDistance))
            .force('center', d3Module.forceCenter(cx, cy))
            .on('tick', onTick)
        simulation.value = sim

        if (validNodes.length === 0) sim.stop()

        resizeObserver.value = new ResizeObserver(() => {
            if (!simulation.value || !d3Ref.value || !graphContainer.value) return
            containerWidth.value = graphContainer.value.clientWidth
            containerHeight.value = graphContainer.value.clientHeight
            simulation.value.force('center', d3Ref.value.forceCenter(
                props.centerX ?? graphContainer.value.clientWidth / 2,
                props.centerY ?? graphContainer.value.clientHeight / 2
            ))
            simulation.value.alpha(0.3).restart()
        })
        resizeObserver.value.observe(container)
    })

    onBeforeUnmount(() => {
        if (simulation.value) simulation.value.stop()
        destroyed.value = true
        if (resizeObserver.value) { resizeObserver.value.disconnect(); resizeObserver.value = null }
        simulation.value = null
        d3Ref.value = null
    })
</script>

<style scoped>
.ot-d3-graph-controls {
    position: absolute;
    top: 8px;
    right: 8px;
    display: flex;
    flex-direction: column;
    gap: 6px;
    z-index: 10;
    font-family: system-ui, -apple-system, sans-serif;
    font-size: 12px;
}
.ot-d3-graph-controls__zoom {
    display: flex;
    align-items: center;
    gap: 2px;
    background: #fff;
    border: 1px solid #d0d0d0;
    border-radius: 4px;
    padding: 2px;
    box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.ot-d3-graph-controls__btn {
    width: 28px;
    height: 28px;
    display: flex;
    align-items: center;
    justify-content: center;
    border: none;
    background: transparent;
    color: #555;
    font-size: 14px;
    cursor: pointer;
    border-radius: 3px;
    transition: background 0.1s;
}
.ot-d3-graph-controls__btn:hover {
    background: #eee;
}
.ot-d3-graph-controls__zoom-level {
    font-size: 10px;
    color: #888;
    min-width: 36px;
    text-align: center;
    pointer-events: none;
}
.ot-d3-graph-controls__anchors {
    display: flex;
    flex-direction: column;
    gap: 2px;
}
.ot-d3-graph-controls__btn--save {
    width: auto;
    padding: 4px 8px;
    font-size: 10px;
    background: #fff;
    border: 1px solid #d0d0d0;
    border-radius: 4px;
    box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.ot-d3-graph-controls__anchor {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 4px;
    padding: 3px 6px;
    background: #fff;
    border: 1px solid #d0d0d0;
    border-radius: 4px;
    cursor: pointer;
    box-shadow: 0 1px 3px rgba(0,0,0,0.08);
    transition: border-color 0.15s;
}
.ot-d3-graph-controls__anchor:hover {
    border-color: #6366f1;
}
.ot-d3-graph-controls__anchor-name {
    font-size: 10px;
    color: #555;
    pointer-events: none;
}
.ot-d3-graph-controls__anchor-del {
    width: 16px;
    height: 16px;
    display: flex;
    align-items: center;
    justify-content: center;
    border: none;
    background: transparent;
    color: #aaa;
    font-size: 12px;
    cursor: pointer;
    border-radius: 2px;
    padding: 0;
}
.ot-d3-graph-controls__anchor-del:hover {
    color: #ef4444;
    background: #fef2f2;
}
.ot-d3-graph-controls__section {
    display: flex;
    flex-direction: column;
    gap: 2px;
}
.ot-d3-graph-controls__btn--toggle {
    width: auto;
    padding: 4px 8px;
    font-size: 10px;
    background: #fff;
    border: 1px solid #d0d0d0;
    border-radius: 4px;
    box-shadow: 0 1px 4px rgba(0,0,0,0.1);
    text-align: left;
}
.ot-d3-graph-controls__panel {
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: 6px;
    background: #fff;
    border: 1px solid #d0d0d0;
    border-radius: 4px;
    box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.ot-d3-graph-controls__label {
    display: flex;
    align-items: center;
    gap: 4px;
    font-size: 10px;
    color: #555;
    white-space: nowrap;
}
.ot-d3-graph-controls__range {
    width: 80px;
    height: 14px;
    cursor: pointer;
}
.ot-d3-graph-controls__val {
    font-size: 9px;
    color: #888;
    min-width: 32px;
    text-align: right;
}
.ot-d3-graph-controls__select {
    font-size: 10px;
    padding: 1px 4px;
    border: 1px solid #d0d0d0;
    border-radius: 3px;
    background: #fff;
    color: #555;
    cursor: pointer;
}
.ot-d3-graph-controls__color {
    width: 20px;
    height: 20px;
    padding: 0;
    border: 1px solid #d0d0d0;
    border-radius: 3px;
    cursor: pointer;
    background: none;
}
.ot-d3-graph-minimap {
    position: absolute;
    bottom: 8px;
    left: 8px;
    width: 160px;
    height: 100px;
    background: #fff;
    border: 1px solid #d0d0d0;
    border-radius: 4px;
    box-shadow: 0 1px 4px rgba(0,0,0,0.1);
    z-index: 10;
    overflow: hidden;
}
</style>