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>
This commit is contained in:
@@ -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 =
|
||||
'<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);
|
||||
@@ -357,6 +357,11 @@
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- measurement tools (ruler / angle) -->
|
||||
<button class="zoom-btn" id="lab-measure-btn" onclick="window.LabMeasure&&LabMeasure.toggle()" title="Измерения: линейка и угломер">
|
||||
<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>
|
||||
|
||||
<!-- sound toggle -->
|
||||
<button class="zoom-btn" id="labfx-sound-btn" onclick="(function(){var e=window.LabFX&&window.LabFX.sound;if(!e)return;e.setEnabled(!e.isEnabled());document.getElementById('labfx-sound-btn').setAttribute('aria-pressed',e.isEnabled());document.getElementById('labfx-sound-icon-on').style.display=e.isEnabled()?'':'none';document.getElementById('labfx-sound-icon-off').style.display=e.isEnabled()?'none':'';})()" title="Звук симуляций" style="position:relative" aria-pressed="true">
|
||||
<!-- speaker on -->
|
||||
@@ -449,6 +454,7 @@
|
||||
<script src="/js/labs/_sim_engine.js"></script>
|
||||
<script src="/js/labs/_sim_adapter.js"></script>
|
||||
<script src="/js/labs/_tasks.js"></script>
|
||||
<script src="/js/labs/_measure.js"></script>
|
||||
<script src="/js/labs/_phys_visuals.js"></script>
|
||||
<script src="/js/labs/_chem_visuals.js"></script>
|
||||
<script src="/js/labs/graph.js"></script>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
## Прогресс
|
||||
- [x] Фаза 0 (фундамент заложен) — эконом-режим/reduced-motion (LabFX, тумблер), выбор симуляции из списка в редакторе урока, удалён мёртвый `SimUtil`, добавлены `LabPalette` (_palette.js) и `SimBase` (_simbase.js) как опциональные основания. **Адаптация симуляций к SimBase/LabPalette и удаление «дробовика» `_pauseAllSims/closeSim` — постепенно, по мере правок каждой симуляции (требует поштучной проверки, нет фронт-тестов).**
|
||||
- [~] Фаза 1 — сделано: фреймворк `LabTasks` (_tasks.js) + интеграция в теорию; задания на 17 симуляций. Осталось: XP за задания, deep-link на §, наполнение остальных.
|
||||
- [~] Фаза 2 — сделано: «Сохранить кадр в Мои материалы» + «Скачать PNG»; сохранение/возобновление параметров симуляции (localStorage поверх getState/applyState, не в embed). Осталось: измерительные инструменты (линейка/транспортир). (3D/WebGL-кадр пустой без preserveDrawingBuffer — доработать.)
|
||||
- [x] Фаза 2 — «Сохранить кадр в Мои материалы» + «Скачать PNG»; сохранение/возобновление параметров (localStorage, не в embed); измерительные инструменты `LabMeasure` (линейка + угломер, SVG-оверлей). Остаток-доработка: 3D/WebGL-снимок (preserveDrawingBuffer), привязка линейки к шкале конкретной симуляции.
|
||||
- [ ] Фаза 3
|
||||
- [ ] Фаза 4
|
||||
- [ ] Фаза 5
|
||||
|
||||
Reference in New Issue
Block a user