feat(sim-builder): улучшение P2 — графика объектов: dash/opacity/градиент/glow, стрелки, стили точек, затухающие трассы, палитра

This commit is contained in:
Maxim Dolgolyov
2026-06-13 14:10:23 +03:00
parent 4be3fbde50
commit 222005c0ba
4 changed files with 293 additions and 42 deletions
+227 -39
View File
@@ -108,8 +108,35 @@
var DEFAULT_BG = '#0D0D1A';
/* Приятная дефолт-палитра объектов (если color не задан в спеке) — циклически по
индексу объекта. Холодно-яркие тона, контрастные на тёмном фоне DEFAULT_BG. */
var DEFAULT_PALETTE = [
'#22D3EE', // cyan
'#A78BFA', // violet
'#F472B6', // pink
'#34D399', // emerald
'#FBBF24', // amber
'#60A5FA', // blue
'#FB7185', // rose
'#4ADE80' // green
];
function num(v, dflt) { return typeof v === 'number' && isFinite(v) ? v : dflt; }
/* dash-паттерн для lineStyle (в пикселях; масштабируется толщиной линии). */
function _dashFor(style, width) {
var w = (typeof width === 'number' && width > 0) ? width : 2;
if (style === 'dashed') return [Math.max(6, w * 3), Math.max(4, w * 2.2)];
if (style === 'dotted') return [Math.max(1, w * 0.9), Math.max(3, w * 2)];
return null; // solid
}
/* нормализовать opacity к [0..1]; не число -> 1 (без прозрачности). */
function _opacity(v) {
if (typeof v !== 'number' || !isFinite(v)) return 1;
return v < 0 ? 0 : (v > 1 ? 1 : v);
}
/* Компилятор свойства: число/строка -> { ev(env) } (всегда число). */
function bind(value, dflt) {
if (value === undefined || value === null) {
@@ -546,7 +573,8 @@
var prep = { id: o.id || ('obj' + i), type: type, raw: o, b: {} };
// общие визуальные поля (не привязки)
prep.color = o.color || '#06D6E0';
// цвет: явный из спеки, иначе циклическая дефолт-палитра (приятнее единого #06D6E0)
prep.color = o.color || DEFAULT_PALETTE[i % DEFAULT_PALETTE.length];
prep.fillColor = o.fill || o.fillColor || null;
prep.width = num(o.width, 2);
prep.trail = !!o.trail;
@@ -555,6 +583,27 @@
prep.size = num(o.size, 14);
prep.text = o.text != null ? String(o.text) : '';
// ── P2: расширенные стили графики (рендерятся ТОЛЬКО на canvas — XSS-безопасно) ──
// lineStyle: solid|dashed|dotted -> setLineDash; opacity 0..1 -> globalAlpha
prep.lineStyle = (o.lineStyle === 'dashed' || o.lineStyle === 'dotted') ? o.lineStyle : 'solid';
prep.opacity = (o.opacity === undefined || o.opacity === null) ? 1 : _opacity(o.opacity);
// glow/shadow: свечение акцентных объектов (по умолчанию ВЫКЛ — производительность)
prep.glow = (o.glow === true || (o.shadow && o.shadow !== false)) || false;
prep.glowColor = (o.shadow && typeof o.shadow === 'string') ? o.shadow
: (o.glowColor || prep.color);
prep.glowBlur = num((o.shadow && typeof o.shadow === 'object') ? o.shadow.blur : o.glowBlur, 12);
// линейный градиент заливки: gradient:[c0,c1] (цвета только в canvas-стоки)
prep.gradient = (Array.isArray(o.gradient) && o.gradient.length >= 2)
? [String(o.gradient[0]), String(o.gradient[1])] : null;
// стиль точки: filled|hollow|cross|ring (деф. filled — заполненный кружок с обводкой)
prep.pointStyle = (o.pointStyle === 'hollow' || o.pointStyle === 'cross' || o.pointStyle === 'ring')
? o.pointStyle : 'filled';
// трасса: затухание (fade — старые сегменты прозрачнее, деф. вкл), длина и толщина
prep.trailFade = (o.trailFade === false) ? false : true;
prep.trailWidth = num(o.trailWidth, 1.6);
prep.trailLen = (typeof o.trailLen === 'number' && o.trailLen > 1)
? Math.min(5000, o.trailLen | 0) : 2000;
// числовые/выражения-привязки по типу
var B = prep.b;
function bp(key, dflt) { B[key] = bind(o[key], dflt); }
@@ -1163,7 +1212,7 @@
if (typeof tx === 'number' && typeof ty === 'number') {
var arr = this._trails[o.id] || (this._trails[o.id] = []);
arr.push([tx, ty]);
if (arr.length > 2000) arr.shift();
if (arr.length > o.trailLen) arr.shift();
}
} else if (o.type === 'plot' && o.trace) {
this._accumPlotTrace(o, env);
@@ -1175,7 +1224,7 @@
var ot = this._objs[ti];
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);
this._drawTrail(ctx, this._trails[ot.id], ot);
}
}
@@ -1234,21 +1283,75 @@
}
};
SimEngineInstance.prototype._drawTrail = function (ctx, pts, color) {
/* Трасса: ломаная по накопленным точкам. С затуханием (trailFade) старые сегменты
рисуются прозрачнее новых (по-сегментно с растущей alpha) — «комета». Без fade —
одной линией (быстрее). Цвет/толщина/длина — из полей объекта (trailColor/trailWidth). */
SimEngineInstance.prototype._drawTrail = function (ctx, pts, o) {
var color = (o.trailColor || o.color);
var lw = o.trailWidth || 1.6;
ctx.save();
ctx.strokeStyle = color;
ctx.globalAlpha = 0.55;
ctx.lineWidth = 1.6;
ctx.lineWidth = lw;
ctx.lineJoin = 'round';
ctx.beginPath();
for (var i = 0; i < pts.length; i++) {
var px = this._toPx(pts[i][0], pts[i][1]);
i === 0 ? ctx.moveTo(px[0], px[1]) : ctx.lineTo(px[0], px[1]);
ctx.lineCap = 'round';
ctx.setLineDash([]);
var n = pts.length;
if (!o.trailFade || n < 3) {
// без затухания — одной линией с постоянной полупрозрачностью
ctx.globalAlpha = 0.55;
ctx.beginPath();
for (var i = 0; i < n; i++) {
var px = this._toPx(pts[i][0], pts[i][1]);
i === 0 ? ctx.moveTo(px[0], px[1]) : ctx.lineTo(px[0], px[1]);
}
ctx.stroke();
ctx.restore();
return;
}
// с затуханием: посегментно, alpha растёт от хвоста (старые) к голове (новые)
var prev = this._toPx(pts[0][0], pts[0][1]);
for (var j = 1; j < n; j++) {
var cur = this._toPx(pts[j][0], pts[j][1]);
var f = j / (n - 1); // 0 (хвост) .. 1 (голова)
ctx.globalAlpha = 0.08 + f * 0.6; // плавно ярче к голове
ctx.beginPath();
ctx.moveTo(prev[0], prev[1]);
ctx.lineTo(cur[0], cur[1]);
ctx.stroke();
prev = cur;
}
ctx.stroke();
ctx.restore();
};
/* Применить общие стили объекта к ctx (вызывать ВНУТРИ ctx.save()/restore() в каждой
ветке _drawObject, чтобы состояние не «протекало» между объектами). Ставит:
globalAlpha (opacity), lineWidth, lineJoin/lineCap (round — сглаженные стыки/торцы),
setLineDash (lineStyle), shadow (glow). Цвета НЕ ставит (их задаёт ветка по типу). */
SimEngineInstance.prototype._applyStroke = function (ctx, o) {
ctx.globalAlpha = o.opacity;
ctx.lineWidth = o.width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
var dash = _dashFor(o.lineStyle, o.width);
ctx.setLineDash(dash || []);
if (o.glow) { ctx.shadowColor = o.glowColor; ctx.shadowBlur = o.glowBlur; }
};
/* Построить заливку для объекта: либо линейный градиент gradient:[c0,c1] по bbox
(x0,y0)-(x1,y1) в экранных px, либо сплошной fillColor. Возвращает CanvasGradient
или строку-цвет (оба — безопасные canvas-стоки), либо null если заливки нет. */
SimEngineInstance.prototype._fillStyleFor = function (ctx, o, x0, y0, x1, y1) {
if (o.gradient) {
try {
var g = ctx.createLinearGradient(x0, y0, x1, y1);
g.addColorStop(0, o.gradient[0]);
g.addColorStop(1, o.gradient[1]);
return g;
} catch (e) { /* мусорный цвет в градиенте — упасть на fillColor */ }
}
return o.fillColor || null;
};
SimEngineInstance.prototype._drawObject = function (ctx, o, env) {
var B = o.b;
switch (o.type) {
@@ -1260,11 +1363,7 @@
var p = this._toPx(pxw, pyw);
// r точки — экранный радиус в пикселях (выражение допустимо)
var r = Math.max(1, B.r.ev(env) || 6);
ctx.save();
ctx.fillStyle = o.color;
ctx.shadowColor = o.color; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.arc(p[0], p[1], r, 0, Math.PI * 2); ctx.fill();
ctx.restore();
this._drawPoint(ctx, o, p[0], p[1], r);
break;
}
case 'segment':
@@ -1272,9 +1371,20 @@
var a = this._toPx(B.x1.ev(env), B.y1.ev(env));
var b = this._toPx(B.x2.ev(env), B.y2.ev(env));
ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
if (o.type === 'vector') this._arrowHead(ctx, a, b, o.color);
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
if (o.type === 'vector') {
// укоротить тело стрелки на длину головы, чтобы линия не торчала сквозь остриё
var ang0 = Math.atan2(b[1] - a[1], b[0] - a[0]);
var headLen = this._arrowHeadLen(o.width);
var bx = b[0] - Math.cos(ang0) * headLen * 0.9;
var by = b[1] - Math.sin(ang0) * headLen * 0.9;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(bx, by); ctx.stroke();
ctx.setLineDash([]); // голова — всегда сплошная заливка
this._arrowHead(ctx, a, b, o.color, o.width);
} else {
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.restore();
break;
}
@@ -1284,11 +1394,17 @@
var c0 = this._toPx(cxw, cyw);
var rad = Math.abs(B.r.ev(env)) * this._scale;
ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width;
if (o.fillColor) { ctx.fillStyle = o.fillColor; }
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
// заливка: градиент (вертикальный по bbox круга) или сплошной fillColor
var cFill = this._fillStyleFor(ctx, o, c0[0], c0[1] - rad, c0[0], c0[1] + rad);
ctx.beginPath(); ctx.arc(c0[0], c0[1], rad, 0, Math.PI * 2);
if (o.fillColor) ctx.fill();
ctx.stroke();
if (cFill) { ctx.fillStyle = cFill; ctx.setLineDash([]); ctx.fill(); }
if (o.width > 0) {
var cDash = _dashFor(o.lineStyle, o.width);
ctx.setLineDash(cDash || []);
ctx.stroke();
}
ctx.restore();
break;
}
@@ -1298,9 +1414,15 @@
var tl = this._toPx(cx - rw / 2, cy + rh / 2); // верх-лево (Y вверх)
var pw = rw * this._scale, ph = rh * this._scale;
ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width;
if (o.fillColor) { ctx.fillStyle = o.fillColor; ctx.fillRect(tl[0], tl[1], pw, ph); }
ctx.strokeRect(tl[0], tl[1], pw, ph);
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
var rFill = this._fillStyleFor(ctx, o, tl[0], tl[1], tl[0], tl[1] + ph); // верт. градиент
if (rFill) { ctx.fillStyle = rFill; ctx.setLineDash([]); ctx.fillRect(tl[0], tl[1], pw, ph); }
if (o.width > 0) {
var rDash = _dashFor(o.lineStyle, o.width);
ctx.setLineDash(rDash || []);
ctx.strokeRect(tl[0], tl[1], pw, ph);
}
ctx.restore();
break;
}
@@ -1308,16 +1430,28 @@
case 'path': {
if (!o.pts || !o.pts.length) break;
ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
if (o.fillColor) ctx.fillStyle = o.fillColor;
ctx.beginPath();
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
// bbox в экранных px для градиента (если задан)
var minX = Infinity, minY = Infinity, maxY = -Infinity;
var screenPts = [];
for (var k = 0; k < o.pts.length; k++) {
var pp = this._toPx(o.pts[k].x.ev(env), o.pts[k].y.ev(env));
k === 0 ? ctx.moveTo(pp[0], pp[1]) : ctx.lineTo(pp[0], pp[1]);
screenPts.push(pp);
if (pp[0] < minX) minX = pp[0];
if (pp[1] < minY) minY = pp[1];
if (pp[1] > maxY) maxY = pp[1];
}
var plFill = this._fillStyleFor(ctx, o, minX, minY, minX, maxY);
ctx.beginPath();
for (var k2 = 0; k2 < screenPts.length; k2++) {
k2 === 0 ? ctx.moveTo(screenPts[k2][0], screenPts[k2][1])
: ctx.lineTo(screenPts[k2][0], screenPts[k2][1]);
}
if (o.closed) ctx.closePath();
if (o.fillColor) ctx.fill();
ctx.stroke();
// заливка только для замкнутого контура (path closed / polygon)
if (plFill && o.closed) { ctx.fillStyle = plFill; var savedDash = ctx.getLineDash ? ctx.getLineDash() : []; ctx.setLineDash([]); ctx.fill(); ctx.setLineDash(savedDash); }
if (o.width > 0) ctx.stroke();
ctx.restore();
break;
}
@@ -1352,10 +1486,8 @@
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
ctx.save();
this._applyStroke(ctx, o);
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++) {
@@ -1404,15 +1536,71 @@
this._labelLayer.appendChild(el);
};
SimEngineInstance.prototype._arrowHead = function (ctx, a, b, color) {
/* длина головы стрелки (px) — масштабируется от толщины линии (мин. 9px). */
SimEngineInstance.prototype._arrowHeadLen = function (width) {
var w = (typeof width === 'number' && width > 0) ? width : 2;
return Math.max(9, w * 3.2);
};
/* Аккуратная заполненная стрелка-голова (треугольник с лёгким вырезом у основания —
«барбед» силуэт, не «галочка»). Масштаб от width. Рисуется сплошной заливкой. */
SimEngineInstance.prototype._arrowHead = function (ctx, a, b, color, width) {
var ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
var s = 9;
var len = this._arrowHeadLen(width);
var halfW = len * 0.42; // полуширина основания
var notch = len * 0.26; // глубина выреза у основания (барб)
ctx.save();
ctx.fillStyle = color;
ctx.lineJoin = 'round';
ctx.translate(b[0], b[1]); ctx.rotate(ang);
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.55); ctx.lineTo(-s * 1.6, s * 0.55);
ctx.closePath(); ctx.fill();
ctx.moveTo(0, 0); // остриё
ctx.lineTo(-len, -halfW); // левое крыло
ctx.lineTo(-len + notch, 0); // вырез у основания
ctx.lineTo(-len, halfW); // правое крыло
ctx.closePath();
ctx.fill();
ctx.restore();
};
/* Точка: стиль pointStyle (filled|hollow|cross|ring). Применяет opacity/glow.
filled — заполненный кружок с тонкой обводкой (красивый дефолт);
hollow — только обводка; ring — толстое кольцо; cross — крестик. */
SimEngineInstance.prototype._drawPoint = function (ctx, o, px, py, r) {
ctx.save();
ctx.globalAlpha = o.opacity;
if (o.glow) { ctx.shadowColor = o.glowColor; ctx.shadowBlur = o.glowBlur; }
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
var style = o.pointStyle;
if (style === 'cross') {
ctx.strokeStyle = o.color;
ctx.lineWidth = Math.max(1.5, o.width);
var c = r;
ctx.beginPath();
ctx.moveTo(px - c, py - c); ctx.lineTo(px + c, py + c);
ctx.moveTo(px - c, py + c); ctx.lineTo(px + c, py - c);
ctx.stroke();
} else if (style === 'hollow') {
ctx.strokeStyle = o.color;
ctx.lineWidth = Math.max(1.5, o.width);
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.stroke();
} else if (style === 'ring') {
ctx.strokeStyle = o.color;
ctx.lineWidth = Math.max(2, r * 0.5);
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.stroke();
} else {
// filled (деф.): заполненный кружок + тонкая контрастная обводка для чёткости
var fill = (o.fillColor || o.color);
ctx.fillStyle = fill;
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.fill();
if (!o.glow) { // тонкая обводка чётче без свечения
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.stroke();
}
}
ctx.restore();
};