feat(sim-builder): улучшение P2 — графика объектов: dash/opacity/градиент/glow, стрелки, стили точек, затухающие трассы, палитра
This commit is contained in:
+227
-39
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user