// Propagation chain layer — verdict-first "Should I book this flight?" flow
// States: origin → destination → verdict → explanation
class PropagationLayer extends BaseLayer {
    constructor(viz) {
        super(viz);
        this.chainData = null;
        this.state = 'origin';
        this.origin = null;
        this.dest = null;
        this.routeKey = null;
        this.routeData = null;
        this.selectedTime = null;
        this.animFrameId = null;
        this.animStart = null;
        this.edgeClickEnabled = false;
    }

    async activate() {
        super.activate();
        if (!this.chainData) {
            try {
                const resp = await fetch('/data/propagation_chains.json');
                this.chainData = await resp.json();
            } catch (err) {
                console.error('Failed to load chain data:', err);
            }
        }
        this.state = 'origin';
        this.origin = null;
        this.dest = null;
        this.routeKey = null;
        this.routeData = null;
        this.selectedTime = null;
        this._renderPanel();
    }

    deactivate() {
        this._stopAnim();
        this.state = 'origin';
        this.origin = null;
        this.dest = null;
        this.routeKey = null;
        this.routeData = null;
        this.selectedTime = null;
        this.viz.selectedAirport = null;
        InfoPanel.clear();
        super.deactivate();
    }

    // --- layer hooks ---

    onAirportClick(d) {
        if (this.state === 'origin') {
            this._setOrigin(d.id);
            return true;
        }
        if (this.state === 'destination') {
            // clicking origin again = start over
            if (d.id === this.origin) { this._goBackToOrigin(); return true; }
            this._setDest(d.id);
            return true;
        }
        // verdict/explanation — clicking either endpoint → back to dest selection; clicking another swaps dest
        if (d.id === this.origin || d.id === this.dest) {
            this._goBackToDestination();
            return true;
        }
        const key = this.origin + '-' + d.id;
        if (this.chainData?.[key]) { this._setDest(d.id); }
        return true;
    }

    onBackgroundClick() {
        if (this.state === 'origin') return false;
        this._goBackToOrigin();
        return true;
    }

    getNodeColor(d) {
        if (this.state === 'origin') return null;

        if (this.state === 'destination') {
            if (d.id === this.origin) return null;
            if (this._isReachable(d.id)) return null;
            return '#d1d5db';
        }

        // verdict / explanation — highlight route endpoints + chain airports
        if (d.id === this.origin || d.id === this.dest) return null;

        if (this.state === 'explanation' && this.routeData) {
            const tw = this._activeTimeWindow();
            const twData = this.routeData.by_time?.[tw];
            if (twData) {
                const ups = (twData.upstream || []).map(u => u.origin);
                const dns = (twData.downstream || []).map(x => x.dest);
                if (ups.includes(d.id) || dns.includes(d.id)) return null;
            }
        }
        return '#d1d5db';
    }

    getEdgeColor() {
        if (this.state === 'origin') return 'rgba(0,0,0,0)';
        if (this.state === 'destination') return null;
        return '#d1d5db';
    }

    isEdgeInteractive() { return false; }

    renderOverlay(ctx, t) {
        if (this.state === 'verdict' || this.state === 'explanation') {
            this._drawSelectedRoute(ctx, t);
        }
        if (this.state === 'explanation') {
            this._drawChainLines(ctx, t);
        }
    }

    stopAnimation() { this._stopAnim(); }

    getLegendHTML() {
        if (this.state === 'explanation') {
            return `
                <div class="layer-legend-title">Delay Chain</div>
                <div class="layer-legend-row">
                    <span class="legend-dot" style="background:#f1c40f"></span>
                    Selected route
                </div>
                <div class="layer-legend-row">
                    <span class="legend-dot" style="background:#22c55e"></span>
                    Low risk feeder
                </div>
                <div class="layer-legend-row">
                    <span class="legend-dot" style="background:#eab308"></span>
                    Moderate risk feeder
                </div>
                <div class="layer-legend-row">
                    <span class="legend-dot" style="background:#ef4444"></span>
                    High risk feeder
                </div>
                <div class="layer-legend-row">
                    <span class="legend-dot" style="background:#6b7280;border-radius:0"></span>
                    Downstream impact
                </div>
            `;
        }
        return `
            <div class="layer-legend-title">Flight Booking Advisor</div>
            <div class="layer-legend-hint">
                Search for a route to see the best time to fly.
            </div>
        `;
    }

