diff --git a/frontend/js/labs/_measure.js b/frontend/js/labs/_measure.js new file mode 100644 index 0000000..d3df3c6 --- /dev/null +++ b/frontend/js/labs/_measure.js @@ -0,0 +1,125 @@ +'use strict'; +/* LabMeasure — измерительные инструменты поверх любой симуляции (Фаза 2). + * Линейка (длина в px + ≈ метрах при PX_PER_M) и угломер (угол при вершине). + * SVG-оверлей на весь экран с pointer-events:none, чтобы симуляция оставалась + * интерактивной; перехватывают события только перетаскиваемые ручки. + * Самодостаточно: свой DOM/CSS, не трогает симуляции. Точка входа — LabMeasure.toggle(). */ +(function (global) { + var NS = 'http://www.w3.org/2000/svg'; + var PXM = (global.LabPalette && LabPalette.PX_PER_M) || 100; + var svg = null, bar = null, mode = null, drag = null; + var ruler = null, angle = null; + + function el(tag, attrs) { var e = document.createElementNS(NS, tag); for (var k in attrs) e.setAttribute(k, attrs[k]); return e; } + function center() { return { x: global.innerWidth / 2, y: global.innerHeight / 2 }; } + + function ensureStyle() { + if (document.getElementById('lm-style')) return; + var s = document.createElement('style'); s.id = 'lm-style'; + s.textContent = [ + '#lm-svg{position:fixed;inset:0;z-index:60;pointer-events:none;display:none;}', + '#lm-svg.on{display:block;}', + '#lm-svg .lm-hit{pointer-events:auto;cursor:grab;}', + '#lm-svg .lm-hit:active{cursor:grabbing;}', + '#lm-bar{position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:61;display:none;gap:6px;padding:6px;border-radius:12px;', + 'background:var(--surface,rgba(255,255,255,.9));backdrop-filter:var(--blur,blur(20px));border:1px solid var(--border,rgba(15,23,42,.1));box-shadow:0 8px 28px rgba(15,23,42,.18);}', + '#lm-bar.on{display:flex;}', + '.lm-tb{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border:none;border-radius:8px;background:transparent;', + 'font:700 .8rem Manrope,sans-serif;color:var(--text-2,#475569);cursor:pointer;}', + '.lm-tb:hover{background:rgba(155,93,229,.08);color:var(--violet,#9B5DE5);}', + '.lm-tb.on{background:var(--violet,#9B5DE5);color:#fff;}', + '.lm-tb .ic{width:15px;height:15px;}', + ].join(''); + document.head.appendChild(s); + } + + function ensure() { + if (svg) return; + ensureStyle(); + svg = el('svg', { id: 'lm-svg' }); + document.body.appendChild(svg); + bar = document.createElement('div'); bar.id = 'lm-bar'; + bar.innerHTML = + '' + + '' + + ''; + document.body.appendChild(bar); + bar.addEventListener('click', function (e) { var b = e.target.closest('.lm-tb'); if (b) setTool(b.dataset.t); }); + + // drag delegation + svg.addEventListener('pointerdown', function (e) { + var h = e.target.getAttribute && e.target.getAttribute('data-h'); + if (!h) return; + drag = h; e.target.setPointerCapture && e.target.setPointerCapture(e.pointerId); e.preventDefault(); + }); + svg.addEventListener('pointermove', function (e) { + if (!drag) return; + var p = (mode === 'ruler') ? ruler : angle; + p[drag + 'x'] = e.clientX; p[drag + 'y'] = e.clientY; + render(); + }); + function end() { drag = null; } + svg.addEventListener('pointerup', end); + svg.addEventListener('pointercancel', end); + global.addEventListener('resize', function () { if (mode) render(); }); + } + + function setTool(t) { + ensure(); + if (t === 'off') { mode = null; svg.classList.remove('on'); paintBar(); render(); return; } + mode = t; + var c = center(); + if (t === 'ruler' && !ruler) ruler = { ax: c.x - 110, ay: c.y, bx: c.x + 110, by: c.y }; + if (t === 'angle' && !angle) angle = { vx: c.x, vy: c.y + 70, ax: c.x - 120, ay: c.y - 40, bx: c.x + 120, by: c.y - 40 }; + svg.classList.add('on'); + paintBar(); render(); + } + function paintBar() { if (!bar) return; bar.querySelectorAll('.lm-tb').forEach(function (b) { b.classList.toggle('on', b.dataset.t === mode); }); } + + function lineLabel(x, y, text) { + var g = el('g', {}); + var pad = 5, w = text.length * 7.2 + pad * 2, h = 20; + g.appendChild(el('rect', { x: x - w / 2, y: y - h / 2, width: w, height: h, rx: 6, fill: 'rgba(13,13,26,.85)' })); + var t = el('text', { x: x, y: y + 4, 'text-anchor': 'middle', fill: '#fff', 'font-size': 12, 'font-family': 'Manrope,sans-serif', 'font-weight': 700 }); + t.textContent = text; g.appendChild(t); + return g; + } + function handle(hx, hy, name) { return el('circle', { cx: hx, cy: hy, r: 9, class: 'lm-hit', fill: '#9B5DE5', stroke: '#fff', 'stroke-width': 2, 'data-h': name }); } + + function render() { + if (!svg) return; + while (svg.firstChild) svg.removeChild(svg.firstChild); + if (!mode) return; + if (mode === 'ruler') { + var r = ruler, dx = r.bx - r.ax, dy = r.by - r.ay; + var dpx = Math.hypot(dx, dy), deg = Math.abs(Math.atan2(dy, dx) * 180 / Math.PI); + if (deg > 90) deg = 180 - deg; + svg.appendChild(el('line', { x1: r.ax, y1: r.ay, x2: r.bx, y2: r.by, stroke: '#9B5DE5', 'stroke-width': 2.5 })); + svg.appendChild(handle(r.ax, r.ay, 'a')); + svg.appendChild(handle(r.bx, r.by, 'b')); + var lab = Math.round(dpx) + ' px · ' + (dpx / PXM).toFixed(2) + ' м · ' + deg.toFixed(1) + '°'; + svg.appendChild(lineLabel((r.ax + r.bx) / 2, (r.ay + r.by) / 2 - 18, lab)); + } else if (mode === 'angle') { + var a = angle; + var a1 = Math.atan2(a.ay - a.vy, a.ax - a.vx), a2 = Math.atan2(a.by - a.vy, a.bx - a.vx); + var deg2 = Math.abs((a1 - a2) * 180 / Math.PI); if (deg2 > 180) deg2 = 360 - deg2; + svg.appendChild(el('line', { x1: a.vx, y1: a.vy, x2: a.ax, y2: a.ay, stroke: '#06D6E0', 'stroke-width': 2.5 })); + svg.appendChild(el('line', { x1: a.vx, y1: a.vy, x2: a.bx, y2: a.by, stroke: '#06D6E0', 'stroke-width': 2.5 })); + // дуга угла + var rr = 36, s = el('path', { d: 'M ' + (a.vx + rr * Math.cos(a1)) + ' ' + (a.vy + rr * Math.sin(a1)) + + ' A ' + rr + ' ' + rr + ' 0 ' + (deg2 > 180 ? 1 : 0) + ' ' + ((a2 - a1 + 2 * Math.PI) % (2 * Math.PI) < Math.PI ? 1 : 0) + ' ' + + (a.vx + rr * Math.cos(a2)) + ' ' + (a.vy + rr * Math.sin(a2)), fill: 'none', stroke: '#06D6E0', 'stroke-width': 2, opacity: .6 }); + svg.appendChild(s); + svg.appendChild(handle(a.ax, a.ay, 'a')); + svg.appendChild(handle(a.bx, a.by, 'b')); + svg.appendChild(handle(a.vx, a.vy, 'v')); + svg.appendChild(lineLabel(a.vx, a.vy + 26, deg2.toFixed(1) + '°')); + } + } + + global.LabMeasure = { + toggle: function () { ensure(); if (bar.classList.contains('on')) { this.hide(); } else { bar.classList.add('on'); if (!mode) setTool('ruler'); } }, + hide: function () { if (bar) bar.classList.remove('on'); if (svg) svg.classList.remove('on'); mode = null; paintBar(); render(); }, + setTool: setTool, + }; +})(window); diff --git a/frontend/lab.html b/frontend/lab.html index fdc44c7..69b08c3 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -357,6 +357,11 @@ + + +