feat(sim-builder): фаза 1 — графики (plot), drag-ручки, readout, векторы origin+dx/dy

This commit is contained in:
Maxim Dolgolyov
2026-06-13 11:30:37 +03:00
parent 4dd92f83a0
commit e51b57d9c7
6 changed files with 423 additions and 34 deletions
+10
View File
@@ -64,3 +64,13 @@ git push origin master
- **`window.registerSpecSim(spec)`** (`_sim_adapter.js`): спека → манифест LabRegistry (ленивый хост `#sim-spec-host-<id>` в `#lab-sim`; `stop` прячет, `destroy` уничтожает). Так спек-сим открывается тем же путём, что рукописные ~40 (через `openSim` → реестр).
- Демо `customdemo``_sim_demo.js`, за флагом `?simdemo=1` / `?sim=customdemo` / `LAB_SHOW_SPEC_DEMO` / localStorage `lab-spec-demo=1` (ученикам не светится).
- Подключение: 3 каркасных `<script>` eager после `_graph_panel.js` в `lab.html`, демо — после `_register-all.js`. `_sim_deps.js` не трогать (каркас грузится до диспетчера).
### Phase 1 — Learnings
- **Новые типы объектов** (в `_sim_engine.js`, формат — в шапке файла):
- `plot` — график `f(var)` на canvas движка в мир-координатах (НЕ через `GraphPanelUI` — тот stacked time-series в фикс. оверлее, не `y=f(x)`). Поля: `expr`, `var` (деф.`x`), `range:[a,b]` (числа/выражения, деф. xmin..xmax), `samples` (клампится 2..2000, деф.200), `trace` (точка var=t пишется в trail; при trace без range статич. кривая не рисуется), `color/width`. Свободная переменная подставляется во временную копию env-ключа (восстанавливается после).
- `readout` — живой бейдж на DOM-оверлее (`_labelLayer`, как label). Поля: `expr`, `label`, `unit`, `precision` (0..8, деф.2), `x/y` (мир-коорд.; без них — авто-столбик верх-право, счётчик `_readoutSlot` сбрасывается на кадр). Ошибка — мягко через `SimExpr.evalSafe` (AST компилируется 1 раз в prepare), показывает «—».
- `vector` — новая форма `origin:[ox,oy]+dx/dy` (конец = origin + (dx,dy)); старая `x1/y1/x2/y2` сохранена; стрелка из Ф0.
- **Drag** (`point`/`circle` с `drag:{param,axis,min,max,paramY}`): pointer events на canvas (мышь+тач, `touchAction:none`); хит-тест в экранных px (допуск 16px, ближайшая ручка), приоритет ручек. `axis:'xy'` требует `paramY`. Курсор → мир через `_toWorld` (инверсия `_toPx`) → `_setParamClamped` (clamp по `drag.min/max` И по диапазону параметра из `_paramRange` — не полагаться на DOM-clamp слайдера). Слушатели снимаются в `destroy`. Drag только point/circle (вершины polyline/конец вектора — не реализовано).
- Тестировать движок headless: `vm.createContext` + ручной DOM/canvas-стаб (canvas-ctx через `Proxy` с noop). `_renderFrame` рано выходит при `_cw/_ch==0` — выставить вручную. `setParam`/drag используют `new Event('input')` (браузерно безопасно, в стабе нужен `Event`).
-`lab.html`/`lab-glue.js` — зона параллельной сессии (Ф2 измерит. инструменты); Ф1 их НЕ трогала, работала только в `_sim_engine.js`/`_sim_demo.js`.
+38 -10
View File
@@ -25,30 +25,49 @@
return false;
}
// Спека v1: бросок тела. g фиксирован 10 -> y = v*sin(θ)*t - 5*t^2.
// Спека v1+ (Фаза 1): бросок тела. g фиксирован 10 -> y = y0 + v*sin(θ)*t - 5*t^2.
// Старт (x0,y0) — перетаскиваемая ручка (drag). plot — статическая параболическая
// траектория y(x); readout — дальность и макс. высота. Вектор v0 — origin+dx/dy.
var PROJECTILE_DEMO = {
id: 'customdemo',
cat: 'phys',
meta: { title: 'Демо: бросок тела', desc: 'Спек-симуляция (Фаза 0). Угол и скорость — слайдеры.' },
meta: { title: 'Демо: бросок тела', desc: 'Спек-симуляция (Фаза 1): слайдеры, drag-старт, график, readout.' },
viewport: { xmin: 0, xmax: 60, ymin: 0, ymax: 30, grid: true, axes: true, bg: '#0D0D1A' },
time: { autoplay: false, loop: true, duration: 8, speed: 1 },
params: [
{ name: 'theta', label: 'Угол θ', min: 0, max: 90, step: 1, value: 45, unit: '°' },
{ name: 'v', label: 'Скорость v', min: 0, max: 30, step: 0.5, value: 20, unit: 'м/с' }
{ name: 'v', label: 'Скорость v', min: 0, max: 30, step: 0.5, value: 20, unit: 'м/с' },
{ name: 'x0', label: 'Старт X', min: 0, max: 20, step: 0.5, value: 2, unit: 'м' },
{ name: 'y0', label: 'Старт Y', min: 0, max: 25, step: 0.5, value: 0, unit: 'м' }
],
objects: [
// снаряд: x = v*cos(θ)*t, y = v*sin(θ)*t - 5 t^2 (но не ниже 0)
// снаряд: x = x0 + v*cos(θ)*t, y = y0 + v*sin(θ)*t - 5 t^2 (но не ниже 0)
{
id: 'ball', type: 'point',
x: 'v*cos(theta*pi/180)*t',
y: 'max(0, v*sin(theta*pi/180)*t - 5*t^2)',
x: 'x0 + v*cos(theta*pi/180)*t',
y: 'max(0, y0 + v*sin(theta*pi/180)*t - 5*t^2)',
r: 7, color: '#06D6E0', trail: true, trailColor: '#9B5DE5'
},
// вектор начальной скорости из старта
// график траектории y(x): парабола броска, var=x на [x0, x0+дальность].
// y(x) = y0 + tan(θ)(x-x0) - g(x-x0)^2/(2 v^2 cos^2θ), g=10.
{
type: 'vector', x1: 0, y1: 0,
x2: 'cos(theta*pi/180)*v*0.4',
y2: 'sin(theta*pi/180)*v*0.4',
type: 'plot', color: '#FFD166', width: 1.6,
var: 'x', range: ['x0', 'x0 + v*v*sin(2*theta*pi/180)/10 + 0.001'],
samples: 200,
expr: 'max(0, y0 + tan(theta*pi/180)*(x-x0) - 10*(x-x0)^2/(2*v*v*cos(theta*pi/180)^2))'
},
// перетаскиваемая ручка старта (drag по обеим осям -> x0/y0)
{
id: 'start', type: 'point',
x: 'x0', y: 'y0', r: 8, color: '#EF476F',
drag: { axis: 'xy', param: 'x0', paramY: 'y0', min: 0, max: 25 }
},
// вектор начальной скорости из старта (origin + dx/dy)
{
type: 'vector',
origin: ['x0', 'y0'],
dx: 'cos(theta*pi/180)*v*0.4',
dy: 'sin(theta*pi/180)*v*0.4',
color: '#FFD166', width: 3
},
// земля
@@ -58,6 +77,15 @@
type: 'label', latex: true,
x: 'ball.x', y: 'ball.y + 2.5',
text: 'v_0', color: '#06D6E0', size: 15
},
// readout: дальность полёта R = v^2 sin(2θ)/g (для y0=0) и макс. высота H
{
type: 'readout', label: 'R', unit: 'м', precision: 1, color: '#FFD166',
expr: 'x0 + v*v*sin(2*theta*pi/180)/10'
},
{
type: 'readout', label: 'H', unit: 'м', precision: 1, color: '#06D6E0',
expr: 'y0 + (v*sin(theta*pi/180))^2/20'
}
]
};
+309 -6
View File
@@ -35,12 +35,38 @@
{ type:'rect', x, y, w, h, color, fill?, width }, // x,y = центр
{ type:'polyline', points:[[x,y],...], color, width, closed? },
{ type:'path', points:[[x,y],...], ... }, // alias polyline
{ type:'label', x, y, text:'LaTeX', latex?:true, color, size?:14 }
{ type:'label', x, y, text:'LaTeX', latex?:true, color, size?:14 },
// ── Фаза 1 ──
{ type:'plot', // график выражения в мир-коорд.
expr:'sin(x)', // f(<var>) (или f(t) при trace)
var:'x', // имя свободной переменной (деф. 'x')
range:[a,b], // отрезок построения (деф. xmin..xmax)
samples?:200, // число точек (деф. 200, клампится)
trace?:false, // true -> точка (varValue=t) пишется в след по времени
color?, width? },
{ type:'vector', origin:[ox,oy], dx, dy, // стрелка из origin на (dx,dy)
color?, width? }, // (x1/y1/x2/y2 тоже поддерживаются)
{ type:'readout', // живой числовой бейдж
label?:'R', expr:'...', unit?:'м', precision?:2,
x?, y?, // мир-координаты (деф. угол вьюпорта)
color? },
// Любой point/circle может стать перетаскиваемой ручкой:
{ type:'point', x:'x0', y:'y0',
drag:{ param:'x0', axis:'x'|'y'|'xy', min?, max?, paramY? } }
]
}
Выражения видят: t, все params по имени, w/h (мир-размер вьюпорта), а также
<objId>.x / <objId>.y для объектов, у которых заданы числовые/выраж. x,y.
── ИНТЕРАКЦИИ (Фаза 1) ──────────────────────────────────────────────────
Объект с полем drag:{param, axis, min?, max?, paramY?} становится ручкой:
перетаскивание мышью/тачем (pointer events) пишет мир-координату курсора в
параметр(ы). axis:'x' -> param=x; 'y' -> param=y; 'xy' -> param=x, paramY=y
(или param используется для x, paramY для y). Позиция ручки следует за
параметром, когда её не тащат (x/y объекта обычно = тот же параметр).
Хит-тест — в экранных пикселях с допуском; ручки имеют приоритет.
── API инстанса ──────────────────────────────────────────────────────────
var inst = SimEngine.mount(host, spec);
inst.play() inst.pause() inst.reset()
@@ -74,6 +100,7 @@
this.spec = spec || {};
this.params = {}; // name -> текущее значение (number)
this._sliders = {}; // name -> input element
this._paramRange = {}; // name -> { min, max } (для clamp при drag)
this._objs = []; // подготовленные объекты с привязками
this._trails = {}; // objId -> [[x,y],...] (мир-координаты)
this._t = 0;
@@ -84,6 +111,8 @@
this._cw = 0; this._ch = 0;
this._destroyed = false;
this._ro = null;
this._dragging = null; // текущая перетаскиваемая ручка (drag)
this._readoutSlot = 0; // счётчик автопозиционируемых readout-бейджей
this._build();
}
@@ -135,6 +164,7 @@
var min = num(p.min, 0), max = num(p.max, 100), step = num(p.step, 1);
var val = num(p.value, min);
self.params[p.name] = val;
self._paramRange[p.name] = { min: min, max: max }; // для clamp при drag
var wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;flex-direction:column;gap:4px';
@@ -196,6 +226,9 @@
this._ro.observe(stage);
}
// drag-интеракции (мышь + тач через pointer events)
this._setupDrag();
// первичная подгонка после layout
requestAnimationFrame(function () {
self._fit();
@@ -253,7 +286,21 @@
function bp(key, dflt) { B[key] = bind(o[key], dflt); }
if (type === 'point') { bp('x', 0); bp('y', 0); B.r = bind(o.r, 6); }
else if (type === 'segment' || type === 'vector') { bp('x1', 0); bp('y1', 0); bp('x2', 1); bp('y2', 1); }
else if (type === 'segment') { bp('x1', 0); bp('y1', 0); bp('x2', 1); bp('y2', 1); }
else if (type === 'vector') {
// vector: либо x1/y1/x2/y2, либо origin:[ox,oy] + dx/dy
if (Array.isArray(o.origin) || o.dx !== undefined || o.dy !== undefined) {
var org = Array.isArray(o.origin) ? o.origin : [0, 0];
B.x1 = bind(org[0], 0); B.y1 = bind(org[1], 0);
// конец = origin + (dx,dy): сумма привязок
var ox = B.x1, oy = B.y1;
var dxB = bind(o.dx, 1), dyB = bind(o.dy, 1);
B.x2 = { ev: function (env) { return ox.ev(env) + dxB.ev(env); } };
B.y2 = { ev: function (env) { return oy.ev(env) + dyB.ev(env); } };
} else {
bp('x1', 0); bp('y1', 0); bp('x2', 1); bp('y2', 1);
}
}
else if (type === 'circle') { bp('x', 0); bp('y', 0); bp('r', 1); }
else if (type === 'rect') { bp('x', 0); bp('y', 0); bp('w', 1); bp('h', 1); }
else if (type === 'polyline' || type === 'path') {
@@ -263,6 +310,38 @@
prep.closed = !!o.closed;
} else if (type === 'label') {
bp('x', 0); bp('y', 0);
} else if (type === 'plot') {
prep.varName = (typeof o['var'] === 'string' && o['var']) ? o['var'] : 'x';
prep.exprFn = bind(o.expr != null ? o.expr : '0', 0);
var rng = Array.isArray(o.range) ? o.range : null;
prep.rangeA = bind(rng ? rng[0] : null, null);
prep.rangeB = bind(rng ? rng[1] : null, null);
prep.hasRange = !!rng;
prep.samples = Math.max(2, Math.min(2000, num(o.samples, 200) | 0));
prep.trace = !!o.trace;
} else if (type === 'readout') {
// компилируем выражение один раз: храним и fn (быстро), и ast (для evalSafe — мягкая ошибка)
var rc = global.SimExpr ? global.SimExpr.compileValue(o.expr != null ? o.expr : '0')
: { fn: function () { return 0; }, ast: null };
prep.exprFn = { ev: rc.fn };
prep.exprAst = rc.ast;
prep.label = o.label != null ? String(o.label) : '';
prep.unit = o.unit != null ? String(o.unit) : '';
prep.precision = Math.max(0, Math.min(8, num(o.precision, 2) | 0));
prep.hasPos = (o.x !== undefined && o.y !== undefined);
if (prep.hasPos) { bp('x', 0); bp('y', 0); }
}
// drag-интеракция (point/circle): объект становится ручкой
if (o.drag && (type === 'point' || type === 'circle')) {
var dg = o.drag;
prep.drag = {
param: dg.param || null, // axis x|y -> этот параметр; xy -> X
paramY: dg.paramY || null, // axis xy -> Y (обязателен для 2D-ручки)
axis: (dg.axis === 'y' || dg.axis === 'xy') ? dg.axis : 'x',
min: typeof dg.min === 'number' ? dg.min : -Infinity,
max: typeof dg.max === 'number' ? dg.max : Infinity
};
}
// привязки для центра объекта (для obj.x/obj.y в env): point/circle/rect/label
@@ -330,6 +409,112 @@
return [this._offX + mx * this._scale, this._offY - my * this._scale];
};
/* экран (px, относит. stage) -> мир (обратно к _toPx) */
SimEngineInstance.prototype._toWorld = function (px, py) {
var s = this._scale || 1;
return [(px - this._offX) / s, (this._offY - py) / s];
};
/* ════════════════════ Drag-интеракции (мышь + тач) ════════════════════
Объекты с prep.drag — перетаскиваемые ручки. Слушаем pointer events на
canvas: pointerdown -> хит-тест ближайшей ручки в пикселях; pointermove ->
записываем мир-координату курсора в параметр(ы) (clamp по min/max). */
SimEngineInstance.prototype._hasHandles = function () {
for (var i = 0; i < this._objs.length; i++) if (this._objs[i].drag) return true;
return false;
};
SimEngineInstance.prototype._setupDrag = function () {
if (!this.canvas || !this._hasHandles()) return;
var self = this;
var HIT_PX = 16; // допуск хит-теста в пикселях
function localXY(ev) {
var r = self.canvas.getBoundingClientRect();
return [ev.clientX - r.left, ev.clientY - r.top];
}
function pickHandle(lx, ly) {
// хит-тест в экранных пикселях, ближайшая в пределах допуска
var env = self._buildEnv();
var best = null, bestD = HIT_PX * HIT_PX;
for (var i = 0; i < self._objs.length; i++) {
var o = self._objs[i];
if (!o.drag || !o.b.x || !o.b.y) continue;
var p = self._toPx(o.b.x.ev(env), o.b.y.ev(env));
var dx = p[0] - lx, dy = p[1] - ly;
var d = dx * dx + dy * dy;
if (d <= bestD) { bestD = d; best = o; }
}
return best;
}
this._onPointerDown = function (ev) {
if (ev.button != null && ev.button !== 0) return; // только левая кнопка/тач
var xy = localXY(ev);
var h = pickHandle(xy[0], xy[1]);
if (!h) return;
self._dragging = h;
ev.preventDefault();
try { self.canvas.setPointerCapture(ev.pointerId); } catch (e) {}
self.canvas.style.cursor = 'grabbing';
self._applyDrag(h, xy[0], xy[1]);
};
this._onPointerMove = function (ev) {
var xy = localXY(ev);
if (self._dragging) {
ev.preventDefault();
self._applyDrag(self._dragging, xy[0], xy[1]);
} else {
// hover-курсор над ручкой
self.canvas.style.cursor = pickHandle(xy[0], xy[1]) ? 'grab' : 'default';
}
};
this._onPointerUp = function (ev) {
if (!self._dragging) return;
self._dragging = null;
try { self.canvas.releasePointerCapture(ev.pointerId); } catch (e) {}
self.canvas.style.cursor = 'default';
};
var c = this.canvas;
c.style.touchAction = 'none'; // не давать браузеру скроллить/зумить при тач-drag
c.addEventListener('pointerdown', this._onPointerDown);
c.addEventListener('pointermove', this._onPointerMove);
c.addEventListener('pointerup', this._onPointerUp);
c.addEventListener('pointercancel', this._onPointerUp);
};
/* записать мир-координату курсора в параметр(ы) ручки */
SimEngineInstance.prototype._applyDrag = function (h, lx, ly) {
var w = this._toWorld(lx, ly);
var d = h.drag;
if (d.axis === 'x') {
if (d.param) this._setParamClamped(d.param, w[0], d);
} else if (d.axis === 'y') {
if (d.param) this._setParamClamped(d.param, w[1], d);
} else { // 'xy'
if (d.param) this._setParamClamped(d.param, w[0], d);
if (d.paramY) this._setParamClamped(d.paramY, w[1], d);
}
if (!this._running) this._renderFrame(); // живой отклик на паузе
};
SimEngineInstance.prototype._setParamClamped = function (name, value, d) {
var v = _clamp(value, d.min, d.max);
// дополнительно зажать в диапазон самого параметра (слайдера), не полагаясь на DOM
var pr = this._paramRange[name];
if (pr) v = _clamp(v, pr.min, pr.max);
if (!isFinite(v)) return;
this.params[name] = v;
var sl = this._sliders[name];
if (sl) {
sl.value = String(v);
// синхронизировать подпись значения слайдера (input-обработчик обновит её)
sl.dispatchEvent(new Event('input'));
}
};
/* ════════════════════ Рендер кадра ════════════════════ */
SimEngineInstance.prototype._renderFrame = function () {
var ctx = this.ctx; if (!ctx) return;
@@ -344,8 +529,9 @@
if (vp.axes) this._drawAxes(ctx, W, H, vp);
var env = this._buildEnv();
this._readoutSlot = 0; // сброс счётчика бейджей-readout на каждый кадр
// обновить трассы
// обновить трассы (point/circle с trail + plot с trace)
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (o.trail && o.hasCenter) {
@@ -355,14 +541,17 @@
arr.push([tx, ty]);
if (arr.length > 2000) arr.shift();
}
} else if (o.type === 'plot' && o.trace) {
this._accumPlotTrace(o, env);
}
}
// нарисовать трассы под объектами
for (var ti = 0; ti < this._objs.length; ti++) {
var ot = this._objs[ti];
if (ot.trail && this._trails[ot.id] && this._trails[ot.id].length > 1) {
this._drawTrail(ctx, this._trails[ot.id], ot.trailColor);
if ((ot.trail || (ot.type === 'plot' && ot.trace)) &&
this._trails[ot.id] && this._trails[ot.id].length > 1) {
this._drawTrail(ctx, this._trails[ot.id], ot.trailColor || ot.color);
}
}
@@ -373,6 +562,20 @@
}
};
/* накопить точку plot-trace (var=t) во след по времени */
SimEngineInstance.prototype._accumPlotTrace = function (o, env) {
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
var prev = env[o.varName];
env[o.varName] = env.t;
var ty = o.exprFn.ev(env);
if (hadPrev) env[o.varName] = prev; else delete env[o.varName];
if (typeof ty === 'number' && isFinite(ty)) {
var arr = this._trails[o.id] || (this._trails[o.id] = []);
arr.push([env.t, ty]);
if (arr.length > 2000) arr.shift();
}
};
SimEngineInstance.prototype._drawTrail = function (ctx, pts, color) {
ctx.save();
ctx.strokeStyle = color;
@@ -459,9 +662,84 @@
this._drawLabel(o, lp[0], lp[1]);
break;
}
case 'plot': {
this._drawPlot(ctx, o, env);
break;
}
case 'readout': {
this._drawReadout(o, env);
break;
}
}
};
/* ── plot: график выражения f(var) на отрезке range (мир-координаты) ── */
SimEngineInstance.prototype._drawPlot = function (ctx, o, env) {
// trace без явного range — только накапливаемый след (статической кривой нет)
if (o.trace && !o.hasRange) return;
var vp = this._vp();
var a = o.rangeA.ev(env), b = o.rangeB.ev(env);
if (!o.hasRange || !isFinite(a) || !isFinite(b)) { a = vp.xmin; b = vp.xmax; }
if (a === b) return;
var n = o.samples;
var step = (b - a) / (n - 1);
// env-копия с дополнительной свободной переменной (не мутируем общий env навсегда)
var prev = env[o.varName];
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
ctx.save();
ctx.strokeStyle = o.color;
ctx.lineWidth = o.width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
var started = false;
for (var i = 0; i < n; i++) {
var xv = a + step * i;
env[o.varName] = xv;
var yv = o.exprFn.ev(env);
if (typeof yv !== 'number' || !isFinite(yv)) { started = false; continue; }
var px = this._toPx(xv, yv);
if (!started) { ctx.moveTo(px[0], px[1]); started = true; }
else ctx.lineTo(px[0], px[1]);
}
ctx.stroke();
ctx.restore();
// восстановить env
if (hadPrev) env[o.varName] = prev; else delete env[o.varName];
};
/* ── readout: живое значение выражения как бейдж на оверлее ── */
SimEngineInstance.prototype._drawReadout = function (o, env) {
// мягкая ошибка через evalSafe (NaN/∞/синтаксис -> error, бейдж покажет «—»)
var res = (global.SimExpr && o.exprAst)
? global.SimExpr.evalSafe(o.exprAst, env)
: { value: o.exprFn.ev(env), error: null };
var el = document.createElement('div');
var px, py;
if (o.hasPos) {
var p = this._toPx(o.b.x.ev(env), o.b.y.ev(env));
px = p[0]; py = p[1];
el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);' + _readoutBadgeCss(o.color);
} else {
// дефолт: верх-правый угол сцены, бейджи столбиком
var idx = (this._readoutSlot = (this._readoutSlot || 0) + 1) - 1;
el.style.cssText = 'position:absolute;right:10px;top:' + (10 + idx * 30) + 'px;' +
_readoutBadgeCss(o.color);
}
if (o.hasPos) { el.style.left = px + 'px'; el.style.top = py + 'px'; }
var valTxt = res.error ? '—' : _fmtFixed(res.value, o.precision);
var html = '';
if (o.label) html += '<span style="opacity:.7;margin-right:5px">' + _esc(o.label) + '</span>';
html += '<span style="font-weight:700;font-variant-numeric:tabular-nums">' + _esc(valTxt) + '</span>';
if (o.unit && !res.error) html += '<span style="opacity:.6;margin-left:3px">' + _esc(o.unit) + '</span>';
el.innerHTML = html;
this._labelLayer.appendChild(el);
};
SimEngineInstance.prototype._arrowHead = function (ctx, a, b, color) {
var ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
var s = 9;
@@ -592,16 +870,41 @@
this.pause();
this._destroyed = true;
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
// снять drag-слушатели
if (this.canvas && this._onPointerDown) {
var c = this.canvas;
c.removeEventListener('pointerdown', this._onPointerDown);
c.removeEventListener('pointermove', this._onPointerMove);
c.removeEventListener('pointerup', this._onPointerUp);
c.removeEventListener('pointercancel', this._onPointerUp);
}
this._onPointerDown = this._onPointerMove = this._onPointerUp = null;
this._dragging = null;
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
this.el = null; this.canvas = null; this.ctx = null;
};
/* ── format helper ── */
/* ── format helpers ── */
function _fmt(v) {
if (!isFinite(v)) return '—';
if (Number.isInteger(v)) return String(v);
return parseFloat(v.toFixed(3)).toString();
}
function _fmtFixed(v, prec) {
if (typeof v !== 'number' || !isFinite(v)) return '—';
return v.toFixed(prec);
}
function _esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function _readoutBadgeCss(color) {
return 'pointer-events:none;font-family:Manrope,system-ui,sans-serif;font-size:.78rem;' +
'color:' + (color || '#06D6E0') + ';background:rgba(13,13,26,0.78);' +
'border:1px solid rgba(255,255,255,0.12);border-radius:8px;padding:3px 9px;' +
'white-space:nowrap;backdrop-filter:blur(2px)';
}
function _clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
/* ════════════════════ public ════════════════════ */
function mount(host, spec) {
+8 -3
View File
@@ -1,6 +1,10 @@
# Feature Context: Конструктор симуляций (SimForge)
## Current State
- **Фаза 1 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только `_sim_engine.js` + `_sim_demo.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
- Новые типы объектов спеки: **plot** (график `f(var)` на canvas движка, `trace` — след по `t`), **readout** (живой бейдж, мягкая ошибка через `evalSafe`), **vector** с формой `origin+dx/dy`. **drag** на point/circle (`drag:{param,axis,min,max,paramY}`) — pointer events (мышь+тач), хит-тест в px (16px), двойной clamp (drag.min/max + диапазон параметра). Точные поля — в шапке `_sim_engine.js` и handoff phase-1.
- Демо `customdemo` расширено: +слайдеры x0/y0, draggable-старт (axis xy), plot траектории, 2 readout (R, H). По-прежнему за флагом.
- Верификация: `node --check` обоих файлов OK; eval/Function — только в комментарии, ни одного call-site; эмодзи нет (скан кодпойнтов); headless-тесты (vm + DOM-стаб): подготовка типов, vector end=origin+(dx,dy), plot evaluate, readout evalSafe, drag clamp+slider-sync, рендер всех 8 типов демо ×6 кадров без ошибок, trail/readout-слоты накапливаются корректно.
- **Фаза 0 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Ветка `feature/sim-builder` от `master`.
- `frontend/js/labs/_sim_expr.js``window.SimExpr` (безопасный движок выражений, без eval/Function; `compile/evaluate/evalSafe/compileValue/parse/tokenize`, whitelist + сравнения/логика/тернарник/multi-var env).
- `frontend/js/labs/_sim_engine.js``window.SimEngine.mount(host, spec) -> { play, pause, reset, setParam, getParam, isRunning, destroy, el }`. Canvas (мир→экран, Y вверх) + KaTeX-оверлей подписей + слайдеры/play/pause/reset. Формат спеки v1 задокументирован в шапке файла.
@@ -43,7 +47,8 @@
- Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`.
## RESUME STATE
- Последний коммит фичи: — (Ф0 реализована, но ещё не закоммичена — ждёт оркестратора)
- Текущая фаза: Phase 0Runtime core (✅ Implemented, pending commit) → дальше Phase 1 — Plots & interactions
- Последний коммит фичи: — (Ф0 + Ф1 реализованы, ещё не закоммичены — ждут оркестратора)
- Текущая фаза: Phase 1Plots & interactions (✅ Implemented, pending commit) → дальше Phase 2 — Physics
- Режим: Automated / Orchestrator / Incremental
- Новые публичные API для следующих фаз: `window.SimExpr`, `window.SimEngine.mount`, `window.registerSpecSim` / `window.SimAdapter`. Формат спеки v1 — в шапке `_sim_engine.js` и в handoff phase-0.
- Новые публичные API для следующих фаз: `window.SimExpr`, `window.SimEngine.mount`, `window.registerSpecSim` / `window.SimAdapter`. Формат спеки v1 + типы plot/readout/drag/vector — в шапке `_sim_engine.js` и в handoff phase-0/phase-1.
- Файлы Ф1 (несведённые с параллельной сессией): `frontend/js/labs/_sim_engine.js`, `frontend/js/labs/_sim_demo.js`.
+2 -2
View File
@@ -40,7 +40,7 @@
## Phases
- [x] Phase 0: Спека v1 + рантайм (формульные сцены) [domain: frontend] → [subplan](./phase-0-runtime-core.md)
- [ ] Phase 1: Графики + интеракции [domain: frontend] → [subplan](./phase-1-plots-interactions.md)
- [x] Phase 1: Графики + интеракции [domain: frontend] → [subplan](./phase-1-plots-interactions.md)
- [ ] Phase 2: Физический интегратор [domain: frontend] → [subplan](./phase-2-physics.md)
- [ ] Phase 3: БД + API (custom_sims) [domain: backend] → [subplan](./phase-3-persistence-api.md)
- [ ] Phase 4: Билдер (редактор) [domain: frontend] → [subplan](./phase-4-builder-ui.md)
@@ -53,7 +53,7 @@
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 0: Runtime core | frontend | ✅ Done | ✅ | ✅ | ✅ |
| Phase 1: Plots & interactions | frontend | ⬜ Not Started | | | |
| Phase 1: Plots & interactions | frontend | ✅ Done | | | |
| Phase 2: Physics | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 3: Persistence + API | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 4: Builder UI | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
+56 -13
View File
@@ -1,6 +1,6 @@
# Phase 1: Графики + интеракции
**Status:** ⬜ Not Started
**Status:** ✅ Implemented (не закоммичено — коммит за оркестратором)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
@@ -9,13 +9,13 @@
векторы и числовые readout. После фазы спека со слайдером, draggable-точкой и live-графиком работает.
## Tasks
- [ ] Plot-объект в спеке: `{ type:'plot', expr:'...', var:'x', range:[a,b], samples, trace? }`
рисует график выражения; `trace:true` — накапливает след по `t`. Переиспользовать `_graph_panel.js` (посмотреть API).
- [ ] Draggable-ручка: объект/маркер с `drag:{ param:'<name>', axis:'x|y|xy', min,max }` — перетаскивание мышью/тачем меняет параметр (и наоборот, позиция следует за параметром). Хит-тест в мир-координатах.
- [ ] Readout: `{ type:'readout', label, expr, unit, precision }` — живое значение выражения как текст/бейдж.
- [ ] Vector-объект с привязкой к (origin, dx, dy)-выражениям + стрелка.
- [ ] Тач-поддержка drag (pointer events), не ломая существующую логику доски/лабы.
- [ ] Обновить демо-спеку: добавить слайдер, draggable-точку, plot + readout.
- [x] Plot-объект в спеке: `{ type:'plot', expr:'...', var:'x', range:[a,b], samples, trace? }`
рисует график выражения; `trace:true` — накапливает след по `t`. Решение: рисуем на canvas движка в мир-координатах (НЕ тянем `GraphPanelUI` — он stacked time-series в фикс. оверлее, не `y=f(x)` инлайн). `samples` деф. 200, клампится 2..2000.
- [x] Draggable-ручка: `point`/`circle` с `drag:{ param, axis:'x|y|xy', min,max, paramY? }` — перетаскивание мышью/тачем (pointer events) меняет параметр(ы); позиция ручки следует за параметром (x/y объекта = тот же параметр). Хит-тест в экранных px (допуск 16px), приоритет ручек. Clamp по `drag.min/max` И по диапазону самого параметра.
- [x] Readout: `{ type:'readout', label, expr, unit, precision, x?, y? }` — живой бейдж; мягкая ошибка через `evalSafe` (NaN/синтаксис → «—», не роняет цикл). Без позиции — авто-столбик в верх-правом углу.
- [x] Vector-объект с привязкой к `origin:[ox,oy]` + `dx`/`dy`-выражениям + стрелка (x1/y1/x2/y2 тоже поддержаны). Стрелка уже была в Ф0.
- [x] Тач-поддержка drag (pointer events + `touchAction:none`), не ломая логику лабы (слушатели только на canvas движка, снимаются в destroy).
- [x] Обновить демо-спеку: +слайдеры x0/y0, draggable-старт (axis xy), plot траектории y(x), 2 readout (дальность R, высота H).
## Files to Modify/Create
- `frontend/js/labs/_sim_engine.js` — типы plot/readout/vector, drag-интеракции (modify)
@@ -31,10 +31,53 @@
- Сэмплинг графика разумный (без фриза на больших range).
## Review Checklist
- [ ] Все задачи выполнены
- [ ] Drag работает мышью и тачем
- [ ] Нет регрессий рантайма Ф0
- [ ] Нет эмодзи, стиль проекта
- [x] Все задачи выполнены
- [x] Drag работает мышью и тачем (pointer events; headless-тест clamp/sync OK)
- [x] Нет регрессий рантайма Ф0 (рендер всех 8 типов демо × 6 кадров без ошибок; point/segment/circle/rect/polyline/path/vector/label не тронуты в логике)
- [x] Нет эмодзи, стиль проекта (скан кодпойнтов — чисто; IIFE-стиль)
## Handoff to Next Phase
<!-- Заполняет реализатор -->
### Что готово (Phase 1) — только `_sim_engine.js` + `_sim_demo.js`
- **plot** — график выражения `f(var)` на отрезке, рисуется на canvas движка в мир-координатах.
- **drag** — `point`/`circle` становятся ручками; pointer events (мышь+тач); clamp двойной (drag.min/max + диапазон параметра).
- **readout** — живой бейдж на оверлее (тот же `_labelLayer`), мягкая ошибка через `SimExpr.evalSafe`.
- **vector** — добавлена форма `origin:[ox,oy]+dx/dy` (конец = origin + (dx,dy)); старая `x1/y1/x2/y2` сохранена; стрелка из Ф0.
### Формат новых типов спеки (точные поля)
```jsonc
// график выражения (мир-координаты)
{ type:'plot', expr:'sin(x)', var:'x', // var деф. 'x'
range:[a,b], // числа/выражения; деф. xmin..xmax
samples?:200, // клампится 2..2000
trace?:false, // true: точка (var=t) пишется в trail по времени;
// при trace без range статич. кривая НЕ рисуется
color?, width? }
// перетаскиваемая ручка (на point/circle)
{ type:'point', x:'x0', y:'y0',
drag:{ param:'x0', // axis x|y -> этот параметр; xy -> X
axis:'x'|'y'|'xy', // деф. 'x'
paramY:'y0', // ТОЛЬКО axis:'xy' -> Y (обязателен для 2D)
min?, max? } } // деф. ±Infinity; доп. clamp по диапазону параметра
// живой числовой бейдж
{ type:'readout', expr:'...', label?:'R', unit?:'м', precision?:2, // precision 0..8, деф.2
x?, y?, // мир-коорд.; без них — авто-столбик верх-право
color? }
// вектор (новая форма)
{ type:'vector', origin:[ox,oy], dx:'...', dy:'...', color?, width? }
```
### API инстанса (без изменений сигнатуры)
`mount(host,spec) -> { play, pause, reset, setParam, getParam, isRunning, destroy, el }`.
Добавлены внутр.: `_toWorld(px,py)`, `_setupDrag`, `_applyDrag`, `_setParamClamped`, `_drawPlot`, `_drawReadout`, `_accumPlotTrace`, `_paramRange`.
### Осталось / риски / на Фазу 2 (физика)
- **Однопроходный env** (`obj.x/obj.y`): из Ф0 — взаимные «вперёд»-ссылки дают значение прошлого кадра. Не трогал; при физике может потребоваться топосорт.
- **Drag только point/circle.** Тащить за конец вектора/вершину polyline — не реализовано (не требовалось).
- **readout позиционирование** на canvas — через DOM-оверлей (`_labelLayer`), как label. На сервере (Ф3) `label`/`unit` readout надо санитизировать как текст.
- **plot и trace на больших range**: ограничены `samples<=2000` и trail `<=2000` точек — без фриза. Очень большие range с тонкой кривой при экстремальном zoom могут ступенчатить — норм для учебных сцен.
- **Физика (`body`/`physics`)** — Фаза 2. Plot/drag/readout/vector полностью совместимы с физ-объектами (drag может задавать начальные условия, readout — читать body-величины, если их положить в env в Ф2).
-`lab.html` и `lab-glue.js` НЕ трогались (зона параллельной сессии). Новых файлов не создавал — всё в `_sim_engine.js`/`_sim_demo.js`.