    // --- state transitions ---

    _setOrigin(code) {
        this.origin = code;
        this.dest = null;
        this.routeKey = null;
        this.routeData = null;
        this.state = 'destination';
        this.viz.selectedAirport = code;
        this._stopAnim();
        this._renderPanel();
        this.viz.render();
    }

    _setDest(code) {
        this.dest = code;
        this.routeKey = this.origin + '-' + code;
        this.routeData = this.chainData?.[this.routeKey] || null;
        this.state = 'verdict';
        this.selectedTime = null;
        this.viz.selectedAirport = null;
        this._stopAnim();
        this._renderPanel();
        this.viz.render();
    }

    _goToExplanation(tw) {
        this.state = 'explanation';
        this.selectedTime = tw || this.routeData?.verdict?.worst_time || 'morning';
        this._startAnim();
        this._renderPanel();
        this.viz.render();
        this._refreshLegend();
    }

    _goBackToVerdict() {
        this.state = 'verdict';
        this._stopAnim();
        this._renderPanel();
        this.viz.render();
        this._refreshLegend();
    }

    _goBackToDestination() {
        this.dest = null;
        this.routeKey = null;
        this.routeData = null;
        this.state = 'destination';
        this.viz.selectedAirport = this.origin;
        this._stopAnim();
        this._renderPanel();
        this.viz.render();
    }

    _goBackToOrigin() {
        this.origin = null;
        this.dest = null;
        this.routeKey = null;
        this.routeData = null;
        this.state = 'origin';
        this.viz.selectedAirport = null;
        this._stopAnim();
        this._renderPanel();
        this.viz.render();
    }

    _refreshLegend() {
        const el = document.getElementById('layer-legend');
        if (el) el.innerHTML = this.getLegendHTML();
    }

    // --- helpers ---

    _activeTimeWindow() {
        return this.selectedTime
            || this.routeData?.verdict?.worst_time
            || 'morning';
    }

    _carrierName(code) {
        const names = {
            AA: 'American Airlines', DL: 'Delta Air Lines',
            UA: 'United Airlines',   WN: 'Southwest Airlines',
            B6: 'JetBlue Airways',   AS: 'Alaska Airlines',
            NK: 'Spirit Airlines',   F9: 'Frontier Airlines',
            G4: 'Allegiant Air',     SY: 'Sun Country Airlines',
            HA: 'Hawaiian Airlines', OO: 'SkyWest Airlines',
            MQ: 'Envoy Air',         YX: 'Republic Airways',
            '9E': 'Endeavor Air',    OH: 'PSA Airlines',
            YV: 'Mesa Airlines',     QX: 'Horizon Air',
            PT: 'Piedmont Airlines', ZW: 'Air Wisconsin',
        };
        return names[code] || code;
    }

    _expandCarrierCodes(str) {
        // replace bare carrier codes with full names in recommendation strings
        return str.replace(/\b([A-Z0-9]{2})\b/g, (match) => this._carrierName(match));
    }

    _isReachable(code) {
        if (!this.chainData || !this.origin) return false;
        return !!this.chainData[this.origin + '-' + code];
    }

