Files
Maxim Dolgolyov eca68e1a28 feat(labs): Фаза2 — измерительные инструменты (линейка + угломер)
LabMeasure (_measure.js): SVG-оверлей поверх сцены с pointer-events:none
(симуляция остаётся интерактивной), перетаскиваемые ручки. Линейка — длина
px + ≈ метры (PX_PER_M) + угол; угломер — угол при вершине с дугой.
Кнопка-тумблер в топбаре лаборатории. Самодостаточно, симуляции не трогает.
Этим Фаза 2 закрыта.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 11:13:41 +03:00

126 lines
7.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 =
'<button class="lm-tb" data-t="ruler"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 8.7 8.7 21.3a1 1 0 0 1-1.4 0l-4.6-4.6a1 1 0 0 1 0-1.4L15.3 2.7a1 1 0 0 1 1.4 0l4.6 4.6a1 1 0 0 1 0 1.4Z"/><path d="m7.5 10.5 2 2M10.5 7.5l2 2M13.5 4.5l2 2M4.5 13.5l2 2"/></svg>Линейка</button>' +
'<button class="lm-tb" data-t="angle"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M3 21 21 3"/><path d="M3 21a18 18 0 0 0 4-7"/></svg>Угол</button>' +
'<button class="lm-tb" data-t="off"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Скрыть</button>';
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);