/* ── KaTeX CSS cache (module-level, shared across all Whiteboard instances) ── */
let _katexCss = null;         // null = not fetched yet; '' = failed/loading; string = ready
const _katexCssCbs = [];      // pending render callbacks waiting for CSS

function _loadKatexCss(cb) {
  if (_katexCss !== null) { cb(_katexCss); return; }
  _katexCssCbs.push(cb);
  if (_katexCssCbs.length > 1) return; // fetch already in flight
  fetch('https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css')
    .then(r => r.text())
    .then(css => {
      // Make font paths absolute so they resolve from inside an SVG Blob URL
      _katexCss = css.replace(
        /url\(fonts\//g,
        'url(https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/fonts/'
      );
    })
    .catch(() => { _katexCss = ''; })
    .finally(() => {
      const waiters = _katexCssCbs.splice(0);
      waiters.forEach(w => w(_katexCss));
    });
}

/**
 * Whiteboard — interactive drawing canvas
 * Virtual space: 1920×1080. All coordinates stored in virtual pixels.
 *
 * Tools:   'pencil' | 'eraser' | 'highlighter' | 'laser' | 'select' | 'text' |
 *          'sticky' | 'formula' | 'table' | 'connector' |
 *          'rect' | 'ellipse' | 'line' | 'arrow' |
 *          'triangle' | 'diamond' | 'hexagon' | 'star' | 'roundedrect' | 'callout'
 *
 * Stroke.tool (DB): 'pencil' | 'eraser' | 'highlighter' | 'shape' | 'text' | 'image' |
 *                   'sticky' | 'formula' | 'table' | 'connector'
 *
 * data shapes:
 *   pencil/eraser/highlighter : {points:[[x,y],...], color, width, lineStyle, opacity}
 *   shape         : {shape, x1,y1,x2,y2, color, width, fill, lineStyle, opacity}
 *   connector     : {x1,y1,x2,y2, color, width, arrowStart, arrowEnd, lineStyle, opacity}
 *   text          : {text, x, y, fontSize, color}
 *   image         : {src, x, y, w, h}
 *   sticky        : {x, y, w, h, text, bgColor, textColor, fontSize}
 *   formula       : {x, y, w, h, latex, color}
 *   table         : {x, y, w, h, rows, cols, cells, borderColor, bgColor, textColor, fontSize}
 */
class Whiteboard {
  static VW = 1920;
  static VH = 1080;

  constructor(canvas, opts = {}) {
    this._canvas  = canvas;
    this._ctx     = canvas.getContext('2d');

    this._readOnly         = opts.readOnly         || false;
    this._onStrokeDone     = opts.onStrokeDone     || null;
    this._onStrokeUndo     = opts.onStrokeUndo     || null;
    this._onStrokeProgress = opts.onStrokeProgress || null;
    this._onStrokeUpdated  = opts.onStrokeUpdated  || null;
    this._onCursorMove     = opts.onCursorMove     || null;
    this._cursorThrottle   = 0; // timestamp of last cursor broadcast

    this._strokes   = [];
    this._undoStack = [];
    this._redoStack = [];

    this._drawing    = false;
    this._curPts     = [];
    this._shapeStart = null;
    this._shapeEnd   = null;

    this._tool      = 'pencil';
    this._color     = '#ffffff';
    this._width     = 4;
    this._fill      = false;
    this._lineStyle = 'solid';
    this._opacity   = 1.0;
    // text tool settings
    this._textFontSize   = 22;
    this._textFontFamily = 'Manrope';
    this._textBold       = false;
    this._textItalic     = false;
    this._template  = opts.template  || 'blank';
    this._pageNum   = opts.pageNum   || 1;
    this._stylusMultiplier = opts.stylusMultiplier ?? 0; // 0=disabled
    this._effectiveWidth   = null;

    this._selectedIds = new Set();   // multi-select
    this._dragState   = null;
    this._clipboard   = null;
    this._lassoRect   = null;        // {x1,y1,x2,y2} rubber-band selection
    this._snapGuides  = [];          // [{axis:'x'|'y', pos:number}]
    this._snapEnabled = true;        // snap guides on/off
    this._staticDirty = true;        // two-layer: re-render static when true
    this._overlays    = [];          // ruler/protractor overlays (not saved to DB)
    this._overlayDrag = null;        // {idx, type:'move'|'rotate'|'resize', ...}
    this._selectedOverlayIdx = -1;
    this._onOverlayChange = opts.onOverlayChange || null;
    this._showMeasurements = false;  // auto-measurements toggle

    // Zoom / pan
    this._zoom        = 1;
    this._panVX       = 0;   // virtual-space X of top-left corner
    this._panVY       = 0;
    this._spaceDown   = false;
    this._panStartCss = null; // [cssX, cssY] at pan start
    this._panStartPan = null; // [panVX, panVY] at pan start
    this._onZoomChange = opts.onZoomChange || null;

    this._lastClickTime = 0;
    this._lastClickVx   = 0;
    this._lastClickVy   = 0;

    this._localIdCounter = -1;
    this._liveId         = null;
    this._progressTimer  = null;
    this._liveStrokes    = new Map();

    this._fitPending           = false;
    this._bgNoise              = null;   // cached noise pattern for chalkboard texture
    this._textInput            = null;
    this._textInputDocHandler  = null;   // document pointerdown handler for outside-click
    this._objectInput          = null;
    this._onObjectCreated        = opts.onObjectCreated || null;
    this._onToolSwitch           = opts.onToolSwitch    || null;
    this._onFormulaInsert        = opts.onFormulaInsert || null;
    this._onCoordEdit            = opts.onCoordEdit        || null;
    this._onNumberLineEdit       = opts.onNumberLineEdit   || null;
    this._onCompassEdit          = opts.onCompassEdit      || null;
    this._editingFormulaStroke   = null;
    this._laserPos               = null;
    this._laserTrail             = [];   // fade trail points (last 25)
    this._pointerVx              = 0;    // last pointer virtual X (for eraser cursor)
    this._pointerVy              = 0;
    this._textAlign              = 'left'; // text alignment state
    this._stickyColor            = null; // forced sticky bg color (null = random)
    this._tableRows              = 3;    // table picker rows
    this._tableCols              = 4;    // table picker cols

    // Board theme
    this._boardTheme   = opts.boardTheme || 'chalkboard';
    this._bgNoiseCache = new Map(); // canvas element per theme
    this._annotateMode = false; // true = transparent bg (draw over simulation)

    // Compass state machine
    this._compassState        = 'idle'; // 'idle'|'setting-radius'|'waiting-arc'|'drawing-arc'
    this._compassCenter       = null;
    this._compassRadius       = 0;
    this._compassArcStart     = 0;
    this._compassArcSweep     = 0;
    this._compassCurrentAngle = 0;
    this._compassLastAngle    = 0;

    // Mind map editing state
    this._editingMindmapStroke   = null;  // compound stroke being edited node-by-node
    this._selectedMindmapNodeId  = null;  // id of currently selected node within mindmap
    this._mmNodeDragStart        = null;  // {nodeId, origRelX, origRelY, startVx, startVy}

    // Smart connector anchor snapping
    this._connSnapStart     = null;
    this._connSnapEnd       = null;
    this._connHoverShapeId  = null;

    // rAF render gate — at most one render per browser frame
    this._rafPending = false;

    // Pause rendering when tab is hidden (resume on visibilitychange)
    this._visHandler = () => { if (!document.hidden) this._doRender(); };
    document.addEventListener('visibilitychange', this._visHandler);

    // dynamic overlay canvas (selection, snap guides, live strokes, laser)
    this._dynCanvas = document.createElement('canvas');
    this._dynCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;';
    const _wrap = canvas.parentElement;
    if (_wrap) _wrap.appendChild(this._dynCanvas);
    this._dynCtx = this._dynCanvas.getContext('2d');

    // minimap canvas — navigation overview (bottom-right corner)
    this._mmCanvas  = document.createElement('canvas');
    this._mmCanvas.width  = 192;
    this._mmCanvas.height = 108;
    this._mmCanvas.style.cssText = [
      'position:absolute;bottom:10px;right:10px;z-index:25;',
      'width:192px;height:108px;border-radius:6px;',
      'border:1px solid rgba(155,93,229,0.35);',
      'box-shadow:0 2px 12px rgba(0,0,0,0.55);',
      'cursor:crosshair;display:none;',
      'transition:opacity 0.25s;'
    ].join('');
    this._mmCtx  = this._mmCanvas.getContext('2d');
    this._mmDrag = false;
    if (_wrap) _wrap.appendChild(this._mmCanvas);
    const _mmDown = e => { e.stopPropagation(); this._mmDrag = true; this._mmNavigate(e); };
    const _mmMove = e => { if (this._mmDrag) this._mmNavigate(e); };
    const _mmUp   = () => { this._mmDrag = false; };
    this._mmCanvas.addEventListener('mousedown',  _mmDown);
    this._mmCanvas.addEventListener('mousemove',  _mmMove);
    this._mmCanvas.addEventListener('mouseup',    _mmUp);
    document.addEventListener('mouseup', _mmUp);

    this._bindEvents();
    this.fit();

    this._ro = new ResizeObserver(() => this.fit());
    this._ro.observe(canvas.parentElement || canvas);
  }

  /* ── backward-compat: single selectedId getter/setter ──────────────── */
  get _selectedId()  { for (const id of this._selectedIds) return id; return null; }
  set _selectedId(v) { this._selectedIds.clear(); if (v != null) this._selectedIds.add(v); }

  /* ── setup ─────────────────────────────────────────────────────────── */

  _bindEvents() {
    const c = this._canvas;
    const onDown = e => {
      if (e.button === 1) { e.preventDefault(); this._beginPan(e); return; }
      if (!this._readOnly) this._onPointerDown(e);
    };
    const onMove = e => {
      if (this._panStartCss) { this._onPointerMove(e); return; } // pan works for everyone
      if (!this._readOnly) { this._onPointerMove(e); return; }
      // readOnly: still broadcast cursor position if callback set
      if (this._onCursorMove) {
        const [vx, vy] = this._pointerPos(e);
        const now = Date.now();
        if (now - this._cursorThrottle > 33) {
          this._cursorThrottle = now;
          this._onCursorMove(vx, vy);
        }
      }
    };
    const onUp = e => {
      if (this._panStartCss) { this._onPointerUp(e); return; } // pan end for everyone
      if (!this._readOnly) this._onPointerUp(e);
    };
    // Prevent browser auto-scroll cursor on middle-click
    c.addEventListener('mousedown', e => { if (e.button === 1) e.preventDefault(); });

    c.addEventListener('pointerdown', onDown);
    c.addEventListener('pointermove', onMove);
    c.addEventListener('pointerup',   onUp);
    c.addEventListener('pointerleave', onUp);
    c.addEventListener('pointercancel', onUp);
    c.addEventListener('touchstart', e => e.preventDefault(), { passive: false });
    c.addEventListener('wheel', e => this._onWheel(e), { passive: false });

    this._onKeyDown  = e => this._handleKeyDown(e);
    this._onKeyUp    = e => { if (e.key === ' ') { this._spaceDown = false; if (!this._panStartCss) this._canvas.style.cursor = ''; } };
    this._onPaste    = e => this._onClipboardPaste(e);
    document.addEventListener('keydown', this._onKeyDown);
    document.addEventListener('keyup',   this._onKeyUp);
    document.addEventListener('paste',   this._onPaste);
  }

  _handleKeyDown(e) {
    if (this._readOnly) return;
    // Mindmap keyboard shortcuts
    if (this._editingMindmapStroke) {
      const tag = document.activeElement?.tagName;
      if (tag === 'INPUT' || tag === 'TEXTAREA') return;
      if ((e.key === 'Delete' || e.key === 'Backspace') && this._selectedMindmapNodeId) {
        e.preventDefault();
        this._deleteMindmapNode(this._editingMindmapStroke, this._selectedMindmapNodeId);
        return;
      }
      if (e.key === 'Tab' && this._selectedMindmapNodeId) {
        e.preventDefault();
        this._addMindmapChild(this._editingMindmapStroke, this._selectedMindmapNodeId);
        return;
      }
      if (e.key === 'Escape') {
        this._editingMindmapStroke  = null;
        this._selectedMindmapNodeId = null;
        this._mmNodeDragStart       = null;
        this.render();
        return;
      }
      if ((e.key === 'F2' || e.key === 'Enter') && this._selectedMindmapNodeId) {
        e.preventDefault();
        this._editMindmapNodeText(this._editingMindmapStroke, this._selectedMindmapNodeId);
        return;
      }
    }
    const tag = document.activeElement?.tagName;
    if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return;

    if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') { this.copy(); }
    if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') { e.preventDefault(); this.paste(); }
    if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); this.undo(); }
    if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') { e.preventDefault(); this.redo(); }
    if (e.key === 'Delete' || e.key === 'Backspace') {
      if (this._selectedIds.size > 0) { e.preventDefault(); this.deleteSelected(); }
    }
    if (e.key === 'Escape') {
      if (this._tool === 'compass' && this._compassState !== 'idle') {
        this._compassState = 'idle'; this._drawing = false; this.render();
      } else {
        this._selectedIds.clear(); this._dragState = null; this._lassoRect = null; this.render();
      }
    }
    // Zoom shortcuts
    if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) { e.preventDefault(); this.zoomTo(this._zoom * 1.25); }
    if ((e.ctrlKey || e.metaKey) && e.key === '-') { e.preventDefault(); this.zoomTo(this._zoom / 1.25); }
    if ((e.ctrlKey || e.metaKey) && e.key === '0') { e.preventDefault(); this.resetView(); }
    // Space = pan mode
    if (e.key === ' ' && !this._spaceDown) { e.preventDefault(); this._spaceDown = true; this._canvas.style.cursor = 'grab'; }
  }

  _isShapeTool() {
    return ['rect','ellipse','line','arrow',
            'triangle','diamond','hexagon','star',
            'roundedrect','callout'].includes(this._tool);
  }

  _isObjectStroke(s) {
    return s.tool === 'image' || s.tool === 'sticky' ||
           s.tool === 'formula' || s.tool === 'table' || s.tool === 'coordinate' ||
           s.tool === 'numberline' || s.tool === 'compass' || s.tool === 'mindmap';
  }

  _isResizableStroke(s) {
    // Compass uses specialized handles, not corner resize; mindmap has its own node handles
    return (this._isObjectStroke(s) && s.tool !== 'compass' && s.tool !== 'mindmap') || s.tool === 'shape' || s.tool === 'connector';
  }

  /* Returns {x, y, w, h} bounding box in virtual coords for any stroke type */
  _getStrokeBBox(stroke) {
    const d = stroke.data;
    // Compass uses cx/cy/radius geometry
    if (stroke.tool === 'compass') {
      const pad = 40;
      const r   = (d.radius || 50) + pad;
      return { x: d.cx - r, y: d.cy - r, w: r * 2, h: r * 2 };
    }
    if (stroke.tool === 'mindmap') {
      if (!d.nodes || !d.nodes.length) return { x: d.x - 100, y: d.y - 60, w: 200, h: 120 };
      let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
      for (const n of d.nodes) {
        const hw = (this._mmNodeDepth(d, n.id) === 0 ? 140 : 120) / 2 + 30;
        const hh = (this._mmNodeDepth(d, n.id) === 0 ? 54  : 44)  / 2 + 20;
        minX = Math.min(minX, d.x + n.relX - hw);
        maxX = Math.max(maxX, d.x + n.relX + hw);
        minY = Math.min(minY, d.y + n.relY - hh);
        maxY = Math.max(maxY, d.y + n.relY + hh);
      }
      return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
    }
    if (this._isObjectStroke(stroke)) return { x: d.x, y: d.y, w: d.w, h: d.h };
    if (stroke.tool === 'text') {
      const lines = (d.text || '').split('\n').length;
      return { x: d.x, y: d.y, w: 400, h: (d.fontSize || 22) * 1.45 * (lines + 1) };
    }
    if (stroke.tool === 'connector') {
      const ep = this._getConnectorEndpoints(d);
      return {
        x: Math.min(ep.x1, ep.x2), y: Math.min(ep.y1, ep.y2),
        w: Math.abs(ep.x2 - ep.x1) || 20, h: Math.abs(ep.y2 - ep.y1) || 20,
      };
    }
    if (stroke.tool === 'shape') {
      return {
        x: Math.min(d.x1, d.x2), y: Math.min(d.y1, d.y2),
        w: Math.abs(d.x2 - d.x1) || 20, h: Math.abs(d.y2 - d.y1) || 20,
      };
    }
    if (d.points && d.points.length) {
      const xs = d.points.map(p => p[0]), ys = d.points.map(p => p[1]);
      const bx = Math.min(...xs) - 10, by = Math.min(...ys) - 10;
      return { x: bx, y: by, w: Math.max(...xs) - bx + 20, h: Math.max(...ys) - by + 20 };
    }
    return { x: 0, y: 0, w: Whiteboard.VW, h: Whiteboard.VH };
  }

  /* Move any stroke by virtual delta */
  _moveStroke(stroke, dvx, dvy) {
    const d = stroke.data;
    if (stroke.tool === 'mindmap') { stroke.data.x += dvx; stroke.data.y += dvy; return; }
    if (stroke.tool === 'compass') { d.cx += dvx; d.cy += dvy; return; }
    if (this._isObjectStroke(stroke) || stroke.tool === 'text') {
      d.x += dvx; d.y += dvy;
    } else if (stroke.tool === 'shape') {
      d.x1 += dvx; d.y1 += dvy; d.x2 += dvx; d.y2 += dvy;
    } else if (stroke.tool === 'connector') {
      if (!d.fromId) { d.x1 += dvx; d.y1 += dvy; }
      if (!d.toId)   { d.x2 += dvx; d.y2 += dvy; }
      if (d.fromId && d.toId) { d.x1 += dvx; d.y1 += dvy; d.x2 += dvx; d.y2 += dvy; }
    } else if (d.points) {
      d.points = d.points.map(([px, py]) => [px + dvx, py + dvy]);
    }
  }

  /* Resize shape/connector by dragging a corner handle */
  _applyResizeShape(data, ds, vx, vy) {
    const origMinX = Math.min(ds.origX1, ds.origX2);
    const origMaxX = Math.max(ds.origX1, ds.origX2);
    const origMinY = Math.min(ds.origY1, ds.origY2);
    const origMaxY = Math.max(ds.origY1, ds.origY2);
    const h = ds.handle;
    let newMinX = origMinX, newMaxX = origMaxX;
    let newMinY = origMinY, newMaxY = origMaxY;
    if (h === 'tl' || h === 'bl') newMinX = Math.min(vx, origMaxX - 10);
    else                           newMaxX = Math.max(vx, origMinX + 10);
    if (h === 'tl' || h === 'tr') newMinY = Math.min(vy, origMaxY - 10);
    else                           newMaxY = Math.max(vy, origMinY + 10);
    // Map back to x1,y1,x2,y2 preserving which slot is min/max
    if (ds.origX1 <= ds.origX2) { data.x1 = newMinX; data.x2 = newMaxX; }
    else                         { data.x1 = newMaxX; data.x2 = newMinX; }
    if (ds.origY1 <= ds.origY2) { data.y1 = newMinY; data.y2 = newMaxY; }
    else                         { data.y1 = newMaxY; data.y2 = newMinY; }
  }

  setSnapEnabled(v) { this._snapEnabled = v; if (!v) { this._snapGuides = []; this.render(); } }

  /* Snap guides: compute alignment guides for movingStroke vs. all other strokes */
  _computeSnapGuides(movingStroke) {
    if (!this._snapEnabled) { this._snapGuides = []; return; }
    const SNAP = 8; // virtual-pixel threshold
    const b = this._getStrokeBBox(movingStroke);
    const guides = [];
    const seenX = new Set(), seenY = new Set();
    for (const s of this._strokes) {
      if (this._selectedIds.has(s.id)) continue;
      const o = this._getStrokeBBox(s);
      const xs = [o.x, o.x + o.w, o.x + o.w / 2];
      const ys = [o.y, o.y + o.h, o.y + o.h / 2];
      for (const sv of xs) {
        if (seenX.has(sv)) continue;
        const moving = [b.x, b.x + b.w, b.x + b.w / 2];
        if (moving.some(mv => Math.abs(mv - sv) < SNAP)) { guides.push({ axis: 'x', pos: sv }); seenX.add(sv); }
      }
      for (const sv of ys) {
        if (seenY.has(sv)) continue;
        const moving = [b.y, b.y + b.h, b.y + b.h / 2];
        if (moving.some(mv => Math.abs(mv - sv) < SNAP)) { guides.push({ axis: 'y', pos: sv }); seenY.add(sv); }
      }
    }
    this._snapGuides = guides;
  }

  /* ── Mind map helpers ─────────────────────────────────────────── */

  _mmNodeDepth(data, nodeId) {
    let depth = 0, current = nodeId;
    while (depth < 20) {
      const node = data.nodes.find(n => n.id === current);
      if (!node || node.parentId === null) break;
      current = node.parentId;
      depth++;
    }
    return depth;
  }

  _mmNodeSize(depth) {
    if (depth === 0) return { w: 148, h: 54 };
    if (depth === 1) return { w: 124, h: 42 };
    return { w: 106, h: 36 };
  }

  _mmNodeAbsPos(data, node) {
    return { x: data.x + node.relX, y: data.y + node.relY };
  }

  _mmNodeRect(data, node) {
    const pos  = this._mmNodeAbsPos(data, node);
    const sz   = this._mmNodeSize(this._mmNodeDepth(data, node.id));
    return { x: pos.x - sz.w / 2, y: pos.y - sz.h / 2, w: sz.w, h: sz.h, cx: pos.x, cy: pos.y };
  }

  _hitTestMindmapNode(stroke, vx, vy) {
    const d = stroke.data;
    for (let i = d.nodes.length - 1; i >= 0; i--) {
      const n = d.nodes[i];
      const r = this._mmNodeRect(d, n);
      if (vx >= r.x && vx <= r.x + r.w && vy >= r.y && vy <= r.y + r.h) return n;
    }
    return null;
  }

  _mmSubtree(data, nodeId) {
    const ids = new Set([nodeId]);
    let changed = true;
    while (changed) {
      changed = false;
      for (const n of data.nodes) {
        if (!ids.has(n.id) && ids.has(n.parentId)) { ids.add(n.id); changed = true; }
      }
    }
    return ids;
  }

  _addMindmapChild(stroke, parentNodeId) {
    const data   = stroke.data;
    const parent = data.nodes.find(n => n.id === parentNodeId);
    if (!parent) return;
    const depth    = this._mmNodeDepth(data, parentNodeId);
    const siblings = data.nodes.filter(n => n.parentId === parentNodeId);
    const colors   = ['#06D6E0', '#F15BB5', '#A8E063', '#FF9F43', '#FF6B6B', '#4361EE', '#9B5DE5'];
    const col      = depth === 0 ? colors[siblings.length % colors.length] : (parent.color || '#06D6E0');
    const spread   = 140;
    const dist     = depth === 0 ? 280 : 220;
    let relX, relY;
    if (depth === 0) {
      const rightCount  = data.nodes.filter(n => n.parentId === 'root' && n.relX > 0).length;
      const leftCount   = data.nodes.filter(n => n.parentId === 'root' && n.relX < 0).length;
      const goRight = rightCount <= leftCount;
      relX = goRight ? dist : -dist;
      const sideNodes = data.nodes.filter(n => n.parentId === 'root' && (goRight ? n.relX > 0 : n.relX < 0));
      relY = sideNodes.length === 0 ? 0 : (sideNodes.length % 2 === 0 ? -1 : 1) * Math.ceil(sideNodes.length / 2) * spread;
    } else {
      relX = parent.relX + (parent.relX >= 0 ? dist : -dist);
      if (siblings.length === 0) relY = parent.relY;
      else if (siblings.length === 1) relY = parent.relY - spread;
      else relY = parent.relY + (siblings.length - 1) * (spread * 0.7) * (siblings.length % 2 === 0 ? 1 : -1);
    }
    const newId = `mm_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`;
    data.nodes.push({ id: newId, text: 'Новый узел', parentId: parentNodeId, relX, relY, color: col });
    this._selectedMindmapNodeId = newId;
    this._staticDirty = true;
    this.render();
    if (this._onStrokeUpdated) this._onStrokeUpdated(stroke);
    setTimeout(() => {
      if (this._editingMindmapStroke === stroke) this._editMindmapNodeText(stroke, newId);
    }, 60);
  }

  _deleteMindmapNode(stroke, nodeId) {
    const data = stroke.data;
    if (!nodeId) return;
    const node = data.nodes.find(n => n.id === nodeId);
    if (!node || node.parentId === null) return;
    const toDelete = this._mmSubtree(data, nodeId);
    data.nodes = data.nodes.filter(n => !toDelete.has(n.id));
    this._selectedMindmapNodeId = null;
    this._staticDirty = true;
    this.render();
    if (this._onStrokeUpdated) this._onStrokeUpdated(stroke);
  }

  _editMindmapNodeText(stroke, nodeId) {
    const data = stroke.data;
    const node = data.nodes.find(n => n.id === nodeId);
    if (!node) return;
    const wrap = this._canvas.parentElement;
    if (!wrap) return;
    this._removeObjectInput();
    const r    = this._mmNodeRect(data, node);
    const [cx, cy] = this._toCanvas(r.x, r.y);
    const cw = (r.w / Whiteboard.VW) * this._cssW;
    const ch = (r.h / Whiteboard.VH) * this._cssH;
    const depth = this._mmNodeDepth(data, nodeId);
    const vFontSize = depth === 0 ? 15 : 13;
    const fs  = Math.max(9, Math.round((vFontSize / Whiteboard.VH) * this._cssH));
    const ta  = document.createElement('textarea');
    ta.value  = node.text || '';
    ta.style.cssText = `
      position:absolute; left:${cx}px; top:${cy}px;
      width:${Math.max(60, cw)}px; height:${Math.max(22, ch)}px;
      font-size:${fs}px; font-family:'Manrope',sans-serif; font-weight:600;
      color:#fff; background:transparent; border:none; outline:none; resize:none;
      padding:2px 6px; text-align:center; line-height:1.35;
      box-sizing:border-box; caret-color:#fff; z-index:20;
    `;
    wrap.style.position = 'relative';
    wrap.appendChild(ta);
    ta.focus();
    ta.select();
    this._objectInput = { el: ta, strokeId: stroke.id };
    const commit = () => {
      const text = ta.value.trim() || node.text;
      this._removeObjectInput();
      const s = this._strokes.find(x => x.id === stroke.id);
      if (!s) return;
      const n2 = s.data.nodes.find(n => n.id === nodeId);
      if (n2) n2.text = text || 'Узел';
      this._staticDirty = true;
      this.render();
      if (this._onStrokeUpdated) this._onStrokeUpdated(s);
    };
    ta.addEventListener('keydown', ev => {
      ev.stopPropagation();
      if (ev.key === 'Escape') { ev.preventDefault(); this._removeObjectInput(); }
      if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); commit(); }
      if (ev.key === 'Tab') { ev.preventDefault(); commit(); setTimeout(() => { if (this._editingMindmapStroke) this._addMindmapChild(this._editingMindmapStroke, nodeId); }, 80); }
    });
    ta.addEventListener('blur', () => setTimeout(commit, 120));
  }

  _enterMindmapEdit(stroke, vx, vy) {
    this._editingMindmapStroke  = stroke;
    this._selectedId            = stroke.id;
    const nodeHit = this._hitTestMindmapNode(stroke, vx, vy);
    this._selectedMindmapNodeId = nodeHit ? nodeHit.id : 'root';
    this.render();
  }

  _getAnchorPoint(stroke, anchor) {
    const b = this._getStrokeBBox(stroke);
    const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
    switch (anchor) {
      case 'n':  return { x: cx,        y: b.y       };
      case 's':  return { x: cx,        y: b.y + b.h };
      case 'e':  return { x: b.x + b.w, y: cy        };
      case 'w':  return { x: b.x,       y: cy        };
      default:   return { x: cx,        y: cy        };
    }
  }

  _findNearestAnchor(vx, vy, excludeId = null) {
    const THRESH = 40;
    let best = null, bestDist = THRESH;
    const anchors = ['n', 's', 'e', 'w', 'center'];
    for (let i = this._strokes.length - 1; i >= 0; i--) {
      const s = this._strokes[i];
      if (s.tool === 'connector' || s.tool === 'pencil' || s.tool === 'highlighter' || s.tool === 'eraser') continue;
      if (s.data.isBackground || s.data.locked) continue;
      if (s.id === excludeId) continue;
      for (const anchor of anchors) {
        const p = this._getAnchorPoint(s, anchor);
        const dist = Math.hypot(vx - p.x, vy - p.y);
        if (dist < bestDist) { bestDist = dist; best = { stroke: s, anchor, x: p.x, y: p.y }; }
      }
    }
    return best;
  }

  _getConnectorEndpoints(d) {
    let x1 = d.x1, y1 = d.y1, x2 = d.x2, y2 = d.y2;
    if (d.fromId) {
      const s = this._strokes.find(x => x.id === d.fromId);
      if (s) { const p = this._getAnchorPoint(s, d.fromAnchor || 'center'); x1 = p.x; y1 = p.y; }
    }
    if (d.toId) {
      const s = this._strokes.find(x => x.id === d.toId);
      if (s) { const p = this._getAnchorPoint(s, d.toAnchor || 'center'); x2 = p.x; y2 = p.y; }
    }
    return { x1, y1, x2, y2 };
  }

  _anchorDirection(anchor) {
    switch (anchor) {
      case 'n': return [0, -1]; case 's': return [0, 1];
      case 'e': return [1,  0]; case 'w': return [-1, 0];
      default:  return [0,  0];
    }
  }

  /* Hit-test any stroke (all types) — returns topmost hit */
  _hitTestAny(vx, vy) {
    for (let i = this._strokes.length - 1; i >= 0; i--) {
      const s = this._strokes[i];
      if (s.data.isBackground || s.data.locked) continue;
      const b = this._getStrokeBBox(s);
      if (vx >= b.x && vx <= b.x + b.w && vy >= b.y && vy <= b.y + b.h) return s;
    }
    return null;
  }

  fit() {
    const c    = this._canvas;
    const wrap = c.parentElement;
    if (!wrap) return;
    const dpr  = window.devicePixelRatio || 1;
    const w    = wrap.clientWidth;
    const h    = wrap.clientHeight;
    if (w === 0 || h === 0) {
      if (!this._fitPending) {
        this._fitPending = true;
        requestAnimationFrame(() => { this._fitPending = false; this.fit(); });
      }
      return;
    }
    c.width  = Math.round(w * dpr);
    c.height = Math.round(h * dpr);
    c.style.width  = w + 'px';
    c.style.height = h + 'px';
    this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    // size dynamic overlay canvas
    const dc = this._dynCanvas;
    dc.width  = c.width;
    dc.height = c.height;
    dc.style.width  = w + 'px';
    dc.style.height = h + 'px';
    this._dynCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this._dpr  = dpr;
    this._cssW = w;
    this._cssH = h;
    this._staticDirty = true;
    this.render();
  }

  /* ── coordinate helpers ─────────────────────────────────────────────── */

  _toVirtual(cssX, cssY) {
    const sx = (this._cssW || 1) / Whiteboard.VW;
    const sy = (this._cssH || 1) / Whiteboard.VH;
    return [
      cssX / (sx * this._zoom) + this._panVX,
      cssY / (sy * this._zoom) + this._panVY,
    ];
  }

  _toCanvas(vx, vy) {
    const sx = (this._cssW || 300) / Whiteboard.VW;
    const sy = (this._cssH || 150) / Whiteboard.VH;
    return [
      (vx - this._panVX) * sx * this._zoom,
      (vy - this._panVY) * sy * this._zoom,
    ];
  }

  _pointerPos(e) {
    const rect = this._canvas.getBoundingClientRect();
    const clientX = e.touches ? e.touches[0].clientX : e.clientX;
    const clientY = e.touches ? e.touches[0].clientY : e.clientY;
    return this._toVirtual(clientX - rect.left, clientY - rect.top);
  }

  /* ── pointer handlers ───────────────────────────────────────────────── */

  _beginPan(e) {
    const rect = this._canvas.getBoundingClientRect();
    const cx   = e.clientX - rect.left;
    const cy   = e.clientY - rect.top;
    this._panStartCss = [cx, cy];
    this._panStartPan = [this._panVX, this._panVY];
    this._canvas.style.cursor = 'grabbing';
    this._canvas.setPointerCapture(e.pointerId);
  }

  _onPointerDown(e) {
    // ── pan mode (Space + drag) ───────────────────────────────────────────
    if (this._spaceDown) { this._beginPan(e); return; }

    const [vx, vy] = this._pointerPos(e);

    // Pressure-sensitive width for stylus
    if (this._stylusMultiplier > 0 && e.pointerType === 'pen') {
      const p = Math.max(0.1, e.pressure || 0.5);
      this._effectiveWidth = Math.max(1, Math.round(this._width * p * this._stylusMultiplier));
    } else {
      this._effectiveWidth = null;
    }

    // ── overlay drag (ruler/protractor) — always checked first ────────────
    if (this._overlays.length > 0) {
      const hit = this._hitTestOverlay(vx, vy);
      if (hit) {
        const ov = this._overlays[hit.idx];
        this._selectedOverlayIdx = hit.idx;
        this._canvas.setPointerCapture(e.pointerId);
        if (hit.zone === 'body') {
          this._overlayDrag = { idx: hit.idx, type: 'move',
            startVx: vx, startVy: vy, origX: ov.x, origY: ov.y };
        } else if (hit.zone === 'rot') {
          this._overlayDrag = { idx: hit.idx, type: 'rotate',
            startAngle: Math.atan2(vy - ov.y, vx - ov.x),
            origAngle: ov.angle || 0 };
        } else if (hit.zone === 'resize') {
          const angle = ov.angle || 0;
          const dx = vx - ov.x, dy = vy - ov.y;
          if (ov.type === 'ruler') {
            this._overlayDrag = { idx: hit.idx, type: 'resize',
              startDot: dx * Math.cos(angle) + dy * Math.sin(angle),
              origWidth: ov.width };
          } else {
            this._overlayDrag = { idx: hit.idx, type: 'resize',
              startDist: Math.hypot(dx, dy),
              origRadius: ov.radius || 80 };
          }
        }
        if (this._onOverlayChange) this._onOverlayChange(ov);
        return;
      }
    }

    // ── select tool ──────────────────────────────────────────────────────
    if (this._tool === 'select') {
      const now = Date.now();
      const dbl = now - this._lastClickTime < 350 &&
                  Math.abs(vx - this._lastClickVx) < 40 &&
                  Math.abs(vy - this._lastClickVy) < 40;
      this._lastClickTime = now;
      this._lastClickVx   = vx;
      this._lastClickVy   = vy;

      // ── mindmap edit mode: intercept pointer events ──────────────────────
      if (this._editingMindmapStroke && this._tool === 'select') {
        const mm = this._editingMindmapStroke;
        const nodeHit = this._hitTestMindmapNode(mm, vx, vy);
        if (nodeHit) {
          if (dbl) { this._editMindmapNodeText(mm, nodeHit.id); return; }
          this._selectedMindmapNodeId = nodeHit.id;
          this._mmNodeDragStart = { nodeId: nodeHit.id, lastVx: vx, lastVy: vy };
          this._canvas.setPointerCapture(e.pointerId);
          this._staticDirty = true;
          this.render();
          return;
        }
        // Check "+" button
        if (this._selectedMindmapNodeId) {
          const selNode = mm.data.nodes.find(n => n.id === this._selectedMindmapNodeId);
          if (selNode) {
            const r = this._mmNodeRect(mm.data, selNode);
            const btnAbsX = r.x + r.w + 10 + 10;
            const btnAbsY = r.y + r.h / 2;
            if (Math.hypot(vx - btnAbsX, vy - btnAbsY) < 18) {
              this._addMindmapChild(mm, this._selectedMindmapNodeId);
              return;
            }
          }
        }
        // Click outside mindmap bbox: exit editing
        const bbox = this._getStrokeBBox(mm);
        const pad  = 30;
        if (vx < bbox.x - pad || vx > bbox.x + bbox.w + pad || vy < bbox.y - pad || vy > bbox.y + bbox.h + pad) {
          this._editingMindmapStroke  = null;
          this._selectedMindmapNodeId = null;
          this._mmNodeDragStart       = null;
          this.render();
          // Fall through to normal select behavior
        } else {
          // Click inside mindmap but not on a node: deselect node
          this._selectedMindmapNodeId = null;
          this._staticDirty = true;
          this.render();
          return;
        }
      }

      // Check handles on the primary selected stroke
      const primarySel = this._selectedIds.size === 1
        ? this._strokes.find(s => s.id === this._selectedId) : null;
      if (primarySel) {
        // Rotation handle (object strokes only)
        if (this._isObjectStroke(primarySel)) {
          const rotH = this._hitTestObjectHandle(vx, vy, primarySel);
          if (rotH === 'rot') {
            const b = this._getStrokeBBox(primarySel);
            const ocx = b.x + b.w / 2, ocy = b.y + b.h / 2;
            this._dragState = { type: 'rotate', cx: ocx, cy: ocy,
              origRotation: primarySel.data.rotation || 0,
              startAngle: Math.atan2(vy - ocy, vx - ocx) };
            this._canvas.setPointerCapture(e.pointerId);
            return;
          }
        }
        if (this._isResizableStroke(primarySel)) {
          const handle = this._hitTestObjectHandle(vx, vy, primarySel);
          if (handle && handle !== 'rot') {
            const d = primarySel.data;
            if (this._isObjectStroke(primarySel)) {
              this._dragState = { type: 'resize', handle,
                startVx: vx, startVy: vy,
                origX: d.x, origY: d.y, origW: d.w, origH: d.h };
            } else {
              this._dragState = { type: 'resize_shape', handle,
                origX1: d.x1, origY1: d.y1, origX2: d.x2, origY2: d.y2 };
            }
            this._canvas.setPointerCapture(e.pointerId);
            return;
          }
        }
      }
      // Check if click is inside any selected stroke (for move)
      let clickedSelected = null;
      for (const id of this._selectedIds) {
        const s = this._strokes.find(x => x.id === id);
        if (!s) continue;
        const bbox = this._getStrokeBBox(s);
        if (vx >= bbox.x && vx <= bbox.x + bbox.w && vy >= bbox.y && vy <= bbox.y + bbox.h) {
          clickedSelected = s; break;
        }
      }
      if (clickedSelected) {
        if (dbl) { this._editObject(clickedSelected, vx, vy); return; }
        this._dragState = { type: 'move', lastVx: vx, lastVy: vy };
        this._canvas.setPointerCapture(e.pointerId);
        this.render();
        return;
      }

      const hit = this._hitTestAny(vx, vy);
      if (hit) {
        if (dbl) { this._editObject(hit, vx, vy); return; }
        if (e.shiftKey) {
          // Shift+click: toggle in selection
          if (this._selectedIds.has(hit.id)) this._selectedIds.delete(hit.id);
          else this._selectedIds.add(hit.id);
        } else {
          this._selectedIds.clear();
          this._selectedIds.add(hit.id);
          this._dragState = { type: 'move', lastVx: vx, lastVy: vy };
          this._canvas.setPointerCapture(e.pointerId);
        }
      } else {
        // Click on empty space: start lasso
        if (!e.shiftKey) this._selectedIds.clear();
        this._lassoRect = { x1: vx, y1: vy, x2: vx, y2: vy };
        this._canvas.setPointerCapture(e.pointerId);
      }
      this._snapGuides = [];
      this.render();
      return;
    }

    // ── text tool ────────────────────────────────────────────────────────
    if (this._tool === 'text') {
      e.preventDefault(); // prevent browser default focus shift during pointerdown
      this._placeTextInput([vx, vy]);
      return;
    }

    // ── sticky tool ──────────────────────────────────────────────────────
    if (this._tool === 'sticky') { this._createSticky(vx, vy); return; }

    // ── formula tool ─────────────────────────────────────────────────────
    if (this._tool === 'formula') { this._placeFormula(vx, vy); return; }

    // ── laser pointer (ephemeral — not saved) ────────────────────────────
    if (this._tool === 'laser') {
      this._canvas.setPointerCapture(e.pointerId);
      this._drawing    = true;
      this._laserPos   = [vx, vy];
      this._laserTrail = [[vx, vy]];
      this._liveId     = `${Date.now()}${Math.random().toString(36).slice(2, 5)}`;
      if (this._onStrokeProgress)
        this._onStrokeProgress({ liveId: this._liveId, tool: 'laser', data: { points: [[vx, vy]] } });
      this.render();
      return;
    }

    // ── coordinate tool ───────────────────────────────────────────────────
    if (this._tool === 'coordinate') {
      const W = 600, H = 500;
      const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W));
      const y = Math.max(0, Math.min(vy - H / 2, Whiteboard.VH - H));
      const stroke = {
        id: this._localIdCounter--, tool: 'coordinate',
        data: { x, y, w: W, h: H, xMin: -10, xMax: 10, yMin: -10, yMax: 10,
                gridStep: 1, showLabels: true, functions: [] },
      };
      this._strokes.push(stroke);
      this._undoStack.push(stroke.id);
      this._redoStack = [];
      this._selectedId = stroke.id;
      this._staticDirty = true;
      this.render();
      if (this._onStrokeDone)    this._onStrokeDone(stroke);
      if (this._onObjectCreated) this._onObjectCreated(stroke);
      return;
    }

    // ── number line tool ─────────────────────────────────────────────────
    if (this._tool === 'numberline') {
      const W = 700, H = 120;
      const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W));
      const y = Math.max(0, Math.min(vy - H / 2, Whiteboard.VH - H));
      const stroke = {
        id: this._localIdCounter--, tool: 'numberline',
        data: { x, y, w: W, h: H, min: -10, max: 10, step: 1, points: [], intervals: [] },
      };
      this._strokes.push(stroke);
      this._undoStack.push(stroke.id);
      this._redoStack = [];
      this._selectedId = stroke.id;
      this._staticDirty = true;
      this.render();
      if (this._onStrokeDone)    this._onStrokeDone(stroke);
      if (this._onObjectCreated) this._onObjectCreated(stroke);
      return;
    }

    // ── mindmap tool ───────────────────────────────────────────────────────
    if (this._tool === 'mindmap') {
      const colors = ['#06D6E0', '#F15BB5', '#A8E063', '#FF9F43', '#FF6B6B', '#4361EE'];
      const rightX = 280, leftX = -280;
      const stroke = {
        id: this._localIdCounter--, tool: 'mindmap',
        data: {
          x: vx, y: vy,
          nodes: [
            { id: 'root', text: 'Главная идея', parentId: null, relX: 0,      relY: 0,    color: '#9B5DE5' },
            { id: 'mm1',  text: 'Ветка 1',      parentId: 'root', relX: rightX, relY: -140, color: colors[0] },
            { id: 'mm2',  text: 'Ветка 2',      parentId: 'root', relX: rightX, relY:  0,   color: colors[1] },
            { id: 'mm3',  text: 'Ветка 3',      parentId: 'root', relX: rightX, relY:  140, color: colors[2] },
            { id: 'mm4',  text: 'Ветка 4',      parentId: 'root', relX: leftX,  relY: -70,  color: colors[3] },
            { id: 'mm5',  text: 'Ветка 5',      parentId: 'root', relX: leftX,  relY:  70,  color: colors[4] },
          ],
        },
      };
      this._strokes.push(stroke);
      this._undoStack.push(stroke.id);
      this._redoStack = [];
      this._selectedId = stroke.id;
      this._staticDirty = true;
      this.render();
      if (this._onStrokeDone)    this._onStrokeDone(stroke);
      if (this._onObjectCreated) this._onObjectCreated(stroke);
      this._editingMindmapStroke  = stroke;
      this._selectedMindmapNodeId = 'root';
      if (this._onToolSwitch) this._onToolSwitch('select');
      return;
    }

    // ── compass tool: two-phase state machine ────────────────────────────
    if (this._tool === 'compass') {
      e.preventDefault();
      this._canvas.setPointerCapture(e.pointerId);
      if (this._compassState === 'idle') {
        // Phase 1: set center, start dragging for radius
        this._compassCenter       = { x: vx, y: vy };
        this._compassRadius       = 0;
        this._compassCurrentAngle = -Math.PI / 2;
        this._compassLastAngle    = -Math.PI / 2;
        this._compassState        = 'setting-radius';
        this._drawing             = true;
      } else if (this._compassState === 'waiting-arc') {
        // Phase 2: set arc start, start dragging for sweep
        const dx = vx - this._compassCenter.x, dy = vy - this._compassCenter.y;
        this._compassArcStart     = Math.atan2(dy, dx);
        this._compassArcSweep     = 0;
        this._compassCurrentAngle = this._compassArcStart;
        this._compassLastAngle    = this._compassArcStart;
        this._compassState        = 'drawing-arc';
      }
      this.render();
      return;
    }

    // ── drawing tools (pencil, eraser, highlighter, shapes, connector, table) ──
    this._canvas.setPointerCapture(e.pointerId);
    this._drawing = true;
    this._liveId  = `${Date.now()}${Math.random().toString(36).slice(2, 5)}`;
    if (this._tool === 'connector') {
      const snap = this._findNearestAnchor(vx, vy);
      if (snap) {
        this._connSnapStart = { strokeId: snap.stroke.id, anchor: snap.anchor, x: snap.x, y: snap.y };
      } else {
        this._connSnapStart = null;
      }
    }
    if (this._isShapeTool() || this._tool === 'connector' || this._tool === 'table') {
      this._shapeStart = [vx, vy];
      this._shapeEnd   = [vx, vy];
    } else {
      this._curPts = [[vx, vy]];
    }
  }

  _onPointerMove(e) {
    // ── pan drag ─────────────────────────────────────────────────────────
    if (this._panStartCss) {
      const rect = this._canvas.getBoundingClientRect();
      const cx = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
      const cy = (e.touches ? e.touches[0].clientY : e.clientY) - rect.top;
      const sx = (this._cssW || 300) / Whiteboard.VW;
      const sy = (this._cssH || 150) / Whiteboard.VH;
      this._panVX = this._panStartPan[0] - (cx - this._panStartCss[0]) / (sx * this._zoom);
      this._panVY = this._panStartPan[1] - (cy - this._panStartCss[1]) / (sy * this._zoom);
      this._clampPan();
      this._staticDirty = true;
      this.render();
      return;
    }

    const [vx, vy] = this._pointerPos(e);
    this._pointerVx = vx; this._pointerVy = vy;

    // Broadcast cursor position to other participants (throttled to ~30fps)
    if (this._onCursorMove) {
      const now = Date.now();
      if (now - this._cursorThrottle > 33) {
        this._cursorThrottle = now;
        this._onCursorMove(vx, vy);
      }
    }

    // ── overlay drag ──────────────────────────────────────────────────────
    if (this._overlayDrag) {
      const od = this._overlayDrag;
      const ov = this._overlays[od.idx];
      if (ov) {
        if (od.type === 'move') {
          ov.x = od.origX + (vx - od.startVx);
          ov.y = od.origY + (vy - od.startVy);
        } else if (od.type === 'rotate') {
          const cur = Math.atan2(vy - ov.y, vx - ov.x);
          ov.angle = od.origAngle + (cur - od.startAngle);
        } else if (od.type === 'resize') {
          const angle = ov.angle || 0;
          const dx = vx - ov.x, dy = vy - ov.y;
          if (ov.type === 'ruler') {
            const dot = dx * Math.cos(angle) + dy * Math.sin(angle);
            ov.width = Math.max(100, od.origWidth + (dot - od.startDot));
          } else if (ov.type === 'protractor') {
            ov.radius = Math.max(40, od.origRadius + (Math.hypot(dx, dy) - od.startDist));
          }
        }
        if (this._onOverlayChange) this._onOverlayChange(ov);
      }
      this.render();
      return;
    }

    // ── laser: update position + trail ───────────────────────────────────
    if (this._tool === 'laser' && this._drawing) {
      this._laserPos = [vx, vy];
      this._laserTrail.push([vx, vy]);
      if (this._laserTrail.length > 25) this._laserTrail.shift();
      if (this._onStrokeProgress && this._liveId)
        this._onStrokeProgress({ liveId: this._liveId, tool: 'laser', data: { points: [[vx, vy]] } });
      this.render();
      return;
    }

    // ── compass: update radius or arc sweep ──────────────────────────────
    if (this._tool === 'compass' && this._compassState !== 'idle') {
      const dx = vx - this._compassCenter.x, dy = vy - this._compassCenter.y;
      if (this._compassState === 'setting-radius') {
        this._compassRadius       = Math.hypot(dx, dy);
        this._compassCurrentAngle = Math.atan2(dy, dx);
      } else if (this._compassState === 'drawing-arc') {
        this._compassCurrentAngle = Math.atan2(dy, dx);
        // Accumulate sweep with wrap-around handling
        let delta = this._compassCurrentAngle - this._compassLastAngle;
        if (delta > Math.PI)  delta -= Math.PI * 2;
        if (delta < -Math.PI) delta += Math.PI * 2;
        this._compassArcSweep += delta;
        // Clamp to ±2π (full circle max)
        this._compassArcSweep = Math.max(-Math.PI * 2, Math.min(Math.PI * 2, this._compassArcSweep));
        this._compassLastAngle = this._compassCurrentAngle;
      }
      this.render();
      return;
    }

    // ── select: update cursor + drag ──────────────────────────────────────
    if (this._tool === 'select') {
      // Lasso update
      if (this._lassoRect) {
        this._lassoRect.x2 = vx;
        this._lassoRect.y2 = vy;
        this.render();
        return;
      }

      if (!this._dragState) {
        let cur = 'default';
        if (this._selectedIds.size === 1) {
          const sel = this._strokes.find(s => s.id === this._selectedId);
          if (sel) {
            if (this._isResizableStroke(sel)) {
              const h = this._hitTestObjectHandle(vx, vy, sel);
              if (h === 'tl' || h === 'br') cur = 'nwse-resize';
              else if (h === 'tr' || h === 'bl') cur = 'nesw-resize';
            }
            if (cur === 'default') {
              const bbox = this._getStrokeBBox(sel);
              if (vx >= bbox.x && vx <= bbox.x + bbox.w && vy >= bbox.y && vy <= bbox.y + bbox.h)
                cur = 'move';
            }
          }
        }
        if (cur === 'default') {
          for (const id of this._selectedIds) {
            const s = this._strokes.find(x => x.id === id);
            if (!s) continue;
            const b = this._getStrokeBBox(s);
            if (vx >= b.x && vx <= b.x + b.w && vy >= b.y && vy <= b.y + b.h) { cur = 'move'; break; }
          }
        }
        if (cur === 'default' && this._hitTestAny(vx, vy)) cur = 'move';
        this._canvas.style.cursor = cur;
      }

      // Mindmap node drag
      if (this._mmNodeDragStart) {
        const mm = this._editingMindmapStroke;
        if (mm) {
          const dvx = vx - this._mmNodeDragStart.lastVx;
          const dvy = vy - this._mmNodeDragStart.lastVy;
          this._mmNodeDragStart.lastVx = vx;
          this._mmNodeDragStart.lastVy = vy;
          const node = mm.data.nodes.find(n => n.id === this._mmNodeDragStart.nodeId);
          if (node) { node.relX += dvx; node.relY += dvy; }
          this._staticDirty = true;
          this.render();
          return;
        }
      }

      if (!this._dragState) return;
      const ds  = this._dragState;
      if (ds.type === 'move') {
        const dvx = vx - ds.lastVx, dvy = vy - ds.lastVy;
        ds.lastVx = vx; ds.lastVy = vy;
        // Move all selected strokes
        for (const id of this._selectedIds) {
          const s = this._strokes.find(x => x.id === id);
          if (s) this._moveStroke(s, dvx, dvy);
        }
        // Compute snap guides for primary selected stroke
        if (this._selectedIds.size === 1) {
          const sel = this._strokes.find(s => s.id === this._selectedId);
          if (sel) this._computeSnapGuides(sel);
        } else { this._snapGuides = []; }
        this._staticDirty = true;
      } else if (ds.type === 'resize') {
        const sel = this._strokes.find(s => s.id === this._selectedId);
        if (!sel) return;
        this._applyResize(sel.data, ds, vx - ds.startVx, vy - ds.startVy);
        this._staticDirty = true;
      } else if (ds.type === 'resize_shape') {
        const sel = this._strokes.find(s => s.id === this._selectedId);
        if (!sel) return;
        this._applyResizeShape(sel.data, ds, vx, vy);
        this._staticDirty = true;
      } else if (ds.type === 'rotate') {
        const sel = this._strokes.find(s => s.id === this._selectedId);
        if (!sel) return;
        const angle = Math.atan2(vy - ds.cy, vx - ds.cx);
        let rotation = ds.origRotation + (angle - ds.startAngle);
        if (e.shiftKey) rotation = Math.round(rotation / (Math.PI / 12)) * (Math.PI / 12);
        sel.data.rotation = rotation;
        this._staticDirty = true;
      }
      this.render();
      return;
    }

    // Connector: update hover shape + snap target (even when not drawing)
    if (this._tool === 'connector') {
      const hit = this._hitTestAny(vx, vy);
      this._connHoverShapeId = hit && hit.tool !== 'connector' ? hit.id : null;
      const snapEndRaw = this._findNearestAnchor(vx, vy, this._connSnapStart?.strokeId);
      this._connSnapEnd = snapEndRaw ? { strokeId: snapEndRaw.stroke.id, anchor: snapEndRaw.anchor, x: snapEndRaw.x, y: snapEndRaw.y } : null;
      if (this._connSnapEnd && this._drawing) {
        this._shapeEnd = [this._connSnapEnd.x, this._connSnapEnd.y];
        this.render();
        return;
      }
    }

    if (!this._drawing) return;
    if (this._isShapeTool() || this._tool === 'connector' || this._tool === 'table') {
      let [ex, ey] = [vx, vy];
      // Shift: proportional constraint for shapes (square/circle)
      if (e.shiftKey && this._isShapeTool() && this._shapeStart) {
        const [sx, sy] = this._shapeStart;
        const dxv = ex - sx, dyv = ey - sy;
        const side = Math.max(Math.abs(dxv), Math.abs(dyv));
        ex = sx + (dxv >= 0 ? side : -side);
        ey = sy + (dyv >= 0 ? side : -side);
      }
      this._shapeEnd = [ex, ey];
      this.render();
    } else {
      // Shift: straight line (snapped to 0°/45°/90°) for pencil and highlighter
      if (e.shiftKey && this._curPts.length >= 1 &&
          (this._tool === 'pencil' || this._tool === 'highlighter')) {
        const [sx, sy] = this._curPts[0];
        const dx = vx - sx, dy = vy - sy;
        const angle   = Math.atan2(dy, dx);
        const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4);
        const dist    = Math.hypot(dx, dy);
        this._curPts = [this._curPts[0], [sx + Math.cos(snapped) * dist, sy + Math.sin(snapped) * dist]];
      } else {
        this._curPts.push([vx, vy]);
      }
      this.render();
    }
    if (this._onStrokeProgress && !this._progressTimer) {
      this._progressTimer = setTimeout(() => {
        this._progressTimer = null;
        this._flushProgress();
      }, 20);
    }
  }

  _onWheel(e) {
    e.preventDefault();
    const rect = this._canvas.getBoundingClientRect();
    const cx   = e.clientX - rect.left;
    const cy   = e.clientY - rect.top;

    // Ctrl+wheel or pinch-zoom (ctrlKey set by browser on pinch)
    if (e.ctrlKey) {
      const [vx, vy] = this._toVirtual(cx, cy);
      const factor   = e.deltaY < 0 ? 1.15 : 1 / 1.15;
      this.zoomTo(this._zoom * factor, vx, vy);
      return;
    }

    // Shift+wheel → horizontal pan
    if (e.shiftKey) {
      const sx = (this._cssW || 300) / Whiteboard.VW;
      this._panVX += e.deltaY / (sx * this._zoom);
      this._clampPan(); this._staticDirty = true; this.render();
      return;
    }

    // Two-finger trackpad scroll (deltaMode=0, both axes) → pan
    if (e.deltaMode === 0 && Math.abs(e.deltaX) > 2) {
      const sx = (this._cssW || 300) / Whiteboard.VW;
      const sy = (this._cssH || 150) / Whiteboard.VH;
      this._panVX += e.deltaX / (sx * this._zoom);
      this._panVY += e.deltaY / (sy * this._zoom);
      this._clampPan(); this._staticDirty = true; this.render();
      return;
    }

    // Regular wheel → zoom
    const [vx, vy] = this._toVirtual(cx, cy);
    const factor   = e.deltaY < 0 ? 1.15 : 1 / 1.15;
    this.zoomTo(this._zoom * factor, vx, vy);
  }

  zoomTo(newZoom, pivotVX, pivotVY) {
    const sx = (this._cssW || 300) / Whiteboard.VW;
    const sy = (this._cssH || 150) / Whiteboard.VH;
    // Pivot in CSS before zoom change
    const pcx = pivotVX != null ? (pivotVX - this._panVX) * sx * this._zoom : (this._cssW || 300) / 2;
    const pcy = pivotVY != null ? (pivotVY - this._panVY) * sy * this._zoom : (this._cssH || 150) / 2;
    this._zoom = Math.max(0.25, Math.min(8, newZoom));
    if (pivotVX != null) {
      this._panVX = pivotVX - pcx / (sx * this._zoom);
      this._panVY = pivotVY - pcy / (sy * this._zoom);
    }
    this._clampPan();
    this._staticDirty = true;
    this.render();
    if (this._onZoomChange) this._onZoomChange(this._zoom);
  }

  resetView() {
    this._zoom  = 1;
    this._panVX = 0;
    this._panVY = 0;
    this._staticDirty = true;
    this.render();
    if (this._onZoomChange) this._onZoomChange(1);
  }

  /* Zoom to fit all visible strokes, with optional padding in virtual px.
     Falls back to resetView() when there are no strokes. */
  zoomFitStrokes(padding = 80) {
    const VW = Whiteboard.VW, VH = Whiteboard.VH;
    if (!this._strokes.length) { this.resetView(); return; }

    let minX = VW, minY = VH, maxX = 0, maxY = 0;
    for (const s of this._strokes) {
      const b = this._getStrokeBBox(s);
      if (b.w === VW && b.h === VH) continue; // skip fallback bbox (unknown type)
      minX = Math.min(minX, b.x);
      minY = Math.min(minY, b.y);
      maxX = Math.max(maxX, b.x + b.w);
      maxY = Math.max(maxY, b.y + b.h);
    }
    if (maxX <= minX || maxY <= minY) { this.resetView(); return; }

    // Add padding, clamp to board bounds
    minX = Math.max(0, minX - padding);
    minY = Math.max(0, minY - padding);
    maxX = Math.min(VW, maxX + padding);
    maxY = Math.min(VH, maxY + padding);

    const contentW = maxX - minX;
    const contentH = maxY - minY;

    // zoom so the content bbox fills the canvas (pick limiting axis)
    const zoom = Math.min(VW / contentW, VH / contentH, 8);
    this._zoom  = Math.max(0.25, zoom);

    // center the content
    this._panVX = (minX + maxX) / 2 - VW / (2 * this._zoom);
    this._panVY = (minY + maxY) / 2 - VH / (2 * this._zoom);
    this._clampPan();
    this._staticDirty = true;
    this.render();
    if (this._onZoomChange) this._onZoomChange(this._zoom);
  }

  _clampPan() {
    const VW = Whiteboard.VW, VH = Whiteboard.VH;
    const visW = VW / this._zoom;
    const visH = VH / this._zoom;
    if (visW <= VW) {
      this._panVX = Math.max(0, Math.min(this._panVX, VW - visW));
    } else {
      this._panVX = (VW - visW) / 2;
    }
    if (visH <= VH) {
      this._panVY = Math.max(0, Math.min(this._panVY, VH - visH));
    } else {
      this._panVY = (VH - visH) / 2;
    }
  }

  _applyResize(data, ds, dvx, dvy) {
    const h = ds.handle;
    if (h === 'br') {
      data.w = Math.max(20, ds.origW + dvx);
      data.h = Math.max(20, ds.origH + dvy);
    } else if (h === 'tr') {
      data.w = Math.max(20, ds.origW + dvx);
      const nh = Math.max(20, ds.origH - dvy);
      data.y = ds.origY + (ds.origH - nh);
      data.h = nh;
    } else if (h === 'bl') {
      const nw = Math.max(20, ds.origW - dvx);
      data.x = ds.origX + (ds.origW - nw);
      data.w = nw;
      data.h = Math.max(20, ds.origH + dvy);
    } else if (h === 'tl') {
      const nw = Math.max(20, ds.origW - dvx);
      const nh = Math.max(20, ds.origH - dvy);
      data.x = ds.origX + (ds.origW - nw);
      data.y = ds.origY + (ds.origH - nh);
      data.w = nw;
      data.h = nh;
    }
  }

  _flushProgress() {
    if (!this._drawing || !this._onStrokeProgress || !this._liveId) return;
    let data;
    if (this._isShapeTool()) {
      if (!this._shapeStart || !this._shapeEnd) return;
      data = { shape: this._tool, x1: this._shapeStart[0], y1: this._shapeStart[1],
               x2: this._shapeEnd[0], y2: this._shapeEnd[1],
               color: this._color, width: this._w, fill: this._fill };
    } else if (this._tool === 'connector') {
      if (!this._shapeStart || !this._shapeEnd) return;
      data = { x1: this._shapeStart[0], y1: this._shapeStart[1],
               x2: this._shapeEnd[0], y2: this._shapeEnd[1],
               color: this._color, width: this._w, arrowEnd: true, arrowStart: false };
    } else {
      if (this._curPts.length === 0) return;
      data = { points: [...this._curPts],
               color: this._tool === 'eraser' ? null : this._color, width: this._w };
    }
    this._onStrokeProgress({
      liveId: this._liveId,
      tool:   this._isShapeTool() ? 'shape' : this._tool,
      data,
    });
  }

  _onPointerUp(_e) {
    // ── pan end ───────────────────────────────────────────────────────────
    if (this._panStartCss) {
      this._panStartCss = null;
      this._panStartPan = null;
      this._canvas.style.cursor = this._spaceDown ? 'grab' : '';
      return;
    }

    // ── overlay drag end ──────────────────────────────────────────────────
    if (this._overlayDrag) {
      const ov = this._overlays[this._overlayDrag.idx];
      this._overlayDrag = null;
      if (ov && this._onOverlayChange) this._onOverlayChange(ov);
      return;
    }

    // ── compass: phase transitions ────────────────────────────────────────
    if (this._tool === 'compass') {
      if (this._compassState === 'setting-radius') {
        if (this._compassRadius < 15) {
          // Too small: cancel
          this._compassState = 'idle'; this._drawing = false;
        } else {
          this._compassState = 'waiting-arc';
          // Stay in "drawing" state so the preview stays visible
        }
        this.render();
      } else if (this._compassState === 'drawing-arc') {
        if (Math.abs(this._compassArcSweep) > 0.05) {
          const stroke = {
            id: this._localIdCounter--, tool: 'compass',
            data: {
              cx: this._compassCenter.x, cy: this._compassCenter.y,
              radius: Math.max(1, Math.round(this._compassRadius)),
              arcStart: this._compassArcStart,
              arcSweep: this._compassArcSweep,
              color:    this._color,
              lineWidth: this._width,
              showLegs: true,
            },
          };
          this._strokes.push(stroke);
          this._undoStack.push(stroke.id);
          this._redoStack = [];
          this._staticDirty = true;
          if (this._onStrokeDone)    this._onStrokeDone(stroke);
          if (this._onObjectCreated) this._onObjectCreated(stroke);
        }
        this._compassState = 'idle'; this._drawing = false;
        this.render();
      }
      return;
    }

    // ── laser pointer: cancel preview ─────────────────────────────────────
    if (this._tool === 'laser') {
      this._drawing    = false;
      this._laserPos   = null;
      this._laserTrail = [];
      const liveId     = this._liveId;
      this._liveId   = null;
      if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true });
      this.render();
      return;
    }

    // ── select tool ──────────────────────────────────────────────────────
    if (this._tool === 'select') {
      // Finish lasso selection
      if (this._lassoRect) {
        const lr = this._lassoRect;
        const lx1 = Math.min(lr.x1, lr.x2), lx2 = Math.max(lr.x1, lr.x2);
        const ly1 = Math.min(lr.y1, lr.y2), ly2 = Math.max(lr.y1, lr.y2);
        if (lx2 - lx1 > 4 || ly2 - ly1 > 4) {
          for (const s of this._strokes) {
            const b = this._getStrokeBBox(s);
            if (b.x < lx2 && b.x + b.w > lx1 && b.y < ly2 && b.y + b.h > ly1)
              this._selectedIds.add(s.id);
          }
        }
        this._lassoRect = null;
        this.render();
        return;
      }
      // Mindmap node drag end
      if (this._mmNodeDragStart) {
        const mm = this._editingMindmapStroke;
        if (mm && this._onStrokeUpdated) this._onStrokeUpdated(mm);
        this._mmNodeDragStart = null;
        this.render();
        return;
      }
      if (this._dragState) {
        this._snapGuides = [];
        // Notify server of updated positions for all moved strokes
        for (const id of this._selectedIds) {
          const sel = this._strokes.find(s => s.id === id);
          if (sel && this._onStrokeUpdated) this._onStrokeUpdated(sel);
        }
        this._dragState = null;
        this.render();
      }
      return;
    }

    if (!this._drawing) return;
    this._drawing = false;
    if (this._progressTimer) { clearTimeout(this._progressTimer); this._progressTimer = null; }
    const liveId = this._liveId;
    this._liveId = null;

    // ── table tool ────────────────────────────────────────────────────────
    if (this._tool === 'table') {
      const [x1, y1] = this._shapeStart;
      const [x2, y2] = this._shapeEnd || this._shapeStart;
      let vx = Math.min(x1, x2), vy = Math.min(y1, y2);
      let vw = Math.abs(x2 - x1), vh = Math.abs(y2 - y1);
      if (vw < 40) vw = 360;
      if (vh < 40) vh = 240;
      vx = Math.min(vx, Whiteboard.VW - vw);
      vy = Math.min(vy, Whiteboard.VH - vh);
      const tR = this._tableRows || 3, tC = this._tableCols || 4;
      const stroke = {
        id: this._localIdCounter--, tool: 'table',
        data: {
          x: Math.max(0, vx), y: Math.max(0, vy), w: vw, h: vh,
          rows: tR, cols: tC,
          cells: Array.from({ length: tR }, () => Array(tC).fill('')),
          borderColor: '#9B5DE5',
          bgColor:     'rgba(26,22,37,0.85)',
          textColor:   '#e8e0f7',
          fontSize:    14,
        },
      };
      this._shapeStart = null; this._shapeEnd = null;
      this._strokes.push(stroke);
      this._undoStack.push(stroke.id);
      this._redoStack = [];
      this._selectedId = stroke.id;
      this._staticDirty = true;
      this.render();
      if (this._onStrokeDone) this._onStrokeDone(stroke);
      return;
    }

    // ── connector tool ────────────────────────────────────────────────────
    if (this._tool === 'connector') {
      let [x1, y1] = this._shapeStart;
      let [x2, y2] = this._shapeEnd || this._shapeStart;
      if (this._connSnapStart) { x1 = this._connSnapStart.x; y1 = this._connSnapStart.y; }
      if (this._connSnapEnd)   { x2 = this._connSnapEnd.x;   y2 = this._connSnapEnd.y; }
      if (Math.abs(x2 - x1) < 5 && Math.abs(y2 - y1) < 5) {
        if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true });
        this._connSnapStart = null; this._connSnapEnd = null;
        return;
      }
      const isCurved = !!(this._connSnapStart || this._connSnapEnd);
      const data = { x1, y1, x2, y2, color: this._color, width: this._width,
        arrowEnd: true, arrowStart: false,
        lineStyle: this._lineStyle, opacity: this._opacity,
        connStyle: isCurved ? 'curved' : 'straight',
      };
      if (this._connSnapStart) { data.fromId = this._connSnapStart.strokeId; data.fromAnchor = this._connSnapStart.anchor; }
      if (this._connSnapEnd)   { data.toId   = this._connSnapEnd.strokeId;   data.toAnchor   = this._connSnapEnd.anchor; }
      const stroke = {
        id: this._localIdCounter--, tool: 'connector',
        data,
      };
      this._shapeStart = null; this._shapeEnd = null;
      this._connSnapStart = null; this._connSnapEnd = null; this._connHoverShapeId = null;
      this._strokes.push(stroke);
      this._undoStack.push(stroke.id);
      this._redoStack = [];
      this._staticDirty = true;
      this.render();
      if (this._onStrokeDone) this._onStrokeDone(stroke);
      return;
    }

    // ── shape tools ────────────────────────────────────────────────────────
    if (this._isShapeTool()) {
      const [x1, y1] = this._shapeStart;
      const [x2, y2] = this._shapeEnd || this._shapeStart;
      if (Math.abs(x2 - x1) < 2 && Math.abs(y2 - y1) < 2) {
        if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true });
        return;
      }
      const stroke = {
        id: this._localIdCounter--, tool: 'shape',
        data: { shape: this._tool, x1, y1, x2, y2,
                color: this._color, width: this._w, fill: this._fill,
                lineStyle: this._lineStyle, opacity: this._opacity },
      };
      this._shapeStart = null; this._shapeEnd = null;
      this._strokes.push(stroke);
      this._undoStack.push(stroke.id);
      this._redoStack = [];
      this._staticDirty = true;
      this.render();
      if (this._onStrokeDone) this._onStrokeDone(stroke);
      return;
    }

    // ── pencil / eraser ────────────────────────────────────────────────────
    if (this._curPts.length === 0) {
      if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true });
      return;
    }
    const stroke = {
      id: this._localIdCounter--, tool: this._tool,
      data: { points: this._curPts,
              color: this._tool === 'eraser' ? null : this._color, width: this._w,
              lineStyle: this._lineStyle, opacity: this._opacity },
    };
    this._curPts = [];
    this._strokes.push(stroke);
    this._undoStack.push(stroke.id);
    this._redoStack = [];
    this._staticDirty = true;
    this.render();
    if (this._onStrokeDone) this._onStrokeDone(stroke);
  }

  /* ── text tool ──────────────────────────────────────────────────────── */

  _placeTextInput([vx, vy]) {
    const wrap = this._canvas.parentElement;
    if (!wrap) return;
    this._removeTextInput();
    const cw = this._cssW || 300;
    const ch = this._cssH || 150;
    const [cx, cy] = this._toCanvas(vx, vy);

    // Position textarea accounting for canvas offset within the parent wrapper
    const canvasRect = this._canvas.getBoundingClientRect();
    const wrapRect   = wrap.getBoundingClientRect();
    const offX = canvasRect.left - wrapRect.left;
    const offY = canvasRect.top  - wrapRect.top;

    const fs   = Math.max(10, Math.round((this._textFontSize / Whiteboard.VH) * ch));
    const W    = Math.max(120, Math.min(300, cw - 16));
    const left = Math.min(cx + offX, offX + cw - W - 4);
    const top  = Math.max(offY, Math.min(cy + offY, offY + ch - 110));
    const fw   = this._textBold   ? 'bold'   : 'normal';
    const fi   = this._textItalic ? 'italic' : 'normal';

    const ta = document.createElement('textarea');
    ta.placeholder = 'Введите текст… (Enter — вставить, Esc — отмена)';
    ta.rows = 3;
    ta.style.cssText = `
      position:absolute; left:${left}px; top:${top}px;
      width:${W}px; min-height:68px; box-sizing:border-box;
      font-size:${fs}px; font-family:'${this._textFontFamily}',sans-serif;
      font-weight:${fw}; font-style:${fi};
      color:${this._color}; text-align:${this._textAlign};
      background:rgba(12,8,24,0.92);
      border:1.5px solid rgba(155,93,229,0.7);
      border-radius:8px; outline:none; resize:none;
      padding:6px 10px; caret-color:${this._color};
      z-index:20; line-height:1.45;
      box-shadow:0 4px 24px rgba(0,0,0,0.6);
    `;
    wrap.appendChild(ta);
    this._textInput = ta;

    const commit = () => {
      const text = ta.value.trim();
      this._removeTextInput();
      if (!text) return;
      const stroke = {
        id: this._localIdCounter--, tool: 'text',
        data: { text, x: vx, y: vy,
          fontSize:   this._textFontSize,
          fontFamily: this._textFontFamily,
          fontWeight: this._textBold   ? 'bold'   : 'normal',
          fontStyle:  this._textItalic ? 'italic' : 'normal',
          textAlign:  this._textAlign,
          color: this._color },
      };
      this._strokes.push(stroke);
      this._undoStack.push(stroke.id);
      this._redoStack = [];
      this._staticDirty = true;
      this.render();
      if (this._onStrokeDone)    this._onStrokeDone(stroke);
      if (this._onObjectCreated) this._onObjectCreated(stroke);
    };

    ta.addEventListener('keydown', ev => {
      ev.stopPropagation(); // don't leak to canvas key handler
      if (ev.key === 'Escape') { ev.preventDefault(); this._removeTextInput(); }
      // Enter without Shift commits; Shift+Enter inserts newline
      if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); commit(); }
    });

    // Outside-click detection: use capture-phase pointerdown on document.
    // Registered via setTimeout to skip the current pointerdown that spawned this textarea.
    const onDocDown = e => {
      if (ta.contains(e.target) || e.target === ta) return;
      this._textInputDocHandler = null;
      document.removeEventListener('pointerdown', onDocDown, true);
      commit();
    };
    this._textInputDocHandler = onDocDown;
    setTimeout(() => {
      if (ta.isConnected) document.addEventListener('pointerdown', onDocDown, true);
    }, 0);

    // Focus after the current event cycle so pointer events on the canvas
    // can't steal focus back immediately.
    requestAnimationFrame(() => { if (ta.isConnected) ta.focus(); });
  }

  _removeTextInput() {
    if (this._textInputDocHandler) {
      document.removeEventListener('pointerdown', this._textInputDocHandler, true);
      this._textInputDocHandler = null;
    }
    if (this._textInput) { this._textInput.remove(); this._textInput = null; }
  }

  /* ── sticky tool ────────────────────────────────────────────────────── */

  _createSticky(vx, vy) {
    const W = 220, H = 200;
    const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W));
    const y = Math.max(0, Math.min(vy - 20, Whiteboard.VH - H));
    const bgColors = ['#FFE066', '#FF9F7F', '#B5EAD7', '#C7CEEA', '#FFDAC1', '#E2B7F5'];
    const bgColor  = this._stickyColor || bgColors[Math.floor(Math.random() * bgColors.length)];
    const stroke = {
      id: this._localIdCounter--, tool: 'sticky',
      data: { x, y, w: W, h: H, text: '', bgColor, textColor: '#1a1a2e', fontSize: 16 },
    };
    this._strokes.push(stroke);
    this._undoStack.push(stroke.id);
    this._redoStack = [];
    this._selectedId = stroke.id;
    this._staticDirty = true;
    this.render();
    if (this._onStrokeDone)    this._onStrokeDone(stroke);
    if (this._onObjectCreated) this._onObjectCreated(stroke);
    this._editSticky(stroke);
  }

  _editSticky(stroke) {
    const wrap = this._canvas.parentElement;
    if (!wrap) return;
    this._removeObjectInput();
    const d = stroke.data;
    const [cx, cy] = this._toCanvas(d.x, d.y);
    const cw = (d.w / Whiteboard.VW) * this._cssW;
    const ch = (d.h / Whiteboard.VH) * this._cssH;
    const pad = 10;
    const fs  = Math.round((d.fontSize / Whiteboard.VH) * this._cssH);
    const ta  = document.createElement('textarea');
    ta.value = d.text || '';
    ta.style.cssText = `
      position:absolute; left:${cx + pad}px; top:${cy + pad}px;
      width:${cw - 2 * pad}px; height:${ch - 2 * pad}px;
      font-size:${fs}px; font-family:'Manrope',sans-serif;
      color:${d.textColor}; background:transparent;
      border:none; outline:none; resize:none;
      padding:0; line-height:1.4; caret-color:${d.textColor}; z-index:10;
    `;
    wrap.style.position = 'relative';
    wrap.appendChild(ta);
    ta.focus();
    this._objectInput = { el: ta, strokeId: stroke.id };

    const commit = () => {
      const text = ta.value;
      this._removeObjectInput();
      const s = this._strokes.find(x => x.id === stroke.id);
      if (!s) return;
      s.data.text = text;
      this._staticDirty = true;
      this.render();
      if (this._onStrokeUpdated) this._onStrokeUpdated(s);
    };
    ta.addEventListener('keydown', ev => { if (ev.key === 'Escape') commit(); });
    ta.addEventListener('blur', () => setTimeout(commit, 80));
  }

  /* ── formula tool ───────────────────────────────────────────────────── */

  _placeFormula(vx, vy) {
    // If an external formula editor is wired up, delegate to it
    if (this._onFormulaInsert) {
      this._onFormulaInsert(vx, vy);
      return;
    }
    // Fallback: inline input (minimal, shown when no modal is wired)
    const wrap = this._canvas.parentElement;
    if (!wrap) return;
    this._removeObjectInput();
    const [cx, cy] = this._toCanvas(vx, vy);
    const inp = document.createElement('input');
    inp.type = 'text';
    inp.placeholder = 'LaTeX, напр: \\frac{a}{b}';
    inp.style.cssText = `
      position:absolute; left:${Math.min(cx, (this._cssW||300)-280)}px; top:${cy}px;
      font-size:14px; font-family:'Manrope',sans-serif;
      color:#e8e0f7; background:rgba(12,8,24,0.95);
      border:1.5px solid #9B5DE5; border-radius:6px;
      outline:none; width:270px; padding:7px 10px;
      caret-color:#9B5DE5; z-index:20;
      box-shadow:0 4px 20px rgba(0,0,0,0.55);
    `;
    wrap.appendChild(inp);
    inp.focus();
    this._objectInput = { el: inp, strokeId: null };

    const commit = () => {
      const latex = inp.value.trim();
      this._removeObjectInput();
      if (!latex) return;
      this.insertFormula(vx, vy, latex);
    };
    inp.addEventListener('keydown', ev => {
      if (ev.key === 'Enter')  { ev.preventDefault(); commit(); }
      if (ev.key === 'Escape') { ev.stopPropagation(); this._removeObjectInput(); }
    });
    inp.addEventListener('blur', () => setTimeout(commit, 100));
  }

  _editFormula(stroke) {
    // If external editor is wired, use it for edit too
    if (this._onFormulaInsert) {
      const d = stroke.data;
      // We pass the stroke ID so the modal can update instead of insert
      this._editingFormulaStroke = stroke;
      this._onFormulaInsert(d.x + d.w / 2, d.y + d.h / 2, d.latex);
      return;
    }
    // Fallback: inline input
    const wrap = this._canvas.parentElement;
    if (!wrap) return;
    this._removeObjectInput();
    const d = stroke.data;
    const [cx, cy] = this._toCanvas(d.x, d.y);
    const inp = document.createElement('input');
    inp.type = 'text';
    inp.value = d.latex || '';
    inp.placeholder = 'LaTeX формула';
    inp.style.cssText = `
      position:absolute; left:${Math.min(cx, (this._cssW||300)-280)}px; top:${cy}px;
      font-size:14px; font-family:'Manrope',sans-serif;
      color:#e8e0f7; background:rgba(12,8,24,0.95);
      border:1.5px solid #9B5DE5; border-radius:6px;
      outline:none; width:270px; padding:7px 10px;
      caret-color:#9B5DE5; z-index:20;
      box-shadow:0 4px 20px rgba(0,0,0,0.55);
    `;
    wrap.appendChild(inp);
    inp.focus();
    inp.select();
    this._objectInput = { el: inp, strokeId: stroke.id };

    const commit = () => {
      const latex = inp.value.trim();
      this._removeObjectInput();
      const s = this._strokes.find(x => x.id === stroke.id);
      if (!s || !latex) return;
      s.data.latex = latex;
      s._formulaImg = null;
      this._staticDirty = true;
      this.render();
      if (this._onStrokeUpdated) this._onStrokeUpdated(s);
    };
    inp.addEventListener('keydown', ev => {
      if (ev.key === 'Enter')  { ev.preventDefault(); commit(); }
      if (ev.key === 'Escape') { ev.stopPropagation(); this._removeObjectInput(); }
    });
    inp.addEventListener('blur', () => setTimeout(commit, 100));
  }

  /* ── table tool ─────────────────────────────────────────────────────── */

  _getTableCell(stroke, vx, vy) {
    const d = stroke.data;
    if (vx < d.x || vx > d.x + d.w || vy < d.y || vy > d.y + d.h) return null;
    return {
      row: Math.min(Math.floor((vy - d.y) / (d.h / d.rows)), d.rows - 1),
      col: Math.min(Math.floor((vx - d.x) / (d.w / d.cols)), d.cols - 1),
    };
  }

  _editTableCell(stroke, row, col) {
    const wrap = this._canvas.parentElement;
    if (!wrap) return;
    this._removeObjectInput();
    const d = stroke.data;
    const cellVW = d.w / d.cols;
    const cellVH = d.h / d.rows;
    const [cx, cy] = this._toCanvas(d.x + col * cellVW, d.y + row * cellVH);
    const cw = (cellVW / Whiteboard.VW) * this._cssW;
    const ch = (cellVH / Whiteboard.VH) * this._cssH;
    const pad = 4;
    const fs  = Math.round(((d.fontSize || 14) / Whiteboard.VH) * this._cssH);
    const inp = document.createElement('input');
    inp.type = 'text';
    inp.value = (d.cells[row] && d.cells[row][col]) || '';
    inp.style.cssText = `
      position:absolute; left:${cx + pad}px; top:${cy + pad}px;
      width:${cw - 2 * pad}px; height:${ch - 2 * pad}px;
      font-size:${fs}px; font-family:'Manrope',sans-serif;
      color:${d.textColor || '#e8e0f7'};
      background:rgba(155,93,229,0.12);
      border:1.5px solid #9B5DE5; border-radius:3px;
      outline:none; padding:0 4px; z-index:10; box-sizing:border-box;
    `;
    wrap.style.position = 'relative';
    wrap.appendChild(inp);
    inp.focus();
    inp.select();
    this._objectInput = { el: inp, strokeId: stroke.id };

    const commit = () => {
      const val = inp.value;
      this._removeObjectInput();
      const s = this._strokes.find(x => x.id === stroke.id);
      if (!s) return;
      if (!s.data.cells[row]) s.data.cells[row] = [];
      s.data.cells[row][col] = val;
      this._staticDirty = true;
      this.render();
      if (this._onStrokeUpdated) this._onStrokeUpdated(s);
    };
    inp.addEventListener('keydown', ev => {
      if (ev.key === 'Enter' || ev.key === 'Tab') { ev.preventDefault(); commit(); }
      if (ev.key === 'Escape') { this._removeObjectInput(); }
    });
    inp.addEventListener('blur', () => setTimeout(commit, 80));
  }

  _removeObjectInput() {
    if (this._objectInput) { this._objectInput.el.remove(); this._objectInput = null; }
  }

  /* ── edit dispatch ──────────────────────────────────────────────────── */

  _editObject(stroke, vx, vy) {
    if (stroke.tool === 'mindmap') { this._enterMindmapEdit(stroke, vx, vy); return; }
    if (stroke.tool === 'coordinate') { if (this._onCoordEdit) this._onCoordEdit(stroke); return; }
    if (stroke.tool === 'numberline') { if (this._onNumberLineEdit) this._onNumberLineEdit(stroke); return; }
    if (stroke.tool === 'compass')    { if (this._onCompassEdit)    this._onCompassEdit(stroke);    return; }
    if (stroke.tool === 'shape')   { this._editShapeText(stroke); return; }
    if (stroke.tool === 'sticky')  { this._editSticky(stroke); return; }
    if (stroke.tool === 'formula') { this._editFormula(stroke); return; }
    if (stroke.tool === 'table') {
      const cell = this._getTableCell(stroke, vx, vy);
      if (cell) this._editTableCell(stroke, cell.row, cell.col);
      return;
    }
    if (stroke.tool === 'text') {
      const d = stroke.data;
      this._strokes = this._strokes.filter(s => s.id !== stroke.id);
      const i = this._undoStack.indexOf(stroke.id);
      if (i !== -1) this._undoStack.splice(i, 1);
      this._selectedId = null;
      this._staticDirty = true;
      this.render();
      this._placeTextInput([d.x, d.y]);
      if (this._textInput) this._textInput.value = d.text || '';
    }
    if (stroke.tool === 'image') {
      // images are not editable inline
    }
  }

  _editShapeText(stroke) {
    const d = stroke.data;
    if (d.shape === 'line' || d.shape === 'arrow') return;
    const wrap = this._canvas.parentElement;
    if (!wrap) return;
    this._removeObjectInput();

    const canvasRect = this._canvas.getBoundingClientRect();
    const wrapRect   = wrap.getBoundingClientRect();
    const offX = canvasRect.left - wrapRect.left;
    const offY = canvasRect.top  - wrapRect.top;

    const [ccx1, ccy1] = this._toCanvas(Math.min(d.x1, d.x2), Math.min(d.y1, d.y2));
    const [ccx2, ccy2] = this._toCanvas(Math.max(d.x1, d.x2), Math.max(d.y1, d.y2));
    const cw = ccx2 - ccx1, ch = ccy2 - ccy1;
    const pad = Math.max(8, cw * 0.06);
    const tcol = d.textColor || '#ffffff';

    const ta = document.createElement('textarea');
    ta.value       = d.text || '';
    ta.placeholder = 'Текст…';
    ta.style.cssText = [
      'position:absolute;',
      `left:${offX + ccx1 + pad}px; top:${offY + ccy1 + pad}px;`,
      `width:${Math.max(60, cw - pad * 2)}px;`,
      `height:${Math.max(36, ch - pad * 2)}px;`,
      'background:rgba(0,0,0,0.18); border:1px dashed rgba(255,255,255,0.45);',
      'border-radius:4px; outline:none; resize:none; overflow:hidden;',
      `padding:4px 6px; color:${tcol}; caret-color:${tcol};`,
      'font:bold 14px Manrope,sans-serif; text-align:center;',
      'z-index:20; line-height:1.4;',
    ].join('');

    wrap.appendChild(ta);
    ta.focus(); ta.select();
    this._objectInput = ta;

    const commit = () => {
      d.text = ta.value.trim() || null;
      if (this._objectInput === ta) { ta.remove(); this._objectInput = null; }
      this._staticDirty = true;
      this.render();
      if (this._onStrokeUpdated) this._onStrokeUpdated(stroke);
    };

    ta.addEventListener('blur', commit);
    ta.addEventListener('keydown', e => {
      if (e.key === 'Escape') { ta.value = d.text || ''; commit(); }
      if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commit(); }
      e.stopPropagation();
    });
  }

  /* ── live stroke API (remote preview) ──────────────────────────────── */

  // TTL for live (preview) strokes — if remote user crashes mid-stroke, ghost preview disappears.
  // A normal stroke gets refreshed every ~50ms, so 1500ms is generous.
  _LIVE_STROKE_TTL_MS = 1500;

  setLiveStroke(liveId, tool, data, userName, color) {
    const prev = this._liveStrokes.get(liveId);
    if (prev?._ttlTimer) clearTimeout(prev._ttlTimer);
    const _ttlTimer = setTimeout(() => {
      if (this._liveStrokes.delete(liveId)) this.render();
    }, this._LIVE_STROKE_TTL_MS);
    this._liveStrokes.set(liveId, { tool, data, userName, color, _ttlTimer });
    this.render();
  }

  removeLiveStroke(liveId) {
    const cur = this._liveStrokes.get(liveId);
    if (cur?._ttlTimer) clearTimeout(cur._ttlTimer);
    if (this._liveStrokes.delete(liveId)) this.render();
  }

  clearAllLiveStrokes() {
    if (this._liveStrokes.size === 0) return;
    for (const [, ls] of this._liveStrokes) {
      if (ls?._ttlTimer) clearTimeout(ls._ttlTimer);
    }
    this._liveStrokes.clear();
    this.render();
  }

  /* ── render ─────────────────────────────────────────────────────────── */

  /* ── chalkboard background ──────────────────────────────────────────── */

  // Returns a noise HTMLCanvasElement for the given theme (cached)
  _getBgNoiseCanvas(theme) {
    if (this._bgNoiseCache.has(theme)) return this._bgNoiseCache.get(theme);
    try {
      const SIZE = 256;
      const oc   = document.createElement('canvas');
      oc.width = oc.height = SIZE;
      const oc2 = oc.getContext('2d');
      const cfg = {
        chalkboard: { bg: '#213d26', delta: 22, gBias: 4, smear: 'horizontal', smearAlpha: 0.05, smearColor: '#ffffff', smearCount: 6 },
        blackboard: { bg: '#1a1a2e', delta: 18, gBias: 0, smear: 'diagonal',   smearAlpha: 0.03, smearColor: '#a0a0b8', smearCount: 4 },
        corkboard:  { bg: '#7a5c1e', delta: 25, gBias: 0, smear: 'diagonal',   smearAlpha: 0.04, smearColor: '#c8a050', smearCount: 5 },
        whiteboard: { bg: '#f0f0f0', delta:  5, gBias: 0, smear: 'none',       smearAlpha: 0.02, smearColor: '#cccccc', smearCount: 3 },
      }[theme] || { bg: '#213d26', delta: 22, gBias: 4, smear: 'horizontal', smearAlpha: 0.05, smearColor: '#ffffff', smearCount: 6 };
      oc2.fillStyle = cfg.bg;
      oc2.fillRect(0, 0, SIZE, SIZE);
      const img = oc2.getImageData(0, 0, SIZE, SIZE);
      const dd  = img.data;
      for (let i = 0; i < dd.length; i += 4) {
        const n = (Math.random() - 0.5) * cfg.delta;
        dd[i]     = Math.max(0, Math.min(255, dd[i]     + n));
        dd[i + 1] = Math.max(0, Math.min(255, dd[i + 1] + n + cfg.gBias));
        dd[i + 2] = Math.max(0, Math.min(255, dd[i + 2] + n));
      }
      oc2.putImageData(img, 0, 0);
      if (cfg.smear !== 'none') {
        oc2.globalAlpha  = cfg.smearAlpha;
        oc2.strokeStyle  = cfg.smearColor;
        oc2.lineWidth    = 1;
        for (let i = 0; i < cfg.smearCount; i++) {
          const x0 = Math.random() * SIZE, y0 = Math.random() * SIZE;
          oc2.beginPath();
          if (cfg.smear === 'horizontal') {
            oc2.moveTo(0, y0); oc2.lineTo(SIZE, y0 + (Math.random() - 0.5) * 6);
          } else {
            const angle = Math.random() * Math.PI;
            oc2.moveTo(x0, y0);
            oc2.lineTo(x0 + Math.cos(angle) * SIZE * 0.4, y0 + Math.sin(angle) * SIZE * 0.4);
          }
          oc2.stroke();
        }
      }
      // Cork knot spots for corkboard
      if (theme === 'corkboard') {
        for (let k = 0; k < 10; k++) {
          const kx = Math.random() * SIZE, ky = Math.random() * SIZE;
          const kr = 2 + Math.random() * 3;
          oc2.globalAlpha = 0.06;
          oc2.fillStyle = '#4a3010';
          oc2.beginPath(); oc2.arc(kx, ky, kr, 0, Math.PI * 2); oc2.fill();
        }
      }
      this._bgNoiseCache.set(theme, oc);
    } catch { this._bgNoiseCache.set(theme, null); }
    return this._bgNoiseCache.get(theme);
  }

  setAnnotateMode(v) {
    this._annotateMode = !!v;
    this.render();
  }

  _renderBg(ctx) {
    const W = this._cssW || 300;
    const H = this._cssH || 150;
    ctx.clearRect(0, 0, W, H);
    if (this._annotateMode) return; // transparent bg — simulation shows through
    const theme = this._boardTheme || 'chalkboard';

    // Base background
    const bgColors = {
      chalkboard: '#213d26',
      blackboard: '#1a1a2e',
      corkboard:  '#7a5c1e',
      whiteboard: null, // uses gradient
    };
    if (theme === 'whiteboard') {
      const wg = ctx.createLinearGradient(0, 0, 0, H);
      wg.addColorStop(0, '#f6f6f6'); wg.addColorStop(1, '#e8e8e8');
      ctx.fillStyle = wg;
    } else {
      ctx.fillStyle = bgColors[theme] || '#213d26';
    }
    ctx.fillRect(0, 0, W, H);

    // Noise texture (overlay composite)
    const noiseCanvas = this._getBgNoiseCanvas(theme);
    if (noiseCanvas) {
      try {
        const pat = ctx.createPattern(noiseCanvas, 'repeat');
        if (pat) {
          ctx.save();
          ctx.globalCompositeOperation = theme === 'whiteboard' ? 'multiply' : 'overlay';
          ctx.globalAlpha = theme === 'whiteboard' ? 0.08 : 0.12;
          ctx.fillStyle   = pat;
          ctx.fillRect(0, 0, W, H);
          ctx.restore();
        }
      } catch { /* ignore pattern errors */ }
    }

    // Vignette
    const vigColors = {
      chalkboard: [0, 0, 0, 0.28],
      blackboard: [0, 0, 0, 0.35],
      corkboard:  [40, 20, 0, 0.22],
      whiteboard: [0, 0, 0, 0.04],
    }[theme] || [0, 0, 0, 0.28];
    const vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.25, W / 2, H / 2, Math.max(W, H) * 0.78);
    vg.addColorStop(0, 'rgba(0,0,0,0)');
    vg.addColorStop(1, `rgba(${vigColors.join(',')})`);
    ctx.fillStyle = vg;
    ctx.fillRect(0, 0, W, H);
  }

  _renderTemplate(ctx) {
    const W = this._cssW || 300;
    const H = this._cssH || 150;
    const isLight = this._boardTheme === 'whiteboard';
    const lineC = isLight ? 'rgba(0,0,0,0.10)' : 'rgba(255,255,255,0.08)';
    const dotC  = isLight ? 'rgba(0,0,0,0.20)'  : 'rgba(255,255,255,0.18)';
    ctx.save();
    if (this._template === 'grid') {
      const stepX = (40 / Whiteboard.VW) * W;
      const stepY = (40 / Whiteboard.VH) * H;
      ctx.strokeStyle = lineC; ctx.lineWidth = 0.8;
      ctx.beginPath();
      for (let x = stepX; x < W; x += stepX) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
      for (let y = stepY; y < H; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
      ctx.stroke();
    } else if (this._template === 'lined') {
      const stepY = (36 / Whiteboard.VH) * H;
      ctx.strokeStyle = lineC; ctx.lineWidth = 0.8;
      ctx.beginPath();
      for (let y = stepY; y < H; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
      ctx.stroke();
    } else if (this._template === 'dots') {
      const stepX = (40 / Whiteboard.VW) * W;
      const stepY = (40 / Whiteboard.VH) * H;
      const r = Math.max(1, (1.5 / Whiteboard.VW) * W);
      ctx.fillStyle = dotC;
      for (let x = stepX; x < W; x += stepX)
        for (let y = stepY; y < H; y += stepY) {
          ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
        }
    } else if (this._template === 'coordinate') {
      const ox = W / 2, oy = H / 2;
      const stepX = (40 / Whiteboard.VW) * W;
      const stepY = (40 / Whiteboard.VH) * H;
      const coordGridC  = isLight ? 'rgba(0,0,0,0.07)'  : 'rgba(255,255,255,0.06)';
      const coordAxisC  = isLight ? 'rgba(0,0,0,0.45)'  : 'rgba(255,255,255,0.35)';
      const coordTickC  = isLight ? 'rgba(0,0,0,0.28)'  : 'rgba(255,255,255,0.25)';
      // Light grid
      ctx.strokeStyle = coordGridC; ctx.lineWidth = 0.6;
      ctx.beginPath();
      for (let x = ox % stepX; x < W; x += stepX) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
      for (let y = oy % stepY; y < H; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
      ctx.stroke();
      // Axes
      ctx.strokeStyle = coordAxisC; ctx.lineWidth = 1.2;
      ctx.beginPath();
      ctx.moveTo(0, oy); ctx.lineTo(W, oy);
      ctx.moveTo(ox, 0); ctx.lineTo(ox, H);
      ctx.stroke();
      // Arrows
      const ar = 7; ctx.fillStyle = coordAxisC;
      ctx.beginPath(); ctx.moveTo(W, oy); ctx.lineTo(W-ar, oy-ar/2); ctx.lineTo(W-ar, oy+ar/2); ctx.closePath(); ctx.fill();
      ctx.beginPath(); ctx.moveTo(ox, 0); ctx.lineTo(ox-ar/2, ar); ctx.lineTo(ox+ar/2, ar); ctx.closePath(); ctx.fill();
      // Tick marks
      ctx.strokeStyle = coordTickC; ctx.lineWidth = 0.8;
      const tk = 4; ctx.beginPath();
      for (let x = ox + stepX; x < W - ar; x += stepX) { ctx.moveTo(x, oy-tk); ctx.lineTo(x, oy+tk); }
      for (let x = ox - stepX; x > 0; x -= stepX)       { ctx.moveTo(x, oy-tk); ctx.lineTo(x, oy+tk); }
      for (let y = oy + stepY; y < H - ar; y += stepY)   { ctx.moveTo(ox-tk, y); ctx.lineTo(ox+tk, y); }
      for (let y = oy - stepY; y > 0; y -= stepY)         { ctx.moveTo(ox-tk, y); ctx.lineTo(ox+tk, y); }
      ctx.stroke();
    }
    ctx.restore();
  }

  /* Schedule a render on the next animation frame.
     Multiple calls within the same frame are collapsed into one. */
  render() {
    if (this._rafPending) return;
    this._rafPending = true;
    requestAnimationFrame(() => {
      this._rafPending = false;
      if (!document.hidden) this._doRender();
    });
  }

  _doRender() {
    if (this._staticDirty) { this._renderStatic(); this._staticDirty = false; }
    this._renderDynamic();
    this._renderMinimap();
  }

  _renderStatic() {
    const ctx = this._ctx;
    this._renderBg(ctx);
    if (this._template && this._template !== 'blank') this._renderTemplate(ctx);
    // Background images render first (locked layer, always behind everything)
    for (const s of this._strokes) {
      if (s.tool === 'image' && s.data.isBackground) this._renderStroke(ctx, s);
    }
    for (const s of this._strokes) {
      if (!(s.tool === 'image' && s.data.isBackground)) this._renderStroke(ctx, s);
    }
  }

  _renderDynamic() {
    const ctx = this._dynCtx;
    const W = this._cssW || 300, H = this._cssH || 150;
    ctx.clearRect(0, 0, W, H);

    // Remote live strokes (other users drawing in real-time)
    // Name/cursor dot is handled by DOM cursors (_showRemoteCursor) — no duplication here
    for (const [, ls] of this._liveStrokes) {
      this._renderStroke(ctx, ls);
    }

    // Laser pointer (local teacher view) — fade trail
    if (this._tool === 'laser' && this._drawing && this._laserTrail.length > 0) {
      const n = this._laserTrail.length;
      ctx.save();
      for (let i = 0; i < n; i++) {
        const [lvx, lvy] = this._laserTrail[i];
        const [lcx, lcy] = this._toCanvas(lvx, lvy);
        const t = (i + 1) / n;
        ctx.globalAlpha = t * 0.85;
        ctx.shadowColor = '#ff2222'; ctx.shadowBlur = 15 * t;
        ctx.fillStyle   = '#ff4444';
        ctx.beginPath(); ctx.arc(lcx, lcy, Math.max(2, 6 * t), 0, Math.PI * 2); ctx.fill();
      }
      ctx.restore();
    }

    // Eraser: visual cursor circle follows pointer
    if (this._tool === 'eraser') {
      const [ecx, ecy] = this._toCanvas(this._pointerVx, this._pointerVy);
      const er = Math.max(4, ((this._w / Whiteboard.VW) * W) / 2);
      ctx.save();
      ctx.strokeStyle = 'rgba(255,255,255,0.75)'; ctx.lineWidth = 1.5;
      ctx.setLineDash([3, 2]);
      ctx.beginPath(); ctx.arc(ecx, ecy, er, 0, Math.PI * 2); ctx.stroke();
      ctx.setLineDash([]); ctx.restore();
    }

    // Connector: anchor point hints on hover shape
    if (this._tool === 'connector') {
      const anchors = ['n', 's', 'e', 'w', 'center'];
      const shapeIdsToShow = new Set();
      if (this._connHoverShapeId) shapeIdsToShow.add(this._connHoverShapeId);
      if (this._drawing && this._connSnapStart) shapeIdsToShow.add(this._connSnapStart.strokeId);

      for (const shapeId of shapeIdsToShow) {
        const shape = this._strokes.find(s => s.id === shapeId);
        if (!shape || shape.tool === 'connector') continue;
        ctx.save();
        for (const anchor of anchors) {
          const p = this._getAnchorPoint(shape, anchor);
          const [acx, acy] = this._toCanvas(p.x, p.y);
          const isSnap = this._connSnapEnd &&
            this._connSnapEnd.strokeId === shapeId && this._connSnapEnd.anchor === anchor;
          ctx.beginPath();
          ctx.arc(acx, acy, isSnap ? 7 : 5, 0, Math.PI * 2);
          ctx.fillStyle   = isSnap ? '#06D6E0' : 'rgba(6,214,224,0.35)';
          ctx.strokeStyle = isSnap ? '#06D6E0' : 'rgba(6,214,224,0.7)';
          ctx.lineWidth   = 1.5;
          ctx.fill();
          ctx.stroke();
        }
        ctx.restore();
      }
    }

    // Compass tool live preview (3 state-machine phases)
    if (this._tool === 'compass' && this._compassState !== 'idle' && this._compassCenter) {
      const [ccx, ccy] = this._toCanvas(this._compassCenter.x, this._compassCenter.y);
      const sx       = W / Whiteboard.VW;
      const cr       = this._compassRadius * sx;
      const lw       = Math.max(1.5, (this._width || 2) * sx);
      const arcColor = this._color || '#FFE066';
      ctx.save();

      if (this._compassState === 'setting-radius') {
        // Dashed ghost circle + radius line to pointer
        if (cr > 2) {
          ctx.strokeStyle = 'rgba(255,255,255,0.28)';
          ctx.lineWidth = 1; ctx.setLineDash([5, 4]);
          ctx.beginPath(); ctx.arc(ccx, ccy, cr, 0, Math.PI * 2); ctx.stroke();
          ctx.setLineDash([]);
        }
        const [pmx, pmy] = this._toCanvas(this._pointerVx, this._pointerVy);
        ctx.strokeStyle = 'rgba(6,214,224,0.4)';
        ctx.lineWidth = 1; ctx.setLineDash([3, 3]);
        ctx.beginPath(); ctx.moveTo(ccx, ccy); ctx.lineTo(pmx, pmy); ctx.stroke();
        ctx.setLineDash([]);
        // Center dot
        ctx.fillStyle = '#06D6E0'; ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 8;
        ctx.beginPath(); ctx.arc(ccx, ccy, 4, 0, Math.PI * 2); ctx.fill();
        ctx.shadowBlur = 0;
        // Radius label
        if (cr > 14) {
          const rLabel = `r = ${Math.round(this._compassRadius)}`;
          ctx.font = 'bold 11px Manrope, sans-serif';
          ctx.textBaseline = 'bottom'; ctx.textAlign = 'left';
          const tw = ctx.measureText(rLabel).width;
          const lx = ccx + cr * 0.28 + 2, ly = ccy - cr * 0.18 - 4;
          ctx.fillStyle = 'rgba(20,14,36,0.78)';
          ctx.beginPath();
          if (ctx.roundRect) ctx.roundRect(lx - 4, ly - 15, tw + 8, 17, 3);
          else ctx.rect(lx - 4, ly - 15, tw + 8, 17);
          ctx.fill();
          ctx.fillStyle = 'rgba(230,225,255,0.9)'; ctx.fillText(rLabel, lx, ly);
          ctx.textAlign = 'left';
        }

      } else if (this._compassState === 'waiting-arc') {
        // Dashed full-circle preview + "click to draw arc" hint
        ctx.strokeStyle = arcColor + '77';
        ctx.lineWidth = lw; ctx.setLineDash([7, 5]);
        ctx.beginPath(); ctx.arc(ccx, ccy, cr, 0, Math.PI * 2); ctx.stroke();
        ctx.setLineDash([]);
        ctx.fillStyle = '#06D6E0'; ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 8;
        ctx.beginPath(); ctx.arc(ccx, ccy, 4, 0, Math.PI * 2); ctx.fill();
        ctx.shadowBlur = 0;
        // Hint text below circle
        const hintText = 'Кликни, чтобы начать дугу';
        ctx.font = '11px Manrope, sans-serif';
        ctx.textBaseline = 'top'; ctx.textAlign = 'center';
        const tw2 = ctx.measureText(hintText).width;
        const hx2 = ccx, hy2 = ccy + cr + 10;
        ctx.fillStyle = 'rgba(20,14,36,0.82)';
        ctx.beginPath();
        if (ctx.roundRect) ctx.roundRect(hx2 - tw2 / 2 - 6, hy2 - 2, tw2 + 12, 19, 4);
        else ctx.rect(hx2 - tw2 / 2 - 6, hy2 - 2, tw2 + 12, 19);
        ctx.fill();
        ctx.fillStyle = 'rgba(200,190,230,0.92)'; ctx.fillText(hintText, hx2, hy2 + 1);
        ctx.textAlign = 'left';

      } else if (this._compassState === 'drawing-arc') {
        // Live arc + instrument legs
        const arcEnd = this._compassArcStart + this._compassArcSweep;
        const ccwArc = this._compassArcSweep < 0;
        ctx.strokeStyle = arcColor; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.setLineDash([]);
        ctx.shadowColor = arcColor; ctx.shadowBlur = 4;
        ctx.beginPath(); ctx.arc(ccx, ccy, cr, this._compassArcStart, arcEnd, ccwArc); ctx.stroke();
        ctx.shadowBlur = 0;

        const ptx = ccx + cr * Math.cos(arcEnd);
        const pty = ccy + cr * Math.sin(arcEnd);
        const legLen = cr * 1.55;
        const dist   = Math.hypot(ptx - ccx, pty - ccy);
        if (dist >= 2 && dist < legLen * 2) {
          const mx    = (ccx + ptx) / 2, my = (ccy + pty) / 2;
          const perpX = -(pty - ccy) / dist;
          const perpY =  (ptx - ccx) / dist;
          const halfH = Math.sqrt(Math.max(0, legLen * legLen - (dist / 2) * (dist / 2)));
          const sign  = (my + perpY * halfH) <= (my - perpY * halfH) ? 1 : -1;
          const hx3   = mx + sign * perpX * halfH;
          const hy3   = my + sign * perpY * halfH;
          ctx.strokeStyle = 'rgba(6,214,224,0.6)'; ctx.lineWidth = Math.max(1, lw * 0.7);
          ctx.beginPath(); ctx.moveTo(hx3, hy3); ctx.lineTo(ccx, ccy); ctx.stroke();
          ctx.strokeStyle = 'rgba(155,93,229,0.6)';
          ctx.beginPath(); ctx.moveTo(hx3, hy3); ctx.lineTo(ptx, pty); ctx.stroke();
          ctx.fillStyle = 'rgba(215,210,232,0.85)';
          ctx.beginPath(); ctx.arc(hx3, hy3, Math.max(3, lw * 1.2), 0, Math.PI * 2); ctx.fill();
        }
        // Center dot
        ctx.fillStyle = '#06D6E0'; ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 8;
        ctx.beginPath(); ctx.arc(ccx, ccy, 4, 0, Math.PI * 2); ctx.fill();
        ctx.shadowBlur = 0;
        // Pencil tip dot
        ctx.fillStyle = arcColor; ctx.shadowColor = arcColor; ctx.shadowBlur = 6;
        ctx.beginPath(); ctx.arc(ptx, pty, Math.max(2.5, lw * 0.8), 0, Math.PI * 2); ctx.fill();
        ctx.shadowBlur = 0;
        // Sweep angle label
        const sweepDeg = Math.abs(this._compassArcSweep * 180 / Math.PI);
        if (sweepDeg > 3) {
          const midAngle = this._compassArcStart + this._compassArcSweep / 2;
          const llx = ccx + (cr + 18) * Math.cos(midAngle);
          const lly = ccy + (cr + 18) * Math.sin(midAngle);
          const lText = `${sweepDeg.toFixed(1)}°`;
          ctx.font = 'bold 11px Manrope, sans-serif';
          ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
          const ltw = ctx.measureText(lText).width;
          ctx.fillStyle = 'rgba(20,14,36,0.8)';
          ctx.beginPath();
          if (ctx.roundRect) ctx.roundRect(llx - ltw / 2 - 4, lly - 9, ltw + 8, 18, 3);
          else ctx.rect(llx - ltw / 2 - 4, lly - 9, ltw + 8, 18);
          ctx.fill();
          ctx.fillStyle = arcColor; ctx.fillText(lText, llx, lly);
          ctx.textAlign = 'left';
        }
      }
      ctx.restore();
    }

    // Selection overlays (all selected strokes)
    for (const id of this._selectedIds) {
      const sel = this._strokes.find(s => s.id === id);
      if (sel) {
        this._renderObjectSelection(ctx, sel);
        // Auto-measurements for shapes
        if (this._showMeasurements && sel.tool === 'shape') this.renderMeasurements(ctx, sel);
      }
    }

    // Lasso rubber-band rectangle
    if (this._lassoRect) {
      const lr = this._lassoRect;
      const [cx1, cy1] = this._toCanvas(Math.min(lr.x1, lr.x2), Math.min(lr.y1, lr.y2));
      const [cx2, cy2] = this._toCanvas(Math.max(lr.x1, lr.x2), Math.max(lr.y1, lr.y2));
      ctx.save();
      ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]);
      ctx.strokeRect(cx1, cy1, cx2 - cx1, cy2 - cy1);
      ctx.fillStyle = 'rgba(155,93,229,0.06)';
      ctx.fillRect(cx1, cy1, cx2 - cx1, cy2 - cy1);
      ctx.setLineDash([]); ctx.restore();
    }

    // Snap guides (cyan lines)
    if (this._snapGuides.length > 0) {
      ctx.save();
      ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1; ctx.globalAlpha = 0.7;
      ctx.setLineDash([4, 4]);
      for (const g of this._snapGuides) {
        const [gx, gy] = this._toCanvas(g.axis === 'x' ? g.pos : 0, g.axis === 'y' ? g.pos : 0);
        ctx.beginPath();
        if (g.axis === 'x') { ctx.moveTo(gx, 0); ctx.lineTo(gx, H); }
        else                { ctx.moveTo(0, gy); ctx.lineTo(W, gy); }
        ctx.stroke();
      }
      ctx.setLineDash([]); ctx.restore();
    }

    // Live drawing preview (current user)
    if (this._drawing) {
      if ((this._isShapeTool() || this._tool === 'connector') && this._shapeStart && this._shapeEnd) {
        if (this._tool === 'connector') {
          this._renderStroke(ctx, { tool: 'connector', data: {
            x1: this._shapeStart[0], y1: this._shapeStart[1],
            x2: this._shapeEnd[0],   y2: this._shapeEnd[1],
            color: this._color, width: this._w, arrowEnd: true, arrowStart: false,
          }});
        } else {
          this._renderStroke(ctx, { tool: 'shape', data: {
            shape: this._tool, x1: this._shapeStart[0], y1: this._shapeStart[1],
            x2: this._shapeEnd[0], y2: this._shapeEnd[1],
            color: this._color, width: this._w, fill: this._fill,
          }});
          // Size label near bottom-right corner of the shape
          const [sx, sy] = this._shapeStart, [ex, ey] = this._shapeEnd;
          const vw2 = Math.abs(ex - sx), vh2 = Math.abs(ey - sy);
          if (vw2 > 8 || vh2 > 8) {
            const [labx, laby] = this._toCanvas(Math.max(sx, ex), Math.max(sy, ey));
            const label = `${Math.round(vw2)} × ${Math.round(vh2)}`;
            ctx.save();
            ctx.font = 'bold 11px Manrope, sans-serif';
            ctx.textBaseline = 'top';
            const tw = ctx.measureText(label).width;
            ctx.fillStyle = 'rgba(20,14,36,0.82)';
            ctx.beginPath();
            if (ctx.roundRect) ctx.roundRect(labx + 6, laby + 4, tw + 12, 20, 4);
            else ctx.rect(labx + 6, laby + 4, tw + 12, 20);
            ctx.fill();
            ctx.fillStyle = '#e8e0f7';
            ctx.fillText(label, labx + 12, laby + 8);
            ctx.restore();
          }
        }
      } else if (this._tool === 'table' && this._shapeStart && this._shapeEnd) {
        const [x1, y1] = this._shapeStart, [x2, y2] = this._shapeEnd;
        const [cx1, cy1] = this._toCanvas(Math.min(x1, x2), Math.min(y1, y2));
        const [cx2, cy2] = this._toCanvas(Math.max(x1, x2), Math.max(y1, y2));
        ctx.save();
        ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]);
        ctx.strokeRect(cx1, cy1, cx2 - cx1, cy2 - cy1);
        ctx.setLineDash([]); ctx.restore();
      } else if (!this._isShapeTool() && this._tool !== 'connector' && this._tool !== 'table' && this._curPts.length > 0) {
        this._renderStroke(ctx, { tool: this._tool,
          data: { points: this._curPts, color: this._color, width: this._width } });
      }
    }

    // Ruler / Protractor overlays
    if (this._overlays.length > 0) this._renderOverlays(ctx);
  }

  _renderStroke(ctx, stroke) {
    if (stroke.tool === 'mindmap')     { this._renderMindmap(ctx, stroke);     return; }
    if (stroke.tool === 'shape')       { this._renderShape(ctx, stroke);       return; }
    if (stroke.tool === 'text')        { this._renderText(ctx, stroke);        return; }
    if (stroke.tool === 'image')       { this._renderImage(ctx, stroke);       return; }
    if (stroke.tool === 'sticky')      { this._renderSticky(ctx, stroke);      return; }
    if (stroke.tool === 'formula')     { this._renderFormula(ctx, stroke);     return; }
    if (stroke.tool === 'table')       { this._renderTable(ctx, stroke);       return; }
    if (stroke.tool === 'connector')   { this._renderConnector(ctx, stroke);   return; }
    if (stroke.tool === 'highlighter') { this._renderHighlighter(ctx, stroke); return; }
    if (stroke.tool === 'laser')       { this._renderLaser(ctx, stroke);       return; }
    if (stroke.tool === 'coordinate')  { this._renderCoordinate(ctx, stroke);  return; }
    if (stroke.tool === 'numberline')  { this._renderNumberLine(ctx, stroke);  return; }
    if (stroke.tool === 'compass')     { this._renderCompass(ctx, stroke);     return; }

    // pencil / eraser
    const pts = stroke.data.points;
    if (!pts || pts.length === 0) return;

    ctx.save();
    if (stroke.tool === 'eraser') {
      ctx.globalCompositeOperation = 'destination-out';
      ctx.strokeStyle = 'rgba(0,0,0,1)';
      ctx.fillStyle   = 'rgba(0,0,0,1)';
    } else {
      ctx.globalCompositeOperation = 'source-over';
      ctx.strokeStyle = stroke.data.color || '#ffffff';
      ctx.fillStyle   = stroke.data.color || '#ffffff';
      // Chalk effect: soft powdery edges + slight transparency, modulated by opacity
      ctx.globalAlpha = (stroke.data.opacity ?? 1.0) * 0.88;
      ctx.shadowColor = stroke.data.color || '#ffffff';
      ctx.shadowBlur  = 1.4;
    }
    ctx.lineWidth = (stroke.data.width / Whiteboard.VW) * (this._cssW || 300);
    ctx.lineCap   = 'round';
    ctx.lineJoin  = 'round';

    ctx.beginPath();
    const [x0, y0] = this._toCanvas(pts[0][0], pts[0][1]);
    ctx.moveTo(x0, y0);

    if (pts.length === 1) {
      ctx.arc(x0, y0, ctx.lineWidth / 2, 0, Math.PI * 2);
      ctx.fill();
    } else if (pts.length === 2) {
      const [x1, y1] = this._toCanvas(pts[1][0], pts[1][1]);
      ctx.lineTo(x1, y1); ctx.stroke();
    } else {
      // Catmull-Rom spline for smooth curves
      for (let i = 0; i < pts.length - 1; i++) {
        const i0 = Math.max(0, i - 1), i3 = Math.min(pts.length - 1, i + 2);
        const p0 = this._toCanvas(pts[i0][0], pts[i0][1]);
        const p1 = this._toCanvas(pts[i][0],  pts[i][1]);
        const p2 = this._toCanvas(pts[i + 1][0], pts[i + 1][1]);
        const p3 = this._toCanvas(pts[i3][0],    pts[i3][1]);
        const cp1x = p1[0] + (p2[0] - p0[0]) / 6;
        const cp1y = p1[1] + (p2[1] - p0[1]) / 6;
        const cp2x = p2[0] - (p3[0] - p1[0]) / 6;
        const cp2y = p2[1] - (p3[1] - p1[1]) / 6;
        ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2[0], p2[1]);
      }
      ctx.stroke();
    }
    ctx.restore();
  }

  _renderShape(ctx, stroke) {
    const d = stroke.data;
    const [cx1, cy1] = this._toCanvas(d.x1, d.y1);
    const [cx2, cy2] = this._toCanvas(d.x2, d.y2);
    const lw = Math.max(1, (d.width / Whiteboard.VW) * (this._cssW || 300));

    ctx.save();
    ctx.globalCompositeOperation = 'source-over';
    ctx.globalAlpha = d.opacity ?? 1.0;
    ctx.strokeStyle = d.color || '#ffffff';
    ctx.fillStyle   = d.color || '#ffffff';
    ctx.lineWidth   = lw;
    ctx.lineCap     = 'round';
    ctx.lineJoin    = 'round';
    const dash = d.lineStyle === 'dashed' ? [lw * 3, lw * 2]
               : d.lineStyle === 'dotted' ? [lw, lw * 2.5] : [];
    ctx.setLineDash(dash);

    const minX = Math.min(cx1, cx2), minY = Math.min(cy1, cy2);
    const W    = Math.abs(cx2 - cx1),  H = Math.abs(cy2 - cy1);
    const midX = (cx1 + cx2) / 2,     midY = (cy1 + cy2) / 2;

    ctx.beginPath();
    switch (d.shape) {
      case 'rect':
        ctx.rect(minX, minY, W, H);
        if (d.fill) ctx.fill(); ctx.stroke();
        break;

      case 'ellipse': {
        ctx.ellipse(midX, midY, W / 2 || 1, H / 2 || 1, 0, 0, Math.PI * 2);
        if (d.fill) ctx.fill(); ctx.stroke();
        break;
      }

      case 'line':
        ctx.moveTo(cx1, cy1); ctx.lineTo(cx2, cy2); ctx.stroke();
        break;

      case 'triangle':
        ctx.moveTo(midX, minY);
        ctx.lineTo(Math.max(cx1, cx2), Math.max(cy1, cy2));
        ctx.lineTo(Math.min(cx1, cx2), Math.max(cy1, cy2));
        ctx.closePath();
        if (d.fill) ctx.fill(); ctx.stroke();
        break;

      case 'diamond':
        ctx.moveTo(midX, minY);
        ctx.lineTo(Math.max(cx1, cx2), midY);
        ctx.lineTo(midX, Math.max(cy1, cy2));
        ctx.lineTo(Math.min(cx1, cx2), midY);
        ctx.closePath();
        if (d.fill) ctx.fill(); ctx.stroke();
        break;

      case 'hexagon': {
        const angles = [0, 60, 120, 180, 240, 300].map(a => a * Math.PI / 180);
        ctx.moveTo(midX + W / 2 * Math.cos(angles[0]), midY + H / 2 * Math.sin(angles[0]));
        for (let i = 1; i < 6; i++)
          ctx.lineTo(midX + W / 2 * Math.cos(angles[i]), midY + H / 2 * Math.sin(angles[i]));
        ctx.closePath();
        if (d.fill) ctx.fill(); ctx.stroke();
        break;
      }

      case 'star': {
        const outerR = Math.min(W, H) / 2;
        const innerR = outerR * 0.4;
        for (let i = 0; i < 10; i++) {
          const r     = i % 2 === 0 ? outerR : innerR;
          const angle = (i * Math.PI / 5) - Math.PI / 2;
          const sx = midX + r * Math.cos(angle);
          const sy = midY + r * Math.sin(angle);
          i === 0 ? ctx.moveTo(sx, sy) : ctx.lineTo(sx, sy);
        }
        ctx.closePath();
        if (d.fill) ctx.fill(); ctx.stroke();
        break;
      }

      case 'roundedrect': {
        const r = Math.min(W, H) * 0.15;
        ctx.roundRect(minX, minY, W, H, r);
        if (d.fill) ctx.fill(); ctx.stroke();
        break;
      }

      case 'callout': {
        const r    = Math.min(W, H) * 0.12;
        const tailH = H * 0.22;
        const tailW = W * 0.16;
        const tailX = minX + W * 0.18;
        const boxH  = H - tailH;
        ctx.roundRect(minX, minY, W, boxH, r);
        if (d.fill) ctx.fill(); ctx.stroke();
        ctx.beginPath();
        ctx.moveTo(tailX, minY + boxH);
        ctx.lineTo(tailX + tailW, minY + boxH);
        ctx.lineTo(tailX + tailW * 0.35, minY + H);
        ctx.closePath();
        if (d.fill) ctx.fill(); else ctx.stroke();
        break;
      }

      case 'arrow': {
        // Block arrow from cx1,cy1 to cx2,cy2
        const dx = cx2 - cx1, dy = cy2 - cy1;
        const len = Math.sqrt(dx * dx + dy * dy) || 1;
        const ux = dx / len, uy = dy / len;
        const bodyW = Math.max(4, H * 0.3);
        const headH = Math.min(W * 0.35, H * 0.85);
        const mx = cx2 - ux * headH, my = cy2 - uy * headH;
        const px = -uy * bodyW, py = ux * bodyW;
        const hpx = -uy * H * 0.5, hpy = ux * H * 0.5;
        ctx.moveTo(cx1 + px, cy1 + py);
        ctx.lineTo(mx + px, my + py);
        ctx.lineTo(mx + hpx, my + hpy);
        ctx.lineTo(cx2, cy2);
        ctx.lineTo(mx - hpx, my - hpy);
        ctx.lineTo(mx - px, my - py);
        ctx.lineTo(cx1 - px, cy1 - py);
        ctx.closePath();
        if (d.fill) ctx.fill(); else { ctx.fill(); }
        break;
      }
    }

    // Text inside shape (set via double-click)
    if (d.text && d.shape !== 'line' && d.shape !== 'arrow') {
      const vH2 = Math.abs(d.y2 - d.y1);
      const autoFsV = Math.max(12, Math.min(38, vH2 * 0.18));
      const fs = Math.max(9, Math.round(((d.fontSize || autoFsV) / Whiteboard.VH) * (this._cssH || 150)));
      const tpad = Math.max(8, W * 0.06);
      const maxTW = W - tpad * 2;
      if (maxTW > 10) {
        ctx.setLineDash([]);
        ctx.fillStyle   = d.textColor || '#ffffff';
        ctx.shadowBlur  = 0;
        ctx.font        = `bold ${fs}px Manrope, sans-serif`;
        ctx.textBaseline = 'middle';
        ctx.textAlign   = 'center';
        const tmidX = (cx1 + cx2) / 2, tmidY = (cy1 + cy2) / 2;
        const tLines = this._wrapText(ctx, d.text, maxTW);
        const lineH  = fs * 1.3;
        const totalH = tLines.length * lineH;
        let ty = tmidY - totalH / 2 + lineH / 2;
        const botLim = Math.max(cy1, cy2) - tpad;
        for (const line of tLines) {
          if (ty > botLim) break;
          ctx.fillText(line, tmidX, ty);
          ty += lineH;
        }
      }
    }
    ctx.restore();
  }

  _renderConnector(ctx, stroke) {
    const d = stroke.data;
    const ep = this._getConnectorEndpoints(d);
    const [cx1, cy1] = this._toCanvas(ep.x1, ep.y1);
    const [cx2, cy2] = this._toCanvas(ep.x2, ep.y2);
    const lw = Math.max(1, (d.width / Whiteboard.VW) * (this._cssW || 300));

    const dx = cx2 - cx1, dy = cy2 - cy1;
    const len = Math.sqrt(dx * dx + dy * dy) || 1;
    const ux = dx / len, uy = dy / len;
    const headL = Math.max(8, Math.min(18, len * 0.22));
    const headW = headL * 0.55;

    ctx.save();
    ctx.globalCompositeOperation = 'source-over';
    ctx.globalAlpha = d.opacity ?? 1.0;
    ctx.strokeStyle = d.color || '#ffffff';
    ctx.fillStyle   = d.color || '#ffffff';
    ctx.lineWidth   = lw;
    ctx.lineCap     = 'round';
    const dash = d.lineStyle === 'dashed' ? [lw * 3, lw * 2]
               : d.lineStyle === 'dotted' ? [lw, lw * 2.5] : [];
    ctx.setLineDash(dash);

    // Line (straight or bezier)
    ctx.beginPath();
    if (d.connStyle === 'curved' && (d.fromAnchor || d.toAnchor)) {
      const dist = Math.hypot(cx2 - cx1, cy2 - cy1);
      const k = Math.max(40, dist * 0.4);
      const [d1x, d1y] = this._anchorDirection(d.fromAnchor || 'center');
      const [d2x, d2y] = this._anchorDirection(d.toAnchor  || 'center');
      const cpx1 = cx1 + d1x * k, cpy1 = cy1 + d1y * k;
      const cpx2 = cx2 - d2x * k, cpy2 = cy2 - d2y * k;
      ctx.moveTo(cx1, cy1);
      ctx.bezierCurveTo(cpx1, cpy1, cpx2, cpy2, cx2 - ux * headL * 0.7, cy2 - uy * headL * 0.7);
    } else {
      ctx.moveTo(cx1, cy1);
      ctx.lineTo(cx2 - ux * headL * 0.7, cy2 - uy * headL * 0.7);
    }
    ctx.stroke();

    // End arrowhead
    if (d.arrowEnd !== false) {
      ctx.setLineDash([]);
      ctx.beginPath();
      ctx.moveTo(cx2, cy2);
      ctx.lineTo(cx2 - ux * headL + uy * headW, cy2 - uy * headL - ux * headW);
      ctx.lineTo(cx2 - ux * headL - uy * headW, cy2 - uy * headL + ux * headW);
      ctx.closePath();
      ctx.fill();
    }

    // Start arrowhead
    if (d.arrowStart) {
      ctx.setLineDash([]);
      ctx.beginPath();
      ctx.moveTo(cx1, cy1);
      ctx.lineTo(cx1 + ux * headL + uy * headW, cy1 + uy * headL - ux * headW);
      ctx.lineTo(cx1 + ux * headL - uy * headW, cy1 + uy * headL + ux * headW);
      ctx.closePath();
      ctx.fill();
    }
    ctx.restore();
  }

  _renderHighlighter(ctx, stroke) {
    const d = stroke.data;
    if (!d.points || d.points.length < 2) return;
    const W = this._cssW || 300, H = this._cssH || 150;
    const lw = Math.max(8, (d.width / Whiteboard.VW) * W * 3);
    // Render on offscreen canvas first so self-overlapping segments
    // don't accumulate alpha (uniform highlight, no darker crossing points)
    const off = document.createElement('canvas');
    off.width = W; off.height = H;
    const octx = off.getContext('2d');
    octx.strokeStyle = d.color || '#FFE066';
    octx.lineWidth   = lw;
    octx.lineCap     = 'square';
    octx.lineJoin    = 'round';
    octx.beginPath();
    const [fx, fy] = this._toCanvas(d.points[0][0], d.points[0][1]);
    octx.moveTo(fx, fy);
    for (let i = 1; i < d.points.length; i++) {
      const [px, py] = this._toCanvas(d.points[i][0], d.points[i][1]);
      octx.lineTo(px, py);
    }
    octx.stroke();
    ctx.save();
    ctx.globalAlpha = 0.38;
    ctx.globalCompositeOperation = 'source-over';
    ctx.drawImage(off, 0, 0);
    ctx.restore();
  }

  _renderLaser(ctx, stroke) {
    const d = stroke.data;
    if (!d.points || d.points.length === 0) return;
    // Render only the last point (remote users don't get trail history)
    const [vx, vy] = d.points[d.points.length - 1];
    const [cx, cy] = this._toCanvas(vx, vy);
    ctx.save();
    ctx.shadowColor = '#ff2222'; ctx.shadowBlur = 15;
    ctx.fillStyle   = '#ff4444';
    ctx.beginPath(); ctx.arc(cx, cy, 6, 0, Math.PI * 2); ctx.fill();
    ctx.restore();
  }

  _renderText(ctx, stroke) {
    const d = stroke.data;
    if (!d.text) return;
    const [cx, cy] = this._toCanvas(d.x, d.y);
    const fontSize = Math.round(((d.fontSize || 22) / Whiteboard.VH) * (this._cssH || 150));
    const fw = d.fontWeight || 'normal';
    const fi = d.fontStyle  || 'normal';
    const ff = d.fontFamily || 'Manrope';
    ctx.save();
    ctx.globalCompositeOperation = 'source-over';
    ctx.fillStyle = d.color || '#ffffff';
    ctx.font = `${fi} ${fw} ${fontSize}px '${ff}', sans-serif`;
    ctx.textBaseline = 'top';
    ctx.textAlign    = d.textAlign || 'left';
    // chalk effect on text too
    ctx.shadowColor = d.color || '#ffffff';
    ctx.shadowBlur  = 1.2;
    const lines = d.text.split('\n');
    const lh = fontSize * 1.45;
    lines.forEach((line, i) => ctx.fillText(line, cx, cy + i * lh));
    ctx.restore();
  }

  _renderImage(ctx, stroke) {
    const d = stroke.data;
    if (!d.src) return;
    const [cx, cy] = this._toCanvas(d.x, d.y);
    const cw = (d.w / Whiteboard.VW) * (this._cssW || 300);
    const ch = (d.h / Whiteboard.VH) * (this._cssH || 150);
    if (!stroke._img) {
      stroke._img = new Image();
      stroke._img.onload = () => { this._staticDirty = true; this.render(); };
      stroke._img.src = d.src;
    }
    if (stroke._img.complete && stroke._img.naturalWidth > 0) {
      ctx.save();
      ctx.globalCompositeOperation = 'source-over';
      ctx.globalAlpha = d.opacity ?? 1.0;
      if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); }
      if (d.isBackground && d.fit === 'contain') {
        // Letterbox: maintain aspect ratio, center in canvas
        const iW = stroke._img.naturalWidth, iH = stroke._img.naturalHeight;
        const scale = Math.min(cw / iW, ch / iH);
        const dw = iW * scale, dh = iH * scale;
        ctx.drawImage(stroke._img, cx + (cw - dw) / 2, cy + (ch - dh) / 2, dw, dh);
      } else {
        ctx.drawImage(stroke._img, cx, cy, cw, ch);
      }
      ctx.restore();
    }
  }

  _renderSticky(ctx, stroke) {
    const d = stroke.data;
    const [cx, cy] = this._toCanvas(d.x, d.y);
    const cw = (d.w / Whiteboard.VW) * (this._cssW || 300);
    const ch = (d.h / Whiteboard.VH) * (this._cssH || 150);

    ctx.save();
    ctx.globalCompositeOperation = 'source-over';
    if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); }

    // Shadow
    ctx.shadowColor = 'rgba(0,0,0,0.35)';
    ctx.shadowBlur  = 8;
    ctx.shadowOffsetX = 3;
    ctx.shadowOffsetY = 4;

    // Body
    ctx.fillStyle = d.bgColor || '#FFE066';
    ctx.beginPath();
    ctx.roundRect(cx, cy, cw, ch, 3);
    ctx.fill();
    ctx.shadowColor = 'transparent';

    // Folded corner (top-right)
    const fold = Math.min(cw, ch) * 0.14;
    ctx.fillStyle = 'rgba(0,0,0,0.18)';
    ctx.beginPath();
    ctx.moveTo(cx + cw - fold, cy);
    ctx.lineTo(cx + cw, cy + fold);
    ctx.lineTo(cx + cw, cy);
    ctx.closePath();
    ctx.fill();

    ctx.fillStyle = this._darkenHex(d.bgColor || '#FFE066', 25);
    ctx.beginPath();
    ctx.moveTo(cx + cw - fold, cy);
    ctx.lineTo(cx + cw - fold, cy + fold);
    ctx.lineTo(cx + cw, cy + fold);
    ctx.closePath();
    ctx.fill();

    // Text
    if (d.text) {
      const fs   = Math.round((d.fontSize / Whiteboard.VH) * (this._cssH || 150));
      const pad  = Math.max(8, cw * 0.08);
      ctx.fillStyle    = d.textColor || '#1a1a2e';
      ctx.font         = `${fs}px 'Manrope', sans-serif`;
      ctx.textBaseline = 'top';
      const maxW  = cw - 2 * pad;
      const lines = this._wrapText(ctx, d.text, maxW);
      let ty = cy + pad;
      const lineH = fs * 1.4;
      for (const line of lines) {
        if (ty + lineH > cy + ch - pad * 0.5) break;
        ctx.fillText(line, cx + pad, ty);
        ty += lineH;
      }
    }
    ctx.restore();
  }

  _renderMindmap(ctx, stroke) {
    const d   = stroke.data;
    if (!d.nodes) return;
    const isEditing  = this._editingMindmapStroke === stroke;
    const selNodeId  = isEditing ? this._selectedMindmapNodeId : null;

    // Draw edges first (behind nodes)
    for (const node of d.nodes) {
      if (node.parentId === null) continue;
      const parent = d.nodes.find(n => n.id === node.parentId);
      if (!parent) continue;
      const pr = this._mmNodeRect(d, parent);
      const cr = this._mmNodeRect(d, node);
      const dx  = cr.cx - pr.cx;
      const px1 = dx >= 0 ? pr.cx + pr.w / 2 : pr.cx - pr.w / 2;
      const px2 = dx >= 0 ? cr.cx - cr.w / 2 : cr.cx + cr.w / 2;
      const [cpx1, cpy1] = this._toCanvas(px1, pr.cy);
      const [cpx2, cpy2] = this._toCanvas(px2, cr.cy);
      const k = Math.abs(cpx2 - cpx1) * 0.5;
      ctx.save();
      ctx.strokeStyle = node.color || '#9B5DE5';
      ctx.lineWidth   = 1.8;
      ctx.globalAlpha = 0.65;
      ctx.setLineDash([]);
      ctx.beginPath();
      ctx.moveTo(cpx1, cpy1);
      ctx.bezierCurveTo(cpx1 + (dx >= 0 ? k : -k), cpy1, cpx2 + (dx >= 0 ? -k : k), cpy2, cpx2, cpy2);
      ctx.stroke();
      ctx.restore();
    }

    // Draw nodes
    for (const node of d.nodes) {
      const r     = this._mmNodeRect(d, node);
      const depth = this._mmNodeDepth(d, node.id);
      const [cx, cy]  = this._toCanvas(r.x, r.y);
      const cw = (r.w / Whiteboard.VW) * this._cssW;
      const ch = (r.h / Whiteboard.VH) * this._cssH;
      const isSelected = isEditing && node.id === selNodeId;
      const color  = node.color || '#9B5DE5';

      ctx.save();
      ctx.globalAlpha = 0.92;
      ctx.fillStyle   = depth === 0 ? color : `${color}33`;
      const rad = depth === 0 ? 10 : 8;
      if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, rad);
      else { ctx.beginPath(); ctx.rect(cx, cy, cw, ch); }
      ctx.fill();

      ctx.strokeStyle = isSelected ? '#ffffff' : color;
      ctx.lineWidth   = isSelected ? 2 : 1.5;
      ctx.globalAlpha = isSelected ? 1.0 : 0.8;
      if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, rad);
      else { ctx.beginPath(); ctx.rect(cx, cy, cw, ch); }
      ctx.stroke();

      const vFontSize = depth === 0 ? 15 : 13;
      const fs = Math.max(8, Math.round((vFontSize / Whiteboard.VH) * this._cssH));
      ctx.fillStyle   = depth === 0 ? '#fff' : '#e8e0f7';
      ctx.globalAlpha = 1.0;
      ctx.font        = `${depth === 0 ? '700' : '600'} ${fs}px Manrope, sans-serif`;
      ctx.textBaseline = 'middle';
      ctx.textAlign    = 'center';
      ctx.shadowBlur   = 0;
      const lines = this._wrapText(ctx, node.text || '', cw - 10);
      const lh = fs * 1.3;
      const totalH = lines.length * lh;
      let ty = cy + ch / 2 - totalH / 2 + lh / 2;
      for (const line of lines) {
        ctx.fillText(line, cx + cw / 2, ty);
        ty += lh;
      }

      if (isSelected) {
        const [bx, by] = this._toCanvas(r.x + r.w + 10, r.y + r.h / 2 - 10);
        const btnR = Math.max(7, (10 / Whiteboard.VW) * this._cssW);
        ctx.beginPath();
        ctx.arc(bx + btnR, by + btnR, btnR, 0, Math.PI * 2);
        ctx.fillStyle = '#06D6E0';
        ctx.globalAlpha = 0.9;
        ctx.fill();
        ctx.fillStyle = '#fff';
        ctx.globalAlpha = 1.0;
        ctx.font = `bold ${Math.round(btnR * 1.4)}px Manrope`;
        ctx.textBaseline = 'middle'; ctx.textAlign = 'center';
        ctx.fillText('+', bx + btnR, by + btnR);
      }
      ctx.restore();
    }

    // Outer selection rect when whole mindmap selected but NOT in node-edit mode
    if (!isEditing && this._selectedIds.has(stroke.id)) {
      const bbox = this._getStrokeBBox(stroke);
      const [bx, by] = this._toCanvas(bbox.x - 10, bbox.y - 10);
      const bw = ((bbox.w + 20) / Whiteboard.VW) * this._cssW;
      const bh = ((bbox.h + 20) / Whiteboard.VH) * this._cssH;
      ctx.save();
      ctx.strokeStyle = 'rgba(155,93,229,0.6)';
      ctx.lineWidth = 1.5; ctx.setLineDash([5, 3]);
      if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, 8);
      else { ctx.beginPath(); ctx.rect(bx, by, bw, bh); }
      ctx.stroke();
      ctx.setLineDash([]);
      ctx.restore();
    }
  }

  _darkenHex(hex, amount) {
    try {
      const r = Math.max(0, parseInt(hex.slice(1, 3), 16) - amount);
      const g = Math.max(0, parseInt(hex.slice(3, 5), 16) - amount);
      const b = Math.max(0, parseInt(hex.slice(5, 7), 16) - amount);
      return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
    } catch { return hex; }
  }

  _wrapText(ctx, text, maxW) {
    const lines = [];
    for (const para of text.split('\n')) {
      const words = para.split(' ');
      let cur = '';
      for (const w of words) {
        const test = cur ? cur + ' ' + w : w;
        if (ctx.measureText(test).width > maxW && cur) {
          lines.push(cur); cur = w;
        } else { cur = test; }
      }
      lines.push(cur);
    }
    return lines;
  }

  _renderFormula(ctx, stroke) {
    const d = stroke.data;
    if (!d.latex) return;
    const [cx, cy] = this._toCanvas(d.x, d.y);
    const cw = (d.w / Whiteboard.VW) * (this._cssW || 300);
    const ch = (d.h / Whiteboard.VH) * (this._cssH || 150);

    ctx.save();
    if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); }
    ctx.fillStyle = 'rgba(26,22,37,0.75)';
    ctx.beginPath();
    ctx.roundRect(cx, cy, cw, ch, 6);
    ctx.fill();

    const fi = stroke._formulaImg;
    if (fi === null || fi === undefined) {
      // Not started yet — kick off async render and show placeholder text
      this._renderFormulaAsync(stroke, cw, ch);
      this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch);
    } else if (fi === 'pending') {
      // CSS / image loading in progress
      this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch);
    } else if (fi === false) {
      // Render failed — show raw LaTeX so user knows what's there
      this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch);
    } else if (fi.complete && fi.naturalWidth > 0) {
      ctx.drawImage(fi, cx, cy, cw, ch);
    } else {
      this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch);
    }
    ctx.restore();
  }

  _drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch) {
    const fs = Math.max(11, Math.round((13 / Whiteboard.VH) * (this._cssH || 150)));
    ctx.fillStyle = 'rgba(255,255,255,0.45)';
    ctx.font = `italic ${fs}px 'Manrope',sans-serif`;
    ctx.textBaseline = 'middle';
    // Clip text to box so it doesn't overflow
    ctx.save();
    ctx.beginPath();
    ctx.rect(cx + 4, cy, cw - 8, ch);
    ctx.clip();
    ctx.fillText(d.latex, cx + 8, cy + ch / 2);
    ctx.restore();
  }

  _renderFormulaAsync(stroke, cw, ch) {
    stroke._formulaImg = 'pending';
    if (!window.katex) { stroke._formulaImg = false; return; }
    _loadKatexCss(css => {
      // Stroke may have been removed while CSS was loading
      if (!this._strokes.includes(stroke)) { stroke._formulaImg = false; return; }
      try {
        const html  = window.katex.renderToString(stroke.data.latex, {
          throwOnError: false, displayMode: true, output: 'html',
        });
        const color = stroke.data.color || '#e8e0f7';
        const vfs   = stroke.data.fontSize || 32;
        // Render at 3× for crisp, PowerPoint-quality formulas
        const scale = 3;
        const W  = Math.ceil(cw * scale);
        const H  = Math.ceil(ch * scale);
        const fs = Math.max(18, Math.round((vfs / Whiteboard.VH) * (this._cssH || 150) * 2.8 * scale));
        const svgStr = [
          `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">`,
          `<foreignObject width="100%" height="100%">`,
          `<div xmlns="http://www.w3.org/1999/xhtml">`,
          `<style>`,
          css,
          `.katex-display{margin:0!important}.katex{font-size:${fs}px!important}`,
          `</style>`,
          `<div style="display:flex;align-items:center;justify-content:center;`,
          `width:${W}px;height:${H}px;padding:${Math.ceil(6*scale)}px;`,
          `box-sizing:border-box;color:${color};overflow:hidden;">`,
          html,
          `</div></div></foreignObject></svg>`,
        ].join('');
        const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
        const url  = URL.createObjectURL(blob);
        const img  = new Image();
        img.onload = () => {
          URL.revokeObjectURL(url);
          stroke._formulaImg = img;
          this._staticDirty = true;
          this.render();
        };
        img.onerror = () => {
          URL.revokeObjectURL(url);
          stroke._formulaImg = false; // use false (not null) to avoid re-render loop
          this._staticDirty = true;
          this.render();
        };
        img.src = url;
      } catch { stroke._formulaImg = false; }
    });
  }

  _renderTable(ctx, stroke) {
    const d   = stroke.data;
    const [cx, cy] = this._toCanvas(d.x, d.y);
    const cw  = (d.w / Whiteboard.VW) * (this._cssW || 300);
    const ch  = (d.h / Whiteboard.VH) * (this._cssH || 150);
    const cellW  = cw / d.cols;
    const cellH  = ch / d.rows;
    const fs     = Math.round(((d.fontSize || 14) / Whiteboard.VH) * (this._cssH || 150));
    const border = d.borderColor || '#9B5DE5';
    const bg     = d.bgColor     || 'rgba(26,22,37,0.85)';
    const tc     = d.textColor   || '#e8e0f7';

    ctx.save();
    ctx.globalCompositeOperation = 'source-over';
    if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); }

    // Background
    ctx.fillStyle = bg;
    ctx.beginPath();
    ctx.roundRect(cx, cy, cw, ch, 4);
    ctx.fill();

    // Grid
    ctx.strokeStyle = border;
    ctx.lineWidth   = 1;
    ctx.beginPath();
    ctx.roundRect(cx, cy, cw, ch, 4);
    ctx.stroke();

    for (let c = 1; c < d.cols; c++) {
      const x = cx + c * cellW;
      ctx.beginPath();
      ctx.moveTo(x, cy); ctx.lineTo(x, cy + ch); ctx.stroke();
    }
    for (let r = 1; r < d.rows; r++) {
      const y = cy + r * cellH;
      ctx.beginPath();
      ctx.moveTo(cx, y); ctx.lineTo(cx + cw, y); ctx.stroke();
    }

    // Cell text
    ctx.fillStyle    = tc;
    ctx.font         = `${fs}px 'Manrope', sans-serif`;
    ctx.textBaseline = 'middle';
    const pad = Math.max(4, cellW * 0.06);

    for (let r = 0; r < d.rows; r++) {
      for (let c = 0; c < d.cols; c++) {
        const text = (d.cells && d.cells[r] && d.cells[r][c]) || '';
        if (!text) continue;
        ctx.save();
        ctx.beginPath();
        ctx.rect(cx + c * cellW + 1, cy + r * cellH + 1, cellW - 2, cellH - 2);
        ctx.clip();
        ctx.fillText(text, cx + c * cellW + pad, cy + r * cellH + cellH / 2);
        ctx.restore();
      }
    }
    ctx.restore();
  }

  /* ── select tool helpers ─────────────────────────────────────────────── */

  _hitTestObject(vx, vy) {
    for (let i = this._strokes.length - 1; i >= 0; i--) {
      const s = this._strokes[i];
      if (!this._isObjectStroke(s)) continue;
      const d = s.data;
      if (vx >= d.x && vx <= d.x + d.w && vy >= d.y && vy <= d.y + d.h) return s;
    }
    return null;
  }

  _hitTestObjectHandle(vx, vy, stroke) {
    const r = (12 / (this._cssW || 300)) * Whiteboard.VW;
    const b = this._getStrokeBBox(stroke);
    // Rotation handle (object strokes only): above top-center
    if (this._isObjectStroke(stroke)) {
      const rotVX = b.x + b.w / 2;
      const rotVY = b.y - (28 / (this._cssH || 150)) * Whiteboard.VH;
      if (Math.abs(vx - rotVX) < r && Math.abs(vy - rotVY) < r) return 'rot';
    }
    const corners = {
      tl: [b.x,       b.y],
      tr: [b.x + b.w, b.y],
      bl: [b.x,       b.y + b.h],
      br: [b.x + b.w, b.y + b.h],
    };
    for (const [name, [hx, hy]] of Object.entries(corners)) {
      if (Math.abs(vx - hx) < r && Math.abs(vy - hy) < r) return name;
    }
    return null;
  }

  _renderObjectSelection(ctx, stroke) {
    const b  = this._getStrokeBBox(stroke);
    const [cx, cy] = this._toCanvas(b.x, b.y);
    const cw = (b.w / Whiteboard.VW) * (this._cssW || 300);
    const ch = (b.h / Whiteboard.VH) * (this._cssH || 150);
    const HS = 8;

    ctx.save();
    ctx.strokeStyle = '#06D6E0';
    ctx.lineWidth   = 1.5;
    ctx.setLineDash([5, 3]);
    ctx.strokeRect(cx, cy, cw, ch);
    ctx.setLineDash([]);

    // Resize handles only for resizable strokes
    if (this._isResizableStroke(stroke)) {
      for (const [hx, hy] of [[cx, cy], [cx + cw, cy], [cx, cy + ch], [cx + cw, cy + ch]]) {
        ctx.fillStyle   = '#ffffff';
        ctx.strokeStyle = '#06D6E0';
        ctx.lineWidth   = 1.5;
        ctx.fillRect(hx - HS / 2, hy - HS / 2, HS, HS);
        ctx.strokeRect(hx - HS / 2, hy - HS / 2, HS, HS);
      }
    }
    // Rotation handle (object strokes only)
    if (this._isObjectStroke(stroke)) {
      const rotCx = cx + cw / 2;
      const rotCy = cy - 28;
      ctx.strokeStyle = '#9B5DE5';
      ctx.lineWidth   = 1.5;
      ctx.setLineDash([3, 3]);
      ctx.beginPath(); ctx.moveTo(cx + cw / 2, cy); ctx.lineTo(rotCx, rotCy); ctx.stroke();
      ctx.setLineDash([]);
      ctx.fillStyle   = '#ffffff';
      ctx.strokeStyle = '#9B5DE5';
      ctx.beginPath(); ctx.arc(rotCx, rotCy, 6, 0, Math.PI * 2);
      ctx.fill(); ctx.stroke();
    }
    ctx.restore();
  }

  /* ── public API ─────────────────────────────────────────────────────── */

  setTool(name) {
    this._removeTextInput();
    this._removeObjectInput();
    this._drawing    = false;
    this._shapeStart = null;
    if (name !== 'select') {
      this._selectedId = null;
      this._dragState  = null;
      this._canvas.style.cursor = '';
    }
    this._tool = name;
  }

  setColor(hex)       {
    this._color = hex;
    if (this._textInput) this._textInput.style.color = hex;
  }
  setWidth(px)        { this._width     = px; }
  setFill(v)          { this._fill      = v; }
  setTextFontSize(n)  { this._textFontSize   = Math.max(8, Math.min(120, n)); }
  setTextFontFamily(f){ this._textFontFamily = f; }
  setTextBold(v)      { this._textBold       = v; }
  setTextItalic(v)    { this._textItalic     = v; }
  setTextAlign(v)     { this._textAlign = v || 'left'; if (this._textInput) this._textInput.style.textAlign = v; }
  setReadOnly(v)      { this._readOnly  = v; }
  setLineStyle(style) { this._lineStyle = style; }
  setOpacity(v)       { this._opacity   = Math.max(0.05, Math.min(1, v)); }
  setStylusMultiplier(v) { this._stylusMultiplier = v; }
  setBoardTheme(name) { this._boardTheme = name || 'chalkboard'; this._bgNoiseCache.delete(name); this._staticDirty = true; this.render(); }
  setStickyColor(c)   { this._stickyColor = c || null; }
  setTableSize(r, c)  { this._tableRows = Math.max(1, r); this._tableCols = Math.max(1, c); }

  // Toggle arrowStart or arrowEnd on the selected connector
  toggleConnectorArrow(which) {
    if (this._selectedIds.size !== 1) return;
    const sel = this._strokes.find(s => s.id === this._selectedId);
    if (!sel || sel.tool !== 'connector') return;
    sel.data[which] = !sel.data[which];
    this._staticDirty = true;
    this.render();
    if (this._onStrokeUpdated) this._onStrokeUpdated(sel);
  }

  // Align all selected strokes by edge/center
  alignStrokes(direction) {
    if (this._selectedIds.size < 2) return;
    const items = [];
    for (const id of this._selectedIds) {
      const s = this._strokes.find(x => x.id === id);
      if (s) items.push({ stroke: s, bbox: this._getStrokeBBox(s) });
    }
    let ref;
    if      (direction === 'left')    ref = Math.min(...items.map(b => b.bbox.x));
    else if (direction === 'right')   ref = Math.max(...items.map(b => b.bbox.x + b.bbox.w));
    else if (direction === 'top')     ref = Math.min(...items.map(b => b.bbox.y));
    else if (direction === 'bottom')  ref = Math.max(...items.map(b => b.bbox.y + b.bbox.h));
    else if (direction === 'centerH') ref = (Math.min(...items.map(b => b.bbox.x)) + Math.max(...items.map(b => b.bbox.x + b.bbox.w))) / 2;
    else if (direction === 'centerV') ref = (Math.min(...items.map(b => b.bbox.y)) + Math.max(...items.map(b => b.bbox.y + b.bbox.h))) / 2;
    for (const { stroke, bbox } of items) {
      let dvx = 0, dvy = 0;
      if      (direction === 'left')    dvx = ref - bbox.x;
      else if (direction === 'right')   dvx = ref - (bbox.x + bbox.w);
      else if (direction === 'top')     dvy = ref - bbox.y;
      else if (direction === 'bottom')  dvy = ref - (bbox.y + bbox.h);
      else if (direction === 'centerH') dvx = ref - (bbox.x + bbox.w / 2);
      else if (direction === 'centerV') dvy = ref - (bbox.y + bbox.h / 2);
      if (dvx !== 0 || dvy !== 0) {
        this._moveStroke(stroke, dvx, dvy);
        if (this._onStrokeUpdated) this._onStrokeUpdated(stroke);
      }
    }
    this._staticDirty = true;
    this.render();
  }
  get _w() { return this._effectiveWidth ?? this._width; }
  setTemplate(name)   { this._template  = name || 'blank'; this._staticDirty = true; this.render(); }
  setPageNum(n)       { this._pageNum   = n; }

  /* ── Insert pre-built educational template ───────────────────────────── */
  insertTemplate(name) {
    const VW = Whiteboard.VW, VH = Whiteboard.VH;
    // Center of current viewport in virtual coords
    const [vcx, vcy] = this._toVirtual((this._cssW || VW) / 2, (this._cssH || VH) / 2);
    const cx = vcx, cy = vcy;

    const mk = (tool, data) => ({ tool, data: { ...data } });
    const sh = (shape, x1, y1, x2, y2, color = '#06D6E0', width = 3, fill = false, extra = {}) =>
      mk('shape', { shape, x1: cx + x1, y1: cy + y1, x2: cx + x2, y2: cy + y2, color, width, fill, ...extra });
    const tx = (x, y, text, color = '#e8e0f7', fontSize = 18) =>
      mk('text',  { x: cx + x, y: cy + y, text, color, fontSize });

    const tpls = {
      venn2: () => [
        sh('ellipse', -310, -200, +90, +200, '#06D6E0'),
        sh('ellipse', -90,  -200, +310, +200, '#F15BB5'),
      ],
      venn3: () => [
        sh('ellipse', -250, -220, +50,  +100, '#06D6E0'),
        sh('ellipse', -50,  -220, +250, +100, '#F15BB5'),
        sh('ellipse', -140, -80,  +140, +240, '#FFE066'),
      ],
      tchart: () => [
        sh('rect',  -390, -230, +390, +230, '#9B5DE5', 2),
        sh('line',  0,    -230, 0,    +230, '#9B5DE5', 2),
        sh('line',  -390, -150, +390, -150, '#9B5DE5', 2),
        tx(-290, -210, 'Колонка 1', '#9B5DE5'),
        tx( +20,  -210, 'Колонка 2', '#9B5DE5'),
      ],
      timeline: () => [
        sh('line', -460, 0, +460, 0, '#06D6E0', 3),
        sh('line',  462, -8, 462, 8, '#06D6E0', 0, false), // pseudo arrow tip
        ...Array.from({ length: 5 }, (_, i) => {
          const bx = -360 + i * 180;
          return sh('line', bx, -35, bx, +35, '#06D6E0', 2);
        }),
        ...Array.from({ length: 5 }, (_, i) =>
          tx(-378 + i * 180, +48, `Событие ${i + 1}`, '#9ca3af', 15)
        ),
      ],
      quadrant: () => [
        sh('line',  -380, 0,    +380, 0,    '#9B5DE5', 2),
        sh('line',  0,    -260, 0,    +260, '#9B5DE5', 2),
        tx(+20,  -250, 'I',   'rgba(155,93,229,0.65)', 22),
        tx(-70,  -250, 'II',  'rgba(155,93,229,0.65)', 22),
        tx(-80,  +210, 'III', 'rgba(155,93,229,0.65)', 22),
        tx(+20,  +210, 'IV',  'rgba(155,93,229,0.65)', 22),
      ],
      pyramid: () => {
        const levels = 4;
        return [
          sh('triangle', -300, -230, +300, +230, '#06D6E0', 2),
          ...Array.from({ length: levels - 1 }, (_, i) => {
            const t = (i + 1) / levels;
            const y = -230 + t * 460;
            const halfW = 300 * t;
            return sh('line', -halfW, y, +halfW, y, '#06D6E0', 1);
          }),
        ];
      },
      swot: () => [
        sh('rect',  -400, -250, +400, +250, '#9B5DE5', 2),
        sh('line',  0,    -250, 0,    +250, '#9B5DE5', 2),
        sh('line',  -400, 0,    +400, 0,    '#9B5DE5', 2),
        tx(-380, -240, 'Strengths',   '#06D6E0', 18),
        tx(  20, -240, 'Weaknesses',  '#F15BB5', 18),
        tx(-380,  +15, 'Opportunities','#A8E063', 18),
        tx(  20,  +15, 'Threats',     '#FF6B6B', 18),
      ],
    };

    const strokes = (tpls[name] ? tpls[name]() : []);
    if (!strokes.length) return;
    const newIds = [];
    for (const s of strokes) {
      s.id = this._localIdCounter--;
      this._strokes.push(s);
      this._undoStack.push(s.id);
      newIds.push(s.id);
      if (this._onStrokeDone) this._onStrokeDone(s);
    }
    this._redoStack = [];
    this._staticDirty = true;
    this._selectedIds.clear();
    for (const id of newIds) this._selectedIds.add(id);
    this.render();
  }

  exportPNG() {
    const off = document.createElement('canvas');
    off.width = Whiteboard.VW; off.height = Whiteboard.VH;
    const ctx = off.getContext('2d');
    const [sw, sh, sz, spx, spy] = [this._cssW, this._cssH, this._zoom, this._panVX, this._panVY];
    this._cssW = Whiteboard.VW; this._cssH = Whiteboard.VH;
    this._zoom = 1; this._panVX = 0; this._panVY = 0;
    this._renderBg(ctx);
    if (this._template && this._template !== 'blank') this._renderTemplate(ctx);
    for (const s of this._strokes) this._renderStroke(ctx, s);
    this._cssW = sw; this._cssH = sh; this._zoom = sz; this._panVX = spx; this._panVY = spy;
    off.toBlob(blob => {
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = `whiteboard-p${this._pageNum}.png`;
      document.body.appendChild(a); a.click(); document.body.removeChild(a);
      setTimeout(() => URL.revokeObjectURL(a.href), 3000);
    }, 'image/png');
  }

  _renderMinimap() {
    if (!this._mmCanvas) return;
    const visible = this._zoom > 1.04;
    this._mmCanvas.style.display = visible ? 'block' : 'none';
    if (!visible) return;

    const mm  = this._mmCtx;
    const MW  = 192, MH = 108;

    // Render full board at zoom=1 (same approach as renderThumbnail)
    const [sw, sh, sz, spx, spy] = [this._cssW, this._cssH, this._zoom, this._panVX, this._panVY];
    this._cssW = MW; this._cssH = MH;
    this._zoom = 1; this._panVX = 0; this._panVY = 0;
    this._renderBg(mm);
    if (this._template && this._template !== 'blank') this._renderTemplate(mm);
    for (const s of this._strokes) this._renderStroke(mm, s);
    // Restore
    this._cssW = sw; this._cssH = sh; this._zoom = sz; this._panVX = spx; this._panVY = spy;

    // Viewport indicator
    const vpW = MW / sz;
    const vpH = MH / sz;
    const vpX = (spx / Whiteboard.VW) * MW;
    const vpY = (spy / Whiteboard.VH) * MH;

    // Dark overlay on non-viewport areas
    mm.fillStyle = 'rgba(0,0,0,0.42)';
    mm.fillRect(0,         0,         MW,              vpY);
    mm.fillRect(0,         vpY + vpH, MW,              MH - vpY - vpH);
    mm.fillRect(0,         vpY,       vpX,             vpH);
    mm.fillRect(vpX + vpW, vpY,       MW - vpX - vpW,  vpH);

    // Viewport border
    mm.strokeStyle = 'rgba(155,93,229,0.95)';
    mm.lineWidth   = 1.5;
    mm.strokeRect(vpX, vpY, vpW, vpH);

    // Crosshair at viewport center
    const cx = vpX + vpW / 2, cy = vpY + vpH / 2;
    mm.strokeStyle = 'rgba(155,93,229,0.6)';
    mm.lineWidth   = 0.8;
    mm.beginPath(); mm.moveTo(cx - 5, cy); mm.lineTo(cx + 5, cy); mm.stroke();
    mm.beginPath(); mm.moveTo(cx, cy - 5); mm.lineTo(cx, cy + 5); mm.stroke();
  }

  _mmNavigate(e) {
    const rect = this._mmCanvas.getBoundingClientRect();
    const mx = (e.clientX - rect.left) * (192 / rect.width);
    const my = (e.clientY - rect.top)  * (108 / rect.height);
    // Center the viewport on the clicked virtual position
    this._panVX = (mx / 192) * Whiteboard.VW - Whiteboard.VW / (2 * this._zoom);
    this._panVY = (my / 108) * Whiteboard.VH - Whiteboard.VH / (2 * this._zoom);
    this._clampPan();
    this._staticDirty = true;
    this.render();
  }

  renderThumbnail(targetCanvas) {
    const ctx = targetCanvas.getContext('2d');
    const [sw, sh, sz, spx, spy] = [this._cssW, this._cssH, this._zoom, this._panVX, this._panVY];
    this._cssW = targetCanvas.width; this._cssH = targetCanvas.height;
    this._zoom = 1; this._panVX = 0; this._panVY = 0;
    this._renderBg(ctx);
    if (this._template && this._template !== 'blank') this._renderTemplate(ctx);
    for (const s of this._strokes) this._renderStroke(ctx, s);
    this._cssW = sw; this._cssH = sh; this._zoom = sz; this._panVX = spx; this._panVY = spy;
  }

  updateOpacity(v) {
    if (this._selectedId == null) return;
    const sel = this._strokes.find(s => s.id === this._selectedId);
    if (!sel) return;
    sel.data.opacity = Math.max(0.05, Math.min(1, v));
    this._staticDirty = true;
    this.render();
    if (this._onStrokeUpdated) this._onStrokeUpdated(sel);
  }

  /* ── formula insert (called by external modal) ──────────────────────── */

  insertFormula(vx, vy, latex, fontSize = 32) {
    if (!latex) return;
    this._editingFormulaStroke = null; // clear edit-mode flag
    const W = Math.round(460 * (fontSize / 32));
    const H = Math.round(160 * (fontSize / 32));
    const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W));
    const y = Math.max(0, Math.min(vy - H / 2, Whiteboard.VH - H));
    const stroke = {
      id: this._localIdCounter--, tool: 'formula',
      data: { x, y, w: W, h: H, latex, fontSize, color: this._color },
    };
    this._strokes.push(stroke);
    this._undoStack.push(stroke.id);
    this._redoStack = [];
    this._selectedId = stroke.id;
    this._staticDirty = true;
    this.render();
    if (this._onStrokeDone)    this._onStrokeDone(stroke);
    if (this._onObjectCreated) this._onObjectCreated(stroke);
  }

  /* Called by external modal when editing an existing formula */
  updateFormula(stroke, latex, fontSize) {
    if (!stroke || !latex) return;
    stroke.data.latex    = latex;
    stroke.data.fontSize = fontSize || stroke.data.fontSize;
    stroke._formulaImg   = null;
    this._editingFormulaStroke = null;
    this._staticDirty = true;
    this.render();
    if (this._onStrokeUpdated) this._onStrokeUpdated(stroke);
  }

  /* ── Clipboard paste (system clipboard: images & text) ──────────────── */

  _onClipboardPaste(e) {
    if (this._readOnly) return;
    // Don't intercept paste when a real input/textarea is focused
    const tag = document.activeElement?.tagName;
    if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return;

    const items = e.clipboardData?.items;
    if (!items) return;

    // Prefer image over text
    for (const item of Array.from(items)) {
      if (item.type.startsWith('image/')) {
        e.preventDefault();
        const blob = item.getAsFile();
        if (blob) this._pasteImageBlob(blob);
        return;
      }
    }
    for (const item of Array.from(items)) {
      if (item.type === 'text/plain') {
        e.preventDefault();
        item.getAsString(text => {
          if (!text.trim()) return;
          // Place pasted text at upper-left area of the board
          const stroke = {
            id: this._localIdCounter--, tool: 'text',
            data: { text: text.trim(), x: 80, y: 80, fontSize: 22, color: this._color },
          };
          this._strokes.push(stroke);
          this._undoStack.push(stroke.id);
          this._redoStack = [];
          this._selectedId = stroke.id;
          this._staticDirty = true;
          this.render();
          if (this._onStrokeDone) this._onStrokeDone(stroke);
        });
        return;
      }
    }
  }

  _pasteImageBlob(blob) {
    const maxPx = 800;
    const reader = new FileReader();
    reader.onload = ev => {
      const img = new Image();
      img.onload = () => {
        let pw = img.naturalWidth, ph = img.naturalHeight;
        if (pw > maxPx || ph > maxPx) {
          if (pw >= ph) { ph = Math.round(ph * maxPx / pw); pw = maxPx; }
          else          { pw = Math.round(pw * maxPx / ph); ph = maxPx; }
        }
        const tmp = document.createElement('canvas');
        tmp.width = pw; tmp.height = ph;
        tmp.getContext('2d').drawImage(img, 0, 0, pw, ph);
        const src = tmp.toDataURL('image/jpeg', 0.8);
        // Virtual dimensions: fit into ~800×600 vp, maintain aspect ratio
        const maxVW = 800, maxVH = 600;
        let vw = maxVW, vh = Math.round(maxVW * ph / pw);
        if (vh > maxVH) { vh = maxVH; vw = Math.round(maxVH * pw / ph); }
        const vx = Math.round((Whiteboard.VW - vw) / 2);
        const vy = Math.round((Whiteboard.VH - vh) / 2);
        const stroke = {
          id: this._localIdCounter--, tool: 'image',
          data: { src, x: vx, y: vy, w: vw, h: vh },
        };
        this._strokes.push(stroke);
        this._undoStack.push(stroke.id);
        this._redoStack = [];
        this._selectedId = stroke.id;
        this._staticDirty = true;
        this.render();
        if (this._onStrokeDone)    this._onStrokeDone(stroke);
        if (this._onObjectCreated) this._onObjectCreated(stroke);
      };
      img.src = ev.target.result;
    };
    reader.readAsDataURL(blob);
  }

  /* ── page background image ──────────────────────────────────────────── */

  pasteImageAsBackground(blob) {
    const reader = new FileReader();
    reader.onload = ev => {
      const img = new Image();
      img.onload = () => {
        // Resize to max 1920 for reasonable data size
        const maxPx = 1920;
        let pw = img.naturalWidth, ph = img.naturalHeight;
        if (pw > maxPx || ph > maxPx) {
          if (pw >= ph) { ph = Math.round(ph * maxPx / pw); pw = maxPx; }
          else          { pw = Math.round(pw * maxPx / ph); ph = maxPx; }
        }
        const tmp = document.createElement('canvas');
        tmp.width = pw; tmp.height = ph;
        tmp.getContext('2d').drawImage(img, 0, 0, pw, ph);
        const src = tmp.toDataURL('image/jpeg', 0.85);
        this.removePageBackground();
        const stroke = {
          id: this._localIdCounter--, tool: 'image',
          data: { src, x: 0, y: 0, w: Whiteboard.VW, h: Whiteboard.VH,
                  isBackground: true, fit: 'contain' },
        };
        this._strokes.unshift(stroke);
        this._undoStack.push(stroke.id);
        this._redoStack = [];
        this._staticDirty = true;
        this.render();
        if (this._onStrokeDone) this._onStrokeDone(stroke);
      };
      img.src = ev.target.result;
    };
    reader.readAsDataURL(blob);
  }

  removePageBackground() {
    const idx = this._strokes.findIndex(s => s.tool === 'image' && s.data.isBackground);
    if (idx === -1) return;
    const stroke = this._strokes.splice(idx, 1)[0];
    const ui = this._undoStack.indexOf(stroke.id);
    if (ui !== -1) this._undoStack.splice(ui, 1);
    this._staticDirty = true;
    this.render();
    if (this._onStrokeUndo) this._onStrokeUndo(stroke.id);
  }

  hasPageBackground() {
    return this._strokes.some(s => s.tool === 'image' && s.data.isBackground);
  }

  /* ── copy / paste / delete / z-order ─────────────────────────────────── */

  copy() {
    if (this._selectedId == null) return;
    const sel = this._strokes.find(s => s.id === this._selectedId);
    if (!sel) return;
    this._clipboard = { tool: sel.tool, data: JSON.parse(JSON.stringify(sel.data)) };
  }

  paste() {
    if (!this._clipboard) return;
    const data = JSON.parse(JSON.stringify(this._clipboard.data));
    const OFF = 30;
    if (data.x1 != null) {
      // shape / connector
      data.x1 += OFF; data.y1 += OFF; data.x2 += OFF; data.y2 += OFF;
    } else if (data.points) {
      // pencil / highlighter
      data.points = data.points.map(([px, py]) => [px + OFF, py + OFF]);
    } else if (data.x != null && data.w != null) {
      // object stroke (image/sticky/formula/table/text with w)
      data.x = Math.min(data.x + OFF, Whiteboard.VW - (data.w || 10));
      data.y = Math.min(data.y + OFF, Whiteboard.VH - (data.h || 10));
    } else if (data.x != null) {
      // text (no w)
      data.x += OFF; data.y += OFF;
    }
    const stroke = { id: this._localIdCounter--, tool: this._clipboard.tool, data };
    this._strokes.push(stroke);
    this._undoStack.push(stroke.id);
    this._redoStack = [];
    this._selectedId = stroke.id;
    this._staticDirty = true;
    this.render();
    if (this._onStrokeDone) this._onStrokeDone(stroke);
  }

  deleteSelected() {
    if (this._selectedIds.size === 0) return;
    const ids = [...this._selectedIds];
    this._selectedIds.clear();
    this._dragState = null;
    this._strokes = this._strokes.filter(s => !ids.includes(s.id));
    for (const id of ids) {
      const ui = this._undoStack.indexOf(id);
      if (ui !== -1) this._undoStack.splice(ui, 1);
    }
    const deletedIds = new Set(ids);
    for (const s of this._strokes) {
      if (s.tool !== 'connector') continue;
      if (deletedIds.has(s.data.fromId)) { delete s.data.fromId; delete s.data.fromAnchor; }
      if (deletedIds.has(s.data.toId))   { delete s.data.toId;   delete s.data.toAnchor; }
    }
    this._staticDirty = true;
    this.render();
    for (const id of ids) { if (this._onStrokeUndo) this._onStrokeUndo(id); }
  }

  bringToFront() {
    if (this._selectedIds.size === 0) return;
    const toMove = this._strokes.filter(s => this._selectedIds.has(s.id));
    this._strokes = this._strokes.filter(s => !this._selectedIds.has(s.id));
    this._strokes.push(...toMove);
    this._staticDirty = true;
    this.render();
    for (const s of toMove) { if (this._onStrokeUpdated) this._onStrokeUpdated(s); }
  }

  sendToBack() {
    if (this._selectedIds.size === 0) return;
    const toMove = this._strokes.filter(s => this._selectedIds.has(s.id));
    this._strokes = this._strokes.filter(s => !this._selectedIds.has(s.id));
    this._strokes.unshift(...toMove);
    this._staticDirty = true;
    this.render();
    for (const s of toMove) { if (this._onStrokeUpdated) this._onStrokeUpdated(s); }
  }

  /* ── strokes management ─────────────────────────────────────────────── */

  loadStrokes(strokes) {
    this._strokes = (strokes || []).map(s => this._normalise(s));
    this._liveStrokes.clear();
    this._staticDirty = true;
    this.render();
  }

  /* Returns strokes not yet confirmed by server (negative IDs = locally drawn). */
  getLocalStrokes() {
    return this._strokes.filter(s => s.id < 0);
  }

  addStrokes(strokes) {
    let changed = false;
    for (const s of strokes) {
      if (this._strokes.some(x => x.id === s.id)) continue;
      this._strokes.push(this._normalise(s));
      changed = true;
    }
    if (changed) { this._staticDirty = true; this.render(); }
  }

  _normalise(s) {
    return {
      id:   s.id,
      tool: s.tool,
      data: typeof s.data === 'string' ? JSON.parse(s.data) : s.data,
    };
  }

  removeStroke(id) {
    this._strokes = this._strokes.filter(s => s.id !== id);
    if (this._selectedIds.has(id)) { this._selectedIds.delete(id); this._dragState = null; }
    this._staticDirty = true;
    this.render();
  }

  updateStroke(id, data) {
    const s = this._strokes.find(x => x.id === id);
    if (!s) return;
    s.data = typeof data === 'string' ? JSON.parse(data) : { ...data };
    s._img        = null;
    s._formulaImg = null;
    this._staticDirty = true;
    this.render();
  }

  confirmStroke(localId, serverId) {
    const s = this._strokes.find(x => x.id === localId);
    if (s) {
      s.id = serverId;
      const before = this._strokes.length;
      this._strokes = this._strokes.filter(x => x === s || x.id !== serverId);
      if (this._strokes.length !== before) { this._staticDirty = true; this.render(); }
    }
    const i = this._undoStack.indexOf(localId);
    if (i !== -1) this._undoStack[i] = serverId;
  }

  undo() {
    if (this._undoStack.length === 0) return;
    const id = this._undoStack.pop();
    const stroke = this._strokes.find(s => s.id === id);
    this._strokes = this._strokes.filter(s => s.id !== id);
    if (stroke) this._redoStack.push(stroke);
    this._staticDirty = true;
    this.render();
    if (this._onStrokeUndo) this._onStrokeUndo(id);
  }

  redo() {
    if (this._redoStack.length === 0) return;
    const stroke = this._redoStack.pop();
    this._strokes.push(stroke);
    this._undoStack.push(stroke.id);
    this._staticDirty = true;
    this.render();
    if (this._onStrokeDone) this._onStrokeDone(stroke);
  }

  clearPage() {
    this._strokes    = [];
    this._undoStack  = [];
    this._redoStack  = [];
    this._curPts     = [];
    this._drawing    = false;
    this._shapeStart = null;
    this._selectedIds.clear();
    this._dragState  = null;
    this._lassoRect  = null;
    this._snapGuides = [];
    this._liveStrokes.clear();
    this._removeTextInput();
    this._removeObjectInput();
    this._staticDirty = true;
    this.render();
  }

  destroy() {
    if (this._ro) this._ro.disconnect();
    this._removeTextInput();
    this._removeObjectInput();
    document.removeEventListener('keydown',          this._onKeyDown);
    document.removeEventListener('keyup',            this._onKeyUp);
    document.removeEventListener('paste',            this._onPaste);
    document.removeEventListener('visibilitychange', this._visHandler);
    if (this._dynCanvas?.parentElement) this._dynCanvas.parentElement.removeChild(this._dynCanvas);
    if (this._mmCanvas?.parentElement)  this._mmCanvas.parentElement.removeChild(this._mmCanvas);
  }

  /* ── Phase 5: Coordinate system stroke ─────────────────────────────── */

  _renderCoordinate(ctx, stroke) {
    const d    = stroke.data;
    const [cx, cy] = this._toCanvas(d.x, d.y);
    const cw   = (d.w / Whiteboard.VW) * (this._cssW || 300);
    const ch   = (d.h / Whiteboard.VH) * (this._cssH || 150);
    const xMin = d.xMin ?? -10, xMax = d.xMax ?? 10;
    const yMin = d.yMin ?? -10, yMax = d.yMax ?? 10;
    const step = d.gridStep || 1;

    ctx.save();
    if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); }
    // Background
    ctx.fillStyle   = 'rgba(18,13,30,0.88)';
    ctx.strokeStyle = 'rgba(155,93,229,0.35)';
    ctx.lineWidth   = 1;
    ctx.beginPath();
    if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, 6);
    else ctx.rect(cx, cy, cw, ch);
    ctx.fill(); ctx.stroke();

    ctx.beginPath(); ctx.rect(cx, cy, cw, ch); ctx.clip();

    const xRange = xMax - xMin, yRange = yMax - yMin;
    const scaleX = cw / xRange, scaleY = ch / yRange;
    // Origin in canvas coords (0,0 of math space)
    const ox = cx + (-xMin) * scaleX;
    const oy = cy + yMax * scaleY;

    // Grid lines
    ctx.strokeStyle = 'rgba(255,255,255,0.05)';
    ctx.lineWidth   = 0.5;
    for (let v = Math.ceil(xMin / step) * step; v <= xMax; v += step) {
      const px = cx + (v - xMin) * scaleX;
      ctx.beginPath(); ctx.moveTo(px, cy); ctx.lineTo(px, cy + ch); ctx.stroke();
    }
    for (let v = Math.ceil(yMin / step) * step; v <= yMax; v += step) {
      const py = cy + (yMax - v) * scaleY;
      ctx.beginPath(); ctx.moveTo(cx, py); ctx.lineTo(cx + cw, py); ctx.stroke();
    }

    // Axes
    ctx.strokeStyle = 'rgba(255,255,255,0.55)';
    ctx.lineWidth   = 1.5;
    // X axis
    ctx.beginPath(); ctx.moveTo(cx, oy); ctx.lineTo(cx + cw, oy); ctx.stroke();
    // Y axis
    ctx.beginPath(); ctx.moveTo(ox, cy); ctx.lineTo(ox, cy + ch); ctx.stroke();

    // Tick marks + labels
    if (d.showLabels !== false) {
      ctx.fillStyle   = 'rgba(255,255,255,0.45)';
      ctx.font        = `${Math.max(8, Math.round(9 * cw / 400))}px Manrope,sans-serif`;
      ctx.textAlign   = 'center';
      ctx.textBaseline = 'top';
      ctx.strokeStyle = 'rgba(255,255,255,0.3)';
      ctx.lineWidth   = 0.8;
      const tickH = 4;
      for (let v = Math.ceil(xMin / step) * step; v <= xMax; v += step) {
        if (Math.abs(v) < 1e-9) continue;
        const px = cx + (v - xMin) * scaleX;
        ctx.beginPath(); ctx.moveTo(px, oy - tickH); ctx.lineTo(px, oy + tickH); ctx.stroke();
        ctx.fillText(v, px, oy + tickH + 1);
      }
      ctx.textAlign    = 'right';
      ctx.textBaseline = 'middle';
      for (let v = Math.ceil(yMin / step) * step; v <= yMax; v += step) {
        if (Math.abs(v) < 1e-9) continue;
        const py = cy + (yMax - v) * scaleY;
        ctx.beginPath(); ctx.moveTo(ox - tickH, py); ctx.lineTo(ox + tickH, py); ctx.stroke();
        ctx.fillText(v, ox - tickH - 2, py);
      }
      ctx.fillText('0', ox - tickH - 2, oy + tickH + 1);
    }

    // Arrow heads on axes
    const aw = 6, ah = 4;
    ctx.fillStyle = 'rgba(255,255,255,0.55)';
    // X arrow right
    const xEnd = cx + cw;
    ctx.beginPath(); ctx.moveTo(xEnd, oy); ctx.lineTo(xEnd - aw, oy - ah); ctx.lineTo(xEnd - aw, oy + ah); ctx.closePath(); ctx.fill();
    // Y arrow up
    ctx.beginPath(); ctx.moveTo(ox, cy); ctx.lineTo(ox - ah, cy + aw); ctx.lineTo(ox + ah, cy + aw); ctx.closePath(); ctx.fill();

    // Plot functions
    for (const fn of (d.functions || [])) {
      ctx.strokeStyle = fn.color || '#06D6E0';
      ctx.lineWidth   = 1.5;
      ctx.globalAlpha = 0.9;
      ctx.beginPath();
      let started = false;
      for (let px = cx; px <= cx + cw; px += 0.8) {
        const mathX = xMin + (px - cx) / scaleX;
        let mathY;
        try { mathY = this._evalMath(fn.expr, mathX); } catch { continue; }
        if (!isFinite(mathY)) { started = false; continue; }
        const py = oy - mathY * scaleY;
        if (!started) { ctx.moveTo(px, py); started = true; } else ctx.lineTo(px, py);
      }
      ctx.stroke();
      ctx.globalAlpha = 1;
    }

    ctx.restore();
  }

  /* Simple recursive descent math expression evaluator */
  _evalMath(expr, x) {
    const tokens = this._mathTokenise(expr.replace(/\s+/g, ''));
    let pos = 0;
    const peek = () => tokens[pos];
    const consume = () => tokens[pos++];
    const num = () => {
      const t = peek();
      if (t === '(') { consume(); const v = addSub(); consume(/* ')' */); return v; }
      if (t === '-') { consume(); return -num(); }
      if (t === 'x') { consume(); return x; }
      if (/^[a-z]+$/.test(t)) {
        consume();
        const arg = peek() === '(' ? (consume(), (() => { const v = addSub(); consume(); return v; })()) : num();
        const fns = { sin: Math.sin, cos: Math.cos, tan: Math.tan, sqrt: Math.sqrt,
                      abs: Math.abs, log: Math.log, exp: Math.exp, asin: Math.asin,
                      acos: Math.acos, atan: Math.atan, floor: Math.floor, ceil: Math.ceil };
        if (fns[t]) return fns[t](arg);
        if (t === 'pi') { pos--; return Math.PI; }
        if (t === 'e')  { pos--; return Math.E; }
        return NaN;
      }
      consume(); return parseFloat(t);
    };
    const pow = () => { let v = num(); while (peek() === '^') { consume(); v = Math.pow(v, num()); } return v; };
    const mulDiv = () => {
      let v = pow();
      while (peek() === '*' || peek() === '/') {
        const op = consume();
        v = op === '*' ? v * pow() : v / pow();
      }
      return v;
    };
    const addSub = () => {
      let v = mulDiv();
      while (peek() === '+' || peek() === '-') {
        const op = consume();
        v = op === '+' ? v + mulDiv() : v - mulDiv();
      }
      return v;
    };
    return addSub();
  }

  _mathTokenise(expr) {
    const tokens = [];
    let i = 0;
    const consts = { pi: Math.PI, e: Math.E };
    while (i < expr.length) {
      if (/\d|\./.test(expr[i])) {
        let n = '';
        while (i < expr.length && /[\d.]/.test(expr[i])) n += expr[i++];
        tokens.push(n);
      } else if (/[a-z]/i.test(expr[i])) {
        let w = '';
        while (i < expr.length && /[a-z]/i.test(expr[i])) w += expr[i++].toLowerCase();
        if (w === 'pi' || w === 'e') tokens.push(parseFloat(consts[w]).toString());
        else tokens.push(w);
      } else {
        tokens.push(expr[i++]);
      }
    }
    tokens.push(null); // EOF sentinel
    return tokens;
  }

  /* ── Phase 5: Ruler/Protractor overlays ────────────────────────────── */
  // Overlays are rendered on the dynamic layer and are not saved to DB.

  _renderOverlays(ctx) {
    for (const ov of this._overlays) {
      if (ov.type === 'ruler') this._renderRuler(ctx, ov);
      else if (ov.type === 'protractor') this._renderProtractor(ctx, ov);
    }
  }

  _renderRuler(ctx, ov) {
    const [cx, cy] = this._toCanvas(ov.x, ov.y);
    const rulerW = (ov.width / Whiteboard.VW) * (this._cssW || 300);
    const rulerH = Math.max(18, rulerW * 0.07);
    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(ov.angle || 0);
    ctx.fillStyle   = 'rgba(255,230,180,0.15)';
    ctx.strokeStyle = 'rgba(255,230,180,0.5)';
    ctx.lineWidth   = 1;
    ctx.fillRect(0, 0, rulerW, rulerH);
    ctx.strokeRect(0, 0, rulerW, rulerH);
    // Tick marks every ~40vp
    const tickStep = (40 / Whiteboard.VW) * (this._cssW || 300);
    ctx.strokeStyle = 'rgba(255,230,180,0.4)';
    ctx.font = `${Math.max(6, Math.round(7 * rulerW / 300))}px Manrope,sans-serif`;
    ctx.fillStyle = 'rgba(255,230,180,0.5)';
    ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
    for (let t = 0; t * tickStep <= rulerW; t++) {
      const tx = t * tickStep;
      const th = t % 5 === 0 ? rulerH * 0.6 : rulerH * 0.3;
      ctx.beginPath(); ctx.moveTo(tx, rulerH); ctx.lineTo(tx, rulerH - th); ctx.stroke();
      if (t % 5 === 0 && t > 0) ctx.fillText(t, tx, rulerH - th - 1);
    }

    // ── handles ────────────────────────────────────────────────────────────
    // Rotation handle — purple circle above ruler center
    const rotHx = rulerW / 2, rotHy = -20;
    ctx.strokeStyle = 'rgba(155,93,229,0.55)'; ctx.lineWidth = 1;
    ctx.setLineDash([3, 2]);
    ctx.beginPath(); ctx.moveTo(rulerW / 2, 0); ctx.lineTo(rotHx, rotHy); ctx.stroke();
    ctx.setLineDash([]);
    ctx.beginPath(); ctx.arc(rotHx, rotHy, 7, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(155,93,229,0.75)'; ctx.fill();
    ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
    // ↺ symbol inside
    ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText('↺', rotHx, rotHy);

    // Resize handle — cyan circle at right end
    const resHx = rulerW + 10, resHy = rulerH / 2;
    ctx.beginPath(); ctx.arc(resHx, resHy, 7, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(6,214,224,0.75)'; ctx.fill();
    ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
    ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText('↔', resHx, resHy);

    ctx.restore();
  }

  _renderProtractor(ctx, ov) {
    const [cx, cy] = this._toCanvas(ov.x, ov.y);
    const vR = ov.radius || 80;
    const r  = Math.max(30, (vR / Whiteboard.VW) * (this._cssW || 300));
    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(ov.angle || 0);
    ctx.fillStyle   = 'rgba(6,214,224,0.08)';
    ctx.strokeStyle = 'rgba(6,214,224,0.5)';
    ctx.lineWidth   = 1;
    ctx.beginPath();
    ctx.arc(0, 0, r, Math.PI, 0, false); // top semicircle
    ctx.lineTo(-r, 0); ctx.closePath();
    ctx.fill(); ctx.stroke();
    // Degree ticks
    ctx.font = `${Math.max(6, Math.round(7 * r / 80))}px Manrope,sans-serif`;
    ctx.fillStyle = 'rgba(6,214,224,0.6)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    for (let deg = 0; deg <= 180; deg += 10) {
      const rad = (Math.PI - deg * Math.PI / 180);
      const major = deg % 30 === 0;
      const tl = major ? r * 0.15 : r * 0.08;
      ctx.strokeStyle = major ? 'rgba(6,214,224,0.7)' : 'rgba(6,214,224,0.3)';
      ctx.beginPath();
      ctx.moveTo(Math.cos(rad) * (r - tl), Math.sin(rad) * (r - tl));
      ctx.lineTo(Math.cos(rad) * r,        Math.sin(rad) * r);
      ctx.stroke();
      if (major && deg > 0 && deg < 180) {
        const lr = r - tl - 5;
        ctx.fillText(deg, Math.cos(rad) * lr, Math.sin(rad) * lr - 1);
      }
    }

    // ── handles ────────────────────────────────────────────────────────────
    // Rotation handle — above center of flat edge (local y negative = upward)
    const pRotHy = -(r + 20);
    ctx.strokeStyle = 'rgba(155,93,229,0.55)'; ctx.lineWidth = 1;
    ctx.setLineDash([3, 2]);
    ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(0, pRotHy); ctx.stroke();
    ctx.setLineDash([]);
    ctx.beginPath(); ctx.arc(0, pRotHy, 7, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(155,93,229,0.75)'; ctx.fill();
    ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
    ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText('↺', 0, pRotHy);

    // Resize handle — at right end of flat edge
    ctx.beginPath(); ctx.arc(r + 10, 0, 7, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(6,214,224,0.75)'; ctx.fill();
    ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
    ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText('↔', r + 10, 0);

    ctx.restore();
  }

  /* Overlay drag helpers */
  _hitTestOverlay(vx, vy) {
    const [pcx, pcy] = this._toCanvas(vx, vy);
    for (let i = this._overlays.length - 1; i >= 0; i--) {
      const ov = this._overlays[i];
      const [ox, oy] = this._toCanvas(ov.x, ov.y);
      const angle = ov.angle || 0;
      const dx = pcx - ox, dy = pcy - oy;
      // rotate pointer into overlay local space
      const lx =  dx * Math.cos(-angle) - dy * Math.sin(-angle);
      const ly =  dx * Math.sin(-angle) + dy * Math.cos(-angle);

      if (ov.type === 'ruler') {
        const W = (ov.width / Whiteboard.VW) * (this._cssW || 300);
        const H = Math.max(18, W * 0.07);
        if (Math.hypot(lx - W / 2, ly + 20) < 10)       return { idx: i, zone: 'rot' };
        if (Math.hypot(lx - (W + 10), ly - H / 2) < 10) return { idx: i, zone: 'resize' };
        if (lx >= -5 && lx <= W + 5 && ly >= -5 && ly <= H + 5) return { idx: i, zone: 'body' };
      }

      if (ov.type === 'protractor') {
        const vR = ov.radius || 80;
        const r  = Math.max(30, (vR / Whiteboard.VW) * (this._cssW || 300));
        if (Math.hypot(lx,       ly + r + 20) < 10) return { idx: i, zone: 'rot' };
        if (Math.hypot(lx - r - 10, ly)       < 10) return { idx: i, zone: 'resize' };
        const dist = Math.hypot(lx, ly);
        if (dist < r && ly <= 0) return { idx: i, zone: 'body' };
        if (Math.abs(ly) < 8 && Math.abs(lx) <= r) return { idx: i, zone: 'body' };
      }
    }
    return null;
  }

  /* ── Number line stroke ─────────────────────────────────────────────── */

  _renderNumberLine(ctx, stroke) {
    const d = stroke.data;
    const [cx, cy] = this._toCanvas(d.x, d.y);
    const cw = (d.w / Whiteboard.VW) * (this._cssW || 300);
    const ch = (d.h / Whiteboard.VH) * (this._cssH || 150);
    const min = d.min ?? -10, max = d.max ?? 10;
    const step = d.step || 1;
    const range = max - min;

    ctx.save();
    if (d.rotation) { const [ocx, ocy] = [cx + cw / 2, cy + ch / 2]; ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); }

    // Background
    ctx.fillStyle = 'rgba(18,13,30,0.82)';
    ctx.strokeStyle = 'rgba(155,93,229,0.3)';
    ctx.lineWidth = 1;
    if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, 6); else ctx.rect(cx, cy, cw, ch);
    ctx.fill(); ctx.stroke();

    const axY  = cy + ch / 2;
    const padX = cw * 0.04;
    const axX1 = cx + padX, axX2 = cx + cw - padX;
    const scaleX = (axX2 - axX1) / range;

    // Main axis line
    ctx.strokeStyle = 'rgba(255,255,255,0.75)';
    ctx.lineWidth   = 1.5;
    ctx.beginPath();
    ctx.moveTo(axX1 - 6, axY); ctx.lineTo(axX2 + 8, axY);
    ctx.stroke();

    // Arrow right
    ctx.fillStyle = 'rgba(255,255,255,0.75)';
    ctx.beginPath(); ctx.moveTo(axX2 + 8, axY);
    ctx.lineTo(axX2 + 2, axY - 5); ctx.lineTo(axX2 + 2, axY + 5); ctx.closePath(); ctx.fill();

    // Tick marks + labels
    const fs = Math.max(8, Math.round((11 / Whiteboard.VH) * (this._cssH || 150)));
    ctx.font = `${fs}px Manrope,sans-serif`;
    ctx.textAlign = 'center'; ctx.textBaseline = 'top';
    for (let v = Math.ceil(min / step) * step; v <= max; v += step) {
      const px = axX1 + (v - min) * scaleX;
      const major = (v % (step * 5) === 0) || step >= 1;
      const th = major ? ch * 0.22 : ch * 0.12;
      ctx.strokeStyle = major ? 'rgba(255,255,255,0.6)' : 'rgba(255,255,255,0.25)';
      ctx.lineWidth = major ? 1.2 : 0.7;
      ctx.beginPath(); ctx.moveTo(px, axY - th); ctx.lineTo(px, axY + th); ctx.stroke();
      if (major) {
        ctx.fillStyle = 'rgba(255,255,255,0.55)';
        ctx.fillText(v === 0 ? '0' : v.toString(), px, axY + th + 2);
      }
    }

    // Origin line
    if (min <= 0 && 0 <= max) {
      const ox = axX1 + (0 - min) * scaleX;
      ctx.strokeStyle = 'rgba(255,255,255,0.4)';
      ctx.lineWidth = 1.5;
      ctx.beginPath(); ctx.moveTo(ox, axY - ch * 0.3); ctx.lineTo(ox, axY + ch * 0.3); ctx.stroke();
    }

    // Points
    if (d.points) {
      for (const pt of d.points) {
        const px = axX1 + (pt.val - min) * scaleX;
        if (px < cx || px > cx + cw) continue;
        const color = pt.color || '#06D6E0';
        ctx.fillStyle = pt.open ? 'transparent' : color;
        ctx.strokeStyle = color;
        ctx.lineWidth = 2;
        ctx.beginPath(); ctx.arc(px, axY, ch * 0.12, 0, Math.PI * 2);
        ctx.fill(); ctx.stroke();
        if (pt.label) {
          ctx.fillStyle = color;
          ctx.font = `bold ${fs}px Manrope,sans-serif`;
          ctx.fillText(pt.label, px, axY - ch * 0.35);
        }
      }
    }

    // Intervals (shaded ranges)
    if (d.intervals) {
      for (const iv of d.intervals) {
        const x1 = axX1 + (Math.max(iv.from, min) - min) * scaleX;
        const x2 = axX1 + (Math.min(iv.to,   max) - min) * scaleX;
        ctx.fillStyle = (iv.color || '#9B5DE5') + '33';
        ctx.fillRect(x1, axY - ch * 0.1, x2 - x1, ch * 0.2);
      }
    }

    ctx.restore();
  }

  /* ── Compass stroke ─────────────────────────────────────────────────── */

  _renderCompass(ctx, stroke) {
    const d = stroke.data;
    // New format: {cx, cy, radius, arcStart, arcSweep, color, lineWidth, showLegs}
    if (d.cx == null) return; // skip legacy strokes with old format
    const [ccx, ccy] = this._toCanvas(d.cx, d.cy);
    const sx  = (this._cssW || 300) / Whiteboard.VW;
    const cr  = d.radius * sx;
    const lw  = Math.max(1.5, (d.lineWidth || 2) * sx);
    const arcColor = d.color || '#FFE066';

    ctx.save();
    ctx.globalAlpha = d.opacity ?? 1.0;

    // 1. Draw the arc
    const arcEnd = d.arcStart + d.arcSweep;
    const ccw    = d.arcSweep < 0;
    ctx.strokeStyle = arcColor;
    ctx.lineWidth   = lw;
    ctx.lineCap     = 'round';
    ctx.setLineDash([]);
    ctx.shadowColor = arcColor;
    ctx.shadowBlur  = Math.max(2, lw * 0.8);
    ctx.beginPath();
    ctx.arc(ccx, ccy, cr, d.arcStart, arcEnd, ccw);
    ctx.stroke();
    ctx.shadowBlur = 0;

    // 2. Compass instrument legs
    if (d.showLegs !== false) {
      const ptx = ccx + cr * Math.cos(arcEnd);
      const pty = ccy + cr * Math.sin(arcEnd);
      const legLen = cr * 1.55;
      const dist   = Math.hypot(ptx - ccx, pty - ccy);

      // Hinge above center & pencil tip (isoceles triangle apex)
      let hx, hy;
      if (dist < 2) {
        hx = ccx; hy = ccy - legLen;
      } else {
        const mx = (ccx + ptx) / 2, my = (ccy + pty) / 2;
        const perpX  = -(pty - ccy) / dist;
        const perpY  =  (ptx - ccx) / dist;
        const halfH  = Math.sqrt(Math.max(0, legLen * legLen - (dist / 2) * (dist / 2)));
        // Choose perpendicular direction that is "upward" (smaller canvas Y)
        const sign   = (my + perpY * halfH) <= (my - perpY * halfH) ? 1 : -1;
        hx = mx + sign * perpX * halfH;
        hy = my + sign * perpY * halfH;
      }

      // Needle leg: hinge → center (fixed pivot, cyan)
      ctx.strokeStyle = 'rgba(6,214,224,0.75)';
      ctx.lineWidth   = Math.max(1, lw * 0.7);
      ctx.lineCap     = 'round';
      ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(ccx, ccy); ctx.stroke();

      // Pencil leg: hinge → pencil tip (violet)
      ctx.strokeStyle = 'rgba(155,93,229,0.75)';
      ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(ptx, pty); ctx.stroke();

      // Hinge screw dot
      const hr = Math.max(3, lw * 1.3);
      ctx.fillStyle   = 'rgba(215,210,232,0.92)';
      ctx.strokeStyle = 'rgba(100,80,140,0.6)';
      ctx.lineWidth   = 1;
      ctx.beginPath(); ctx.arc(hx, hy, hr, 0, Math.PI * 2); ctx.fill(); ctx.stroke();

      // Center needle tip (cyan glow)
      ctx.fillStyle   = '#06D6E0';
      ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 8;
      ctx.beginPath(); ctx.arc(ccx, ccy, Math.max(2.5, lw), 0, Math.PI * 2); ctx.fill();
      ctx.shadowBlur = 0;

      // Pencil tip dot (arc color glow)
      ctx.fillStyle   = arcColor;
      ctx.shadowColor = arcColor; ctx.shadowBlur = 6;
      ctx.beginPath(); ctx.arc(ptx, pty, Math.max(2, lw * 0.85), 0, Math.PI * 2); ctx.fill();
      ctx.shadowBlur = 0;
    }

    ctx.restore();
  }

  /* ── Auto-measurements: display geometric annotations on shapes ─────── */

  renderMeasurements(ctx, stroke) {
    if (!stroke || stroke.tool !== 'shape') return;
    const d = stroke.data;
    const fs = Math.max(9, Math.round((10 / Whiteboard.VH) * (this._cssH || 150)));
    ctx.save();
    ctx.font      = `${fs}px Manrope,sans-serif`;
    ctx.fillStyle = 'rgba(255,230,100,0.85)';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';

    const [cx1, cy1] = this._toCanvas(d.x1, d.y1);
    const [cx2, cy2] = this._toCanvas(d.x2, d.y2);
    const midCx = (cx1 + cx2) / 2, midCy = (cy1 + cy2) / 2;

    if (d.shape === 'line' || d.shape === 'arrow') {
      const len = Math.hypot(d.x2 - d.x1, d.y2 - d.y1);
      const ang = Math.atan2(d.y2 - d.y1, d.x2 - d.x1) * 180 / Math.PI;
      this._drawMeasLabel(ctx, `${len.toFixed(0)} vp  ${ang.toFixed(1)}°`, midCx, midCy - 12);
    } else if (d.shape === 'rect' || d.shape === 'roundedrect') {
      const vw = Math.abs(d.x2 - d.x1), vh = Math.abs(d.y2 - d.y1);
      this._drawMeasLabel(ctx, `${vw.toFixed(0)}`, midCx, Math.min(cy1, cy2) - 8);
      this._drawMeasLabel(ctx, `${vh.toFixed(0)}`, Math.max(cx1, cx2) + 8, midCy);
    } else if (d.shape === 'ellipse') {
      const rx = Math.abs(d.x2 - d.x1) / 2, ry = Math.abs(d.y2 - d.y1) / 2;
      this._drawMeasLabel(ctx, `rx=${rx.toFixed(0)} ry=${ry.toFixed(0)}`, midCx, Math.min(cy1, cy2) - 8);
    } else if (d.shape === 'triangle') {
      const base = Math.abs(d.x2 - d.x1), height = Math.abs(d.y2 - d.y1);
      const area = (base * height / 2).toFixed(0);
      this._drawMeasLabel(ctx, `A≈${area}`, midCx, Math.min(cy1, cy2) - 8);
    }
    ctx.restore();
  }

  _drawMeasLabel(ctx, text, x, y) {
    const m = ctx.measureText(text);
    const pad = 3;
    ctx.fillStyle = 'rgba(26,20,40,0.7)';
    ctx.fillRect(x - m.width / 2 - pad, y - 7, m.width + pad * 2, 14);
    ctx.fillStyle = 'rgba(255,230,100,0.9)';
    ctx.fillText(text, x, y);
  }

  toggleMeasurements() {
    this._showMeasurements = !this._showMeasurements;
    this.render();
    return this._showMeasurements;
  }

  toggleRuler() {
    const idx = this._overlays.findIndex(o => o.type === 'ruler');
    if (idx >= 0) this._overlays.splice(idx, 1);
    else this._overlays.push({ type: 'ruler', x: 200, y: 200, angle: 0, width: 400 });
    this.render();
  }

  toggleProtractor() {
    const idx = this._overlays.findIndex(o => o.type === 'protractor');
    if (idx >= 0) this._overlays.splice(idx, 1);
    else this._overlays.push({ type: 'protractor', x: 960, y: 350, radius: 80, angle: 0 });
    this.render();
  }
}

if (typeof module !== 'undefined') module.exports = Whiteboard;