    _getReachableDests() {
        if (!this.chainData || !this.origin) return [];
        const prefix = this.origin + '-';
        const dests = [];
        for (const key of Object.keys(this.chainData)) {
            if (!key.startsWith(prefix)) continue;
            const dest = key.slice(prefix.length);
            const d = this.chainData[key];
            dests.push({
                code: dest,
                flights: d.overall?.flights || 0,
                pctDelayed: d.overall?.pct_delayed || 0,
            });
        }
        dests.sort((a, b) => b.flights - a.flights);
        return dests;
    }

    _airportName(code) {
        if (!this.viz.data?.nodes) return code;
        const n = this.viz.data.nodes.find(x => x.id === code);
        return n?.name || code;
    }

    // --- panel rendering ---

    _renderPanel() {
        const panel = InfoPanel.panel;
        if (!panel) return;

        switch (this.state) {
            case 'origin':      this._renderOriginPanel(panel); break;
            case 'destination':  this._renderDestPanel(panel); break;
            case 'verdict':      this._renderVerdictPanel(panel); break;
            case 'explanation':  this._renderExplanationPanel(panel); break;
        }
    }

    _renderOriginPanel(panel) {
        panel.innerHTML = `
            <h2>Where are you flying from?</h2>
            <p>Search or click an airport on the map</p>
            <div class="chain-search-wrap">
                <input type="text" id="chain-origin-input" class="chain-search"
                       placeholder="Airport code or name..." autocomplete="off">
                <div id="chain-origin-ac" class="chain-autocomplete"></div>
            </div>
        `;
        this._setupSearch('chain-origin-input', 'chain-origin-ac', (code) => {
            this._setOrigin(code);
        });
    }

    _renderDestPanel(panel) {
        const dests = this._getReachableDests().slice(0, 10);

        panel.innerHTML = `
            <div class="chain-nav">
                <button class="chain-nav-btn" id="chain-back-origin">&larr; Change origin</button>
            </div>
            <h2>From ${this.origin}</h2>
            <p>${this._airportName(this.origin)}</p>
            <h3 style="margin-top:1rem">Where to?</h3>
            <div class="chain-search-wrap">
                <input type="text" id="chain-dest-input" class="chain-search"
                       placeholder="Destination..." autocomplete="off">
                <div id="chain-dest-ac" class="chain-autocomplete"></div>
            </div>
            <h3 style="margin-top:1.25rem">Top Destinations</h3>
            <ul class="chain-dest-list">
                ${dests.map(d => `
                    <li class="chain-dest-item" data-code="${d.code}">
                        <span class="chain-dest-code">${d.code}</span>
                        <span class="chain-dest-name">${this._airportName(d.code)}</span>
                        <span class="chain-dest-flights">${d.flights.toLocaleString()}</span>
                    </li>
                `).join('')}
            </ul>
        `;

        document.getElementById('chain-back-origin')
            ?.addEventListener('click', () => this._goBackToOrigin());

        panel.querySelectorAll('.chain-dest-item').forEach(el => {
            el.addEventListener('click', () => this._setDest(el.dataset.code));
        });

        this._setupSearch('chain-dest-input', 'chain-dest-ac', (code) => {
            this._setDest(code);
        }, true);
    }

