feat(sim-builder): улучшение P3 — графики: несколько кривых, заливка под кривой, маркеры, легенда
This commit is contained in:
+195
-17
@@ -137,6 +137,26 @@
|
||||
return v < 0 ? 0 : (v > 1 ? 1 : v);
|
||||
}
|
||||
|
||||
/* нормализовать стиль маркера узлов кривой plot: 'dot'|'ring'|'none' (деф. none). */
|
||||
function _markerStyle(v) {
|
||||
return (v === 'dot' || v === 'ring') ? v : 'none';
|
||||
}
|
||||
|
||||
/* полупрозрачная версия цвета для заливки под кривой. #RGB/#RRGGBB -> rgba(...,a);
|
||||
прочие форматы (rgb()/named) оставляем как есть (canvas сам применит globalAlpha). */
|
||||
function _fillAlpha(color, a) {
|
||||
if (typeof color !== 'string') return color;
|
||||
var m = color.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
|
||||
if (!m) return color;
|
||||
var h = m[1], r, g, b;
|
||||
if (h.length === 3) {
|
||||
r = parseInt(h[0] + h[0], 16); g = parseInt(h[1] + h[1], 16); b = parseInt(h[2] + h[2], 16);
|
||||
} else {
|
||||
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
|
||||
}
|
||||
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
|
||||
}
|
||||
|
||||
/* Компилятор свойства: число/строка -> { ev(env) } (всегда число). */
|
||||
function bind(value, dflt) {
|
||||
if (value === undefined || value === null) {
|
||||
@@ -635,13 +655,52 @@
|
||||
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;
|
||||
// ── P3: несколько кривых на одном plot ──
|
||||
// Источник кривых (приоритет): curves[] -> exprs[] -> одиночный expr (легаси).
|
||||
// Каждой кривой свой нормализованный стиль; цвет — явный или из палитры по индексу.
|
||||
var curveDefs = [];
|
||||
if (Array.isArray(o.curves) && o.curves.length) {
|
||||
curveDefs = o.curves.map(function (cv) {
|
||||
return (cv && typeof cv === 'object') ? cv : { expr: cv };
|
||||
});
|
||||
} else if (Array.isArray(o.exprs) && o.exprs.length) {
|
||||
curveDefs = o.exprs.map(function (ex) { return { expr: ex }; });
|
||||
} else {
|
||||
curveDefs = [{ expr: o.expr != null ? o.expr : '0' }];
|
||||
}
|
||||
var plotMarker = _markerStyle(o.marker);
|
||||
// plot-уровневые дефолты заливки/маркера наследуются кривыми (если у кривой не задано)
|
||||
prep.curves = curveDefs.map(function (cv, ci) {
|
||||
cv = cv || {};
|
||||
var cFill = (cv.fill !== undefined) ? cv.fill : o.fill;
|
||||
return {
|
||||
exprFn: bind(cv.expr != null ? cv.expr : '0', 0),
|
||||
color: cv.color || o.color || DEFAULT_PALETTE[ci % DEFAULT_PALETTE.length],
|
||||
label: (cv.label != null) ? String(cv.label) : '',
|
||||
width: num(cv.width, prep.width),
|
||||
lineStyle: (cv.lineStyle === 'dashed' || cv.lineStyle === 'dotted') ? cv.lineStyle
|
||||
: prep.lineStyle,
|
||||
opacity: (cv.opacity === undefined || cv.opacity === null) ? prep.opacity : _opacity(cv.opacity),
|
||||
// заливка под кривой: true -> полупрозрачный цвет кривой; строка -> явный цвет
|
||||
fill: (cFill === true || (typeof cFill === 'string' && cFill)) ? cFill : false,
|
||||
// маркеры узлов: none|dot|ring (наследует plot-уровень)
|
||||
marker: (cv.marker !== undefined) ? _markerStyle(cv.marker) : plotMarker,
|
||||
glow: prep.glow,
|
||||
glowColor: prep.glowColor,
|
||||
glowBlur: prep.glowBlur
|
||||
};
|
||||
});
|
||||
// легаси: одиночное выражение для trace-режима (накопление по t)
|
||||
prep.exprFn = prep.curves[0].exprFn;
|
||||
// легенда: показывать, если есть хотя бы одна подпись (можно явно legend:false)
|
||||
var anyLabel = prep.curves.some(function (c) { return !!c.label; });
|
||||
prep.legend = (o.legend === false) ? false : anyLabel;
|
||||
} else if (type === 'readout') {
|
||||
// компилируем выражение один раз: храним и fn (быстро), и ast (для evalSafe — мягкая ошибка)
|
||||
var rc = global.SimExpr ? global.SimExpr.compileValue(o.expr != null ? o.expr : '0')
|
||||
@@ -1471,11 +1530,14 @@
|
||||
}
|
||||
};
|
||||
|
||||
/* ── plot: график выражения f(var) на отрезке range (мир-координаты) ── */
|
||||
/* ── plot: график f(var) на отрезке range (мир-координаты) ──
|
||||
P3: несколько кривых (o.curves[]), заливка под кривой (к оси y=0), маркеры узлов,
|
||||
легенда (на canvas). Trace-режим (накопление по t) рисуется отдельно через _drawTrail. */
|
||||
SimEngineInstance.prototype._drawPlot = function (ctx, o, env) {
|
||||
// trace без явного range — только накапливаемый след (статической кривой нет)
|
||||
if (o.trace && !o.hasRange) return;
|
||||
var vp = this._vp();
|
||||
var W = this._cw, H = this._ch;
|
||||
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;
|
||||
@@ -1485,25 +1547,141 @@
|
||||
var prev = env[o.varName];
|
||||
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
|
||||
|
||||
ctx.save();
|
||||
this._applyStroke(ctx, o);
|
||||
ctx.strokeStyle = o.color;
|
||||
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]);
|
||||
var curves = o.curves || [];
|
||||
var legendItems = [];
|
||||
// y=0 в экранных px (для клипа заливки к оси X) + видимая область для клипа
|
||||
var zeroPy = this._toPx(0, 0)[1];
|
||||
for (var ci = 0; ci < curves.length; ci++) {
|
||||
var cv = curves[ci];
|
||||
// сэмплинг: экранные точки [px,py,worldY], разрывы как null (не-finite y -> пропуск сегмента)
|
||||
var pts = [];
|
||||
for (var i = 0; i < n; i++) {
|
||||
var xv = a + step * i;
|
||||
env[o.varName] = xv;
|
||||
var yv = cv.exprFn.ev(env);
|
||||
if (typeof yv !== 'number' || !isFinite(yv)) { pts.push(null); continue; }
|
||||
var p = this._toPx(xv, yv);
|
||||
pts.push([p[0], p[1], yv]);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
// заливка под кривой (между кривой и осью y=0), клиппится к видимой высоте
|
||||
if (cv.fill) {
|
||||
var fillCol = (cv.fill === true) ? _fillAlpha(cv.color, 0.18) : cv.fill;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = cv.opacity;
|
||||
ctx.fillStyle = fillCol;
|
||||
ctx.shadowBlur = 0;
|
||||
this._fillUnderCurve(ctx, pts, _clamp(zeroPy, 0, H));
|
||||
ctx.restore();
|
||||
}
|
||||
// линия кривой (через _applyStroke: dash/opacity/glow/width)
|
||||
this._applyStroke(ctx, cv);
|
||||
ctx.strokeStyle = cv.color;
|
||||
ctx.beginPath();
|
||||
var started = false;
|
||||
for (var k = 0; k < pts.length; k++) {
|
||||
if (!pts[k]) { started = false; continue; }
|
||||
if (!started) { ctx.moveTo(pts[k][0], pts[k][1]); started = true; }
|
||||
else ctx.lineTo(pts[k][0], pts[k][1]);
|
||||
}
|
||||
ctx.stroke();
|
||||
// маркеры узлов (с прореживанием: не чаще ~28px по экрану)
|
||||
if (cv.marker && cv.marker !== 'none') {
|
||||
this._drawCurveMarkers(ctx, pts, cv);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
if (o.legend && cv.label) legendItems.push({ color: cv.color, label: cv.label });
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// восстановить env
|
||||
if (hadPrev) env[o.varName] = prev; else delete env[o.varName];
|
||||
|
||||
// легенда (поверх кривых, в углу области plot, на canvas)
|
||||
if (legendItems.length) this._drawLegend(ctx, W, H, legendItems);
|
||||
};
|
||||
|
||||
/* Заливка области под полилинией к базовой линии y=baseY (экранные px). Каждый
|
||||
непрерывный сегмент (между разрывами null) заливается отдельным замкнутым контуром:
|
||||
curve-up -> вдоль кривой -> curve-down к baseY -> закрыть. baseY клиппится к canvas. */
|
||||
SimEngineInstance.prototype._fillUnderCurve = function (ctx, pts, baseY) {
|
||||
var i = 0, n = pts.length;
|
||||
while (i < n) {
|
||||
// найти начало непрерывного сегмента
|
||||
while (i < n && !pts[i]) i++;
|
||||
var startI = i;
|
||||
while (i < n && pts[i]) i++;
|
||||
var endI = i; // [startI, endI)
|
||||
if (endI - startI < 2) continue; // сегмент из <2 точек — заливать нечего
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[startI][0], baseY);
|
||||
for (var k = startI; k < endI; k++) ctx.lineTo(pts[k][0], pts[k][1]);
|
||||
ctx.lineTo(pts[endI - 1][0], baseY);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
/* Маркеры узлов кривой (dot|ring) с прореживанием по экранному расстоянию (~28px),
|
||||
чтобы не рисовать сотни точек. Цвет — цвет кривой; opacity наследуется от ctx. */
|
||||
SimEngineInstance.prototype._drawCurveMarkers = function (ctx, pts, cv) {
|
||||
var MIN_PX = 28; // минимальный шаг между маркерами по экрану
|
||||
var r = Math.max(2.5, (cv.width || 2) + 1.5);
|
||||
var marker = { color: cv.color, fillColor: cv.color, opacity: cv.opacity, glow: false,
|
||||
pointStyle: (cv.marker === 'ring') ? 'hollow' : 'filled', width: cv.width || 2 };
|
||||
var lastX = -1e9, lastY = -1e9;
|
||||
for (var k = 0; k < pts.length; k++) {
|
||||
var p = pts[k];
|
||||
if (!p) continue;
|
||||
var dx = p[0] - lastX, dy = p[1] - lastY;
|
||||
if (dx * dx + dy * dy < MIN_PX * MIN_PX) continue;
|
||||
this._drawPoint(ctx, marker, p[0], p[1], r);
|
||||
lastX = p[0]; lastY = p[1];
|
||||
}
|
||||
};
|
||||
|
||||
/* Компактная легенда в углу области plot (на canvas, без DOM): цветная метка + текст.
|
||||
Позиция: верх-право, со смещением вниз, чтобы не наезжать на ось Y/подписи. */
|
||||
SimEngineInstance.prototype._drawLegend = function (ctx, W, H, items) {
|
||||
if (!items.length) return;
|
||||
ctx.save();
|
||||
ctx.font = '12px Manrope,system-ui,sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'left';
|
||||
var pad = 8, rowH = 18, swatch = 11, gap = 7;
|
||||
// ширина по самой длинной подписи
|
||||
var maxTxt = 0;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var w = ctx.measureText(items[i].label).width;
|
||||
if (w > maxTxt) maxTxt = w;
|
||||
}
|
||||
var boxW = pad * 2 + swatch + gap + Math.ceil(maxTxt);
|
||||
var boxH = pad * 2 + items.length * rowH - (rowH - 14);
|
||||
// верх-право; отступ от края, не наезжает на бар кнопок (right/bottom) и оси
|
||||
var bx = W - boxW - 12, by = 12;
|
||||
if (bx < 6) bx = 6;
|
||||
// фон-плашка (полупрозрачная тёмная, как readout-бейджи)
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = 'rgba(13,13,26,0.78)';
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
|
||||
ctx.lineWidth = 1;
|
||||
if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill(); ctx.stroke(); }
|
||||
else { ctx.fillRect(bx, by, boxW, boxH); ctx.strokeRect(bx, by, boxW, boxH); }
|
||||
for (var j = 0; j < items.length; j++) {
|
||||
var cy = by + pad + 7 + j * rowH;
|
||||
// цветная метка (линия-свотч)
|
||||
ctx.strokeStyle = items[j].color;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx + pad, cy); ctx.lineTo(bx + pad + swatch, cy);
|
||||
ctx.stroke();
|
||||
// текст метки (светлый, без пользовательского цвета в DOM)
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.88)';
|
||||
ctx.fillText(items[j].label, bx + pad + swatch + gap, cy);
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
/* ── readout: живое значение выражения как бейдж на оверлее ── */
|
||||
|
||||
Reference in New Issue
Block a user