feat(sim-builder): фаза 1 — графики (plot), drag-ручки, readout, векторы origin+dx/dy
This commit is contained in:
@@ -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'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user