    _renderVerdictPanel(panel) {
        if (!this.routeData) {
            panel.innerHTML = `
                <div class="chain-nav">
                    <button class="chain-nav-btn" id="chain-back-dest">&larr; Change route</button>
                </div>
                <h2>${this.origin} &rarr; ${this.dest}</h2>
                <p style="color:var(--text-muted);padding-top:1rem;">
                    Not enough data to generate a booking verdict for this route.
                </p>
            `;
            document.getElementById('chain-back-dest')
                ?.addEventListener('click', () => this._goBackToDestination());
            return;
        }

        const data = this.routeData;
        const verdict = data.verdict || {};
        const overall = data.overall || {};
        const byTime = data.by_time || {};
        const byCarrier = data.by_carrier || {};

        const timeLabels = { morning: 'Morning', afternoon: 'Afternoon', evening: 'Evening' };
        const riskIcons = { low: '\u2713', moderate: '\u26A0', high: '\u2717' };
        const riskColors = { low: '#22c55e', moderate: '#eab308', high: '#ef4444' };

        let verdictRows = '';
        for (const tw of ['morning', 'afternoon', 'evening']) {
            const td = byTime[tw];
            if (!td) continue;
            const risk = td.risk_level || 'moderate';
            const isBest = tw === verdict.best_time;
            const isWorst = tw === verdict.worst_time;
            const tag = isBest ? ' <span class="chain-badge chain-badge-best">BEST</span>'
                       : isWorst ? ' <span class="chain-badge chain-badge-worst">WORST</span>' : '';

            verdictRows += `
                <div class="chain-verdict-row" data-tw="${tw}">
                    <span class="chain-traffic-light" style="color:${riskColors[risk]}">${riskIcons[risk]}</span>
                    <span class="chain-verdict-label">${timeLabels[tw]}${tag}</span>
                    <span class="chain-verdict-pct">${td.pct_delayed}% delayed</span>
                    <span class="chain-verdict-avg">${td.avg_delay} min avg</span>
                </div>
            `;
        }

        // Carrier chips
        let carrierHTML = '';
        const carriers = Object.entries(byCarrier);
        if (carriers.length) {
            const sorted = carriers.sort((a, b) => (a[1].pct_delayed || 1) - (b[1].pct_delayed || 1));
            carrierHTML = `
                <h3>By Carrier</h3>
                <div class="chain-carrier-grid">
                    ${sorted.map(([code, c]) => {
                        const pct = c.pct_delayed != null ? (c.pct_delayed * 100).toFixed(0) : '?';
                        const cls = code === verdict.best_carrier ? 'chain-carrier-best'
                                  : code === verdict.worst_carrier ? 'chain-carrier-worst' : '';
                        return `<div class="chain-carrier-item ${cls}">
                            <span class="chain-carrier-code">${this._carrierName(code)}</span>
                            <span class="chain-carrier-pct">${pct}%</span>
                        </div>`;
                    }).join('')}
                </div>
            `;
        }

        panel.innerHTML = `
            <div class="chain-nav">
                <button class="chain-nav-btn" id="chain-back-dest">&larr; Change route</button>
            </div>
            <h2>${this.origin} &rarr; ${this.dest}</h2>
            <p>${overall.flights?.toLocaleString() || 0} flights analyzed</p>

            <div class="chain-verdict-section">${verdictRows}</div>

            ${verdict.recommendation
                ? `<div class="chain-recommendation">${this._expandCarrierCodes(verdict.recommendation)}</div>` : ''}

            ${carrierHTML}

            <div class="data-actions" style="margin-top:1.5rem">
                <button class="chain-explain-btn" id="chain-explain">Show me why &rarr;</button>
            </div>
        `;

        document.getElementById('chain-back-dest')
            ?.addEventListener('click', () => this._goBackToDestination());
        document.getElementById('chain-explain')
            ?.addEventListener('click', () => this._goToExplanation());

        // Click verdict rows to jump straight to that time window
        panel.querySelectorAll('.chain-verdict-row').forEach(el => {
            el.style.cursor = 'pointer';
            el.addEventListener('click', () => this._goToExplanation(el.dataset.tw));
        });
    }

    _renderExplanationPanel(panel) {
        if (!this.routeData) return;

        const byTime = this.routeData.by_time || {};
        const tw = this._activeTimeWindow();
        const twData = byTime[tw];
        const timeLabels = { morning: 'Morning', afternoon: 'Afternoon', evening: 'Evening' };

        let tabs = '';
        for (const t of ['morning', 'afternoon', 'evening']) {
            if (!byTime[t]) continue;
            tabs += `<button class="chain-time-tab${t === tw ? ' active' : ''}" data-tw="${t}">${timeLabels[t]}</button>`;
        }

        // How much of total delay is explained by upstream propagation
        // avg_delay = unconditional avg across all flights on this route/window
        // upstream weighted avg = across all linked pairs (including negatives)
        let propagationHTML = '';
        const upstream = twData?.upstream || [];
        const totalDelay = twData?.avg_delay || 0;
        if (upstream.length && totalDelay > 0) {
            const weightedSum = upstream.reduce((s, u) => s + u.avg_delay_inherited * u.flight_count, 0);
            const totalLinks = upstream.reduce((s, u) => s + u.flight_count, 0);
            const avgUpstream = totalLinks > 0 ? weightedSum / totalLinks : 0;
            const rawPct = Math.round((avgUpstream / totalDelay) * 100);
            if (rawPct > 100) console.warn(`upstream share ${rawPct}% for ${this.routeKey} ${tw} — cohort mismatch?`);
            const pct = Math.max(0, Math.min(rawPct, 100));

            let msg;
            if (pct > 60) {
                msg = `~${pct}% of avg delay is inherited from upstream &mdash; most delays propagate in from feeder routes`;
            } else if (pct > 30) {
                msg = `~${pct}% of avg delay traces to upstream &mdash; mixed causes (propagation + local factors)`;
            } else {
                msg = `Only ~${pct}% traces to upstream &mdash; delays on this route are mostly local. Check other layers for more context.`;
            }
            propagationHTML = `<p class="chain-propagation-note">${msg}</p>`;
        }

        // Upstream feeders
        let upHTML = '';
        if (upstream.length) {
            const riskColors = { low: '#22c55e', moderate: '#eab308', high: '#ef4444' };
            upHTML = `
                <h3>Upstream Feeders</h3>
                <p class="chain-explain-hint">Aircraft arriving from these routes before your flight</p>
                <ul class="chain-list">
                    ${upstream.map(u => `
                        <li>
                            <span class="chain-dot" style="background:${riskColors[u.risk] || '#999'}"></span>
                            <span class="chain-route">${u.origin} &rarr; ${u.dest}</span>
                            <span class="chain-count">${u.flight_count} linked</span>
                            <span class="chain-corr">${u.avg_delay_inherited > 0 ? '+' : ''}${u.avg_delay_inherited} min</span>
                        </li>
                    `).join('')}
                </ul>
            `;
        }

        // Downstream impact
        let downHTML = '';
        if (twData?.downstream?.length) {
            downHTML = `
                <h3>Downstream Impact</h3>
                <p class="chain-explain-hint">Flights affected if your aircraft runs late</p>
                <ul class="chain-list">
                    ${twData.downstream.map(d => {
                        const pax = d.est_pax_impact ? d.est_pax_impact.toLocaleString() + ' pax' : '';
                        return `
                            <li>
                                <span class="chain-dot" style="background:#f97316"></span>
                                <span class="chain-route">${d.origin} &rarr; ${d.dest}</span>
                                <span class="chain-count">${pax}</span>
                                <span class="chain-corr">${d.avg_delay_propagated > 0 ? '+' : ''}${d.avg_delay_propagated} min</span>
                            </li>
                        `;
                    }).join('')}
                </ul>
            `;
        }

        panel.innerHTML = `
            <div class="chain-nav">
                <button class="chain-nav-btn" id="chain-back-verdict">&larr; Back to verdict</button>
            </div>
            <h2>${this.origin} &rarr; ${this.dest}</h2>
            <p>${timeLabels[tw]} flights &mdash; ${twData?.reason || ''}</p>

            <div class="chain-time-tabs">${tabs}</div>

            <div class="stat-grid">
                <div class="stat">
                    <span class="stat-value">${twData?.pct_delayed ?? '?'}%</span>
                    <span class="stat-label">Delayed</span>
                </div>
                <div class="stat">
                    <span class="stat-value">${twData?.avg_delay ?? '?'} min</span>
                    <span class="stat-label">Avg Delay</span>
                </div>
                <div class="stat">
                    <span class="stat-value">${twData?.flights?.toLocaleString() || '?'}</span>
                    <span class="stat-label">Flights</span>
                </div>
                <div class="stat">
                    <span class="stat-value">${(twData?.upstream?.length || 0) + (twData?.downstream?.length || 0)}</span>
                    <span class="stat-label">Chain Links</span>
                </div>
            </div>

            ${propagationHTML}
            ${upHTML}
            ${downHTML}
        `;

        document.getElementById('chain-back-verdict')
            ?.addEventListener('click', () => this._goBackToVerdict());

        panel.querySelectorAll('.chain-time-tab').forEach(btn => {
            btn.addEventListener('click', () => {
                this.selectedTime = btn.dataset.tw;
                this._renderPanel();
                this.viz.render();
            });
        });
    }

    // --- search autocomplete ---

    _setupSearch(inputId, dropdownId, onSelect, destOnly = false) {
        const input = document.getElementById(inputId);
        const dropdown = document.getElementById(dropdownId);
        if (!input || !dropdown) return;

        let activeIdx = -1;

        const getMatches = () => {
            const q = input.value.trim().toUpperCase();
            if (q.length < 1) return [];

            let pool;
            if (destOnly) {
                pool = this._getReachableDests().map(d => {
                    const node = this.viz.data?.nodes?.find(n => n.id === d.code);
                    return { id: d.code, name: node?.name || '', flights: d.flights };
                });
            } else {
                pool = (this.viz.data?.nodes || []).map(n => ({
                    id: n.id, name: n.name || '', flights: n.total_flights || 0,
                }));
            }

            return pool.filter(n =>
                n.id.includes(q) || (n.name && n.name.toUpperCase().includes(q))
            ).sort((a, b) => {
                if (a.id === q) return -1;
                if (b.id === q) return 1;
                return b.flights - a.flights;
            }).slice(0, 8);
        };

        const render = () => {
            const matches = getMatches();
            const q = input.value.trim();

            if (q.length < 1) {
                dropdown.innerHTML = '';
                dropdown.style.display = 'none';
                return;
            }

            if (matches.length === 0) {
                dropdown.innerHTML = '<div class="chain-ac-empty">No matches</div>';
                dropdown.style.display = 'block';
                return;
            }

            dropdown.innerHTML = matches.map((m, i) => `
                <div class="chain-ac-item${i === activeIdx ? ' active' : ''}" data-code="${m.id}">
                    <strong>${m.id}</strong>
                    <span class="chain-ac-name">${m.name}</span>
                </div>
            `).join('');
            dropdown.style.display = 'block';

            dropdown.querySelectorAll('.chain-ac-item').forEach(el => {
                el.addEventListener('mousedown', (e) => {
                    e.preventDefault();
                    onSelect(el.dataset.code);
                });
            });
        };

        input.addEventListener('input', () => { activeIdx = -1; render(); });
        input.addEventListener('focus', render);
        input.addEventListener('blur', () => {
            setTimeout(() => { dropdown.style.display = 'none'; }, 150);
        });

        input.addEventListener('keydown', (e) => {
            const items = dropdown.querySelectorAll('.chain-ac-item');
            if (e.key === 'ArrowDown') {
                e.preventDefault();
                activeIdx = Math.min(activeIdx + 1, items.length - 1);
                render();
            } else if (e.key === 'ArrowUp') {
                e.preventDefault();
                activeIdx = Math.max(activeIdx - 1, -1);
                render();
            } else if (e.key === 'Enter') {
                e.preventDefault();
                if (activeIdx >= 0 && items[activeIdx]) {
                    onSelect(items[activeIdx].dataset.code);
                } else if (items.length === 1) {
                    onSelect(items[0].dataset.code);
                }
            } else if (e.key === 'Escape') {
                dropdown.style.display = 'none';
                input.blur();
            }
        });

        setTimeout(() => input.focus(), 50);
    }

    // --- canvas overlay ---

    _drawSelectedRoute(ctx, t) {
        if (!this.origin || !this.dest) return;
        const src = this.viz.projectedNodes.get(this.origin);
        const dst = this.viz.projectedNodes.get(this.dest);
        if (!src || !dst) return;

        ctx.beginPath();
        ctx.moveTo(src.x, src.y);
        ctx.lineTo(dst.x, dst.y);
        ctx.setLineDash([]);
        ctx.strokeStyle = '#f1c40f';
        ctx.lineWidth = 4 / t.k;
        ctx.globalAlpha = 0.9;
        ctx.stroke();
    }

    _drawChainLines(ctx, t) {
        if (!this.routeData) return;
        const tw = this._activeTimeWindow();
        const twData = this.routeData.by_time?.[tw];
        if (!twData) return;

        const progress = this.animStart
            ? ((performance.now() - this.animStart) % 3000) / 3000
            : 0;

        const riskColors = { low: '#22c55e', moderate: '#eab308', high: '#ef4444' };

        // Upstream feeders — dotted, color-coded by risk
        for (const u of (twData.upstream || [])) {
            const src = this.viz.projectedNodes.get(u.origin);
            const dst = this.viz.projectedNodes.get(u.dest);
            if (!src || !dst) continue;

            const color = riskColors[u.risk] || '#999';
            const w = Math.min(2 + u.flight_count / 80, 5) / t.k;

            ctx.beginPath();
            ctx.moveTo(src.x, src.y);
            ctx.lineTo(dst.x, dst.y);
            ctx.setLineDash([5 / t.k, 3 / t.k]);
            ctx.strokeStyle = color;
            ctx.lineWidth = w;
            ctx.globalAlpha = 0.7;
            ctx.stroke();

            // animated dot flowing along the edge
            const dx = src.x + (dst.x - src.x) * progress;
            const dy = src.y + (dst.y - src.y) * progress;
            ctx.beginPath();
            ctx.arc(dx, dy, 3 / t.k, 0, Math.PI * 2);
            ctx.fillStyle = color;
            ctx.globalAlpha = 1;
            ctx.fill();
        }

        // Downstream — dashed gray, kept subtle so upstream stays focal
        for (const d of (twData.downstream || [])) {
            const src = this.viz.projectedNodes.get(d.origin);
            const dst = this.viz.projectedNodes.get(d.dest);
            if (!src || !dst) continue;

            const w = Math.min(2 + d.flight_count / 100, 5) / t.k;

            ctx.beginPath();
            ctx.moveTo(src.x, src.y);
            ctx.lineTo(dst.x, dst.y);
            ctx.setLineDash([8 / t.k, 3 / t.k]);
            ctx.strokeStyle = '#6b7280';
            ctx.lineWidth = w;
            ctx.globalAlpha = 0.5;
            ctx.stroke();

            const dx = src.x + (dst.x - src.x) * progress;
            const dy = src.y + (dst.y - src.y) * progress;
            ctx.beginPath();
            ctx.arc(dx, dy, 3 / t.k, 0, Math.PI * 2);
            ctx.fillStyle = '#6b7280';
            ctx.globalAlpha = 0.7;
            ctx.fill();
        }

        ctx.setLineDash([]);
        ctx.globalAlpha = 1;
    }

    // --- animation loop ---

    _startAnim() {
        this._stopAnim();
        this.animStart = performance.now();
        const tick = () => {
            this.viz._renderEdges();
            this.animFrameId = requestAnimationFrame(tick);
        };
        this.animFrameId = requestAnimationFrame(tick);
    }

    _stopAnim() {
        if (this.animFrameId) {
            cancelAnimationFrame(this.animFrameId);
            this.animFrameId = null;
        }
        this.animStart = null;
    }
}
