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
+12
View File
@@ -146,3 +146,15 @@ git push origin master
- Иконки кнопок (`_chevIcon/_fitIcon/_resetViewIcon`) — inline SVG `.ic`-стиль (без эмодзи). Вычислений выражений в P1 нет → eval/Function не вводились. - Иконки кнопок (`_chevIcon/_fitIcon/_resetViewIcon`) — inline SVG `.ic`-стиль (без эмодзи). Вычислений выражений в P1 нет → eval/Function не вводились.
- **Верификация P1**: `node --check` OK; headless-смоук (ручной DOM/canvas-стаб + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`, грузятся через `require`) 40/40: центрирование пустой спеки, zoom-инвариант курсора + кламп, pan-сдвиг `_off`, приоритет ручек над pan, drag-ручка пишет param, подписи-оверлей следуют zoom/pan (позиционируются по `_toPx`), fit/reset вида, ресайз сохраняет вид, рендер всех 10 типов объектов без throw, destroy снимает все canvas-листенеры. Стаб баланса addEventListener/removeEventListener доказывает отсутствие утечек. - **Верификация P1**: `node --check` OK; headless-смоук (ручной DOM/canvas-стаб + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`, грузятся через `require`) 40/40: центрирование пустой спеки, zoom-инвариант курсора + кламп, pan-сдвиг `_off`, приоритет ручек над pan, drag-ручка пишет param, подписи-оверлей следуют zoom/pan (позиционируются по `_toPx`), fit/reset вида, ресайз сохраняет вид, рендер всех 10 типов объектов без throw, destroy снимает все canvas-листенеры. Стаб баланса addEventListener/removeEventListener доказывает отсутствие утечек.
- **На P2 (графика объектов)**: расширять `_drawObject`/`_drawTrail`/`_arrowHead`/`_drawPlot` и чтение стилей в `_prepareObjects` (там уже читаются color/fill/width). - **На P2 (графика объектов)**: расширять `_drawObject`/`_drawTrail`/`_arrowHead`/`_drawPlot` и чтение стилей в `_prepareObjects` (там уже читаются color/fill/width).
### SimForge improvements — P2 (Качество графики объектов) — Learnings
Всё в `frontend/js/labs/_sim_engine.js`. Расширено чтение стилей в `_prepareObjects` + применение в `_drawObject`.
- **Два хелпера вместо повтора в каждой ветке**: `_applyStroke(ctx,o)` ставит `globalAlpha=opacity`, `lineWidth=width`, `lineJoin/lineCap='round'`, `setLineDash` по `lineStyle` (хелпер `_dashFor`, паттерн масштабируется от width), и glow→`shadowColor/shadowBlur` (если `o.glow`). `_fillStyleFor(ctx,o,x0,y0,x1,y1)` строит линейный градиент `gradient:[c0,c1]` по переданному bbox (try/catch — мусорный цвет падает на `fillColor`) или возвращает сплошной `fillColor`/null. **Каждая ветка `_drawObject` обёрнута в свой `ctx.save()/restore()`** → состояние (alpha/dash/shadow/join) НЕ протекает между объектами.
- **Безопасность цвета**: все новые цветовые поля (включая стопы `gradient`, `glowColor`/`shadow`) идут ТОЛЬКО в canvas-стоки (`fillStyle`/`strokeStyle`/`createLinearGradient`+`addColorStop`/`shadowColor`) — canvas игнорит мусор, XSS нет. ⛔ В DOM `style.cssText` пользовательские цвета НЕ кладутся (это `_drawLabel`/`_drawReadout` — НЕ трогались в P2).
- **Новые поля стиля спеки** (контракт для P4-контролов): `opacity` 0..1; `lineStyle` solid|dashed|dotted; `width` (0 → у circle/rect только заливка); `fill`/`fillColor`; `gradient:[c0,c1]` (приоритетнее fill, верт. по bbox, полигон — только при `closed`); `glow:true`/`shadow:'#c'`/`shadow:{blur}`/`glowColor`/`glowBlur` (деф. ВЫКЛ); `pointStyle` filled|hollow|cross|ring; `trailFade`(деф.true)/`trailWidth`(1.6)/`trailLen`(2000,макс 5000). Полные дефолты — IMPROVEMENTS.md Handoff P2.
- **Стрелки векторов**: `_arrowHead(ctx,a,b,color,width)` — заполненный «барбед»-треугольник (вырез у основания, не «галочка»), длина `_arrowHeadLen(width)=max(9,width*3.2)`px; тело линии укорочено на длину головы (`headLen*0.9`), голова всегда сплошная (`setLineDash([])` перед ней). **Точки** `_drawPoint(ctx,o,px,py,r)` — 4 стиля; filled-деф. = заполненный кружок + тонкая белая обводка (если не glow). **Трассы** `_drawTrail(ctx,pts,o)` — при `trailFade` рисуется ПОСЕГМЕНТНО (alpha 0.08→0.68 от хвоста к голове, «комета»), иначе одной полупрозрачной линией.
- **Палитра по умолчанию** `DEFAULT_PALETTE` (8 холодно-ярких тонов) — циклически `[i % 8]` в `_prepareObjects`, только если `color` не задан в спеке; явный color сохраняется.
- **Верификация P2**: `node --check` OK; headless-смоук (vm + DOM/canvas-стаб со счётчиками вызовов и проверкой баланса save/restore + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`) 23/23: 18-объектная спека (все типы + все новые поля) ×4 кадра без throw; **ctx не протекает** (depth=0, globalAlpha→1, shadowBlur→0, lineDash→[] после кадра); setLineDash/createLinearGradient/fill/stroke/arc вызваны; поля прочитаны; палитра+явный color; трасса накоплена; destroy чист. Эмодзи нет (скан: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только в комментарии стр.15.
- **На P3 (графики/диаграммы)**: `_drawPlot` уже зовёт `_applyStroke`. Расширять `_drawPlot` — оси-деления plot, несколько кривых, заливка под кривой, маркеры (переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint` готовы к переиспользованию.
+227 -39
View File
@@ -108,8 +108,35 @@
var DEFAULT_BG = '#0D0D1A'; 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; } 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) } (всегда число). */ /* Компилятор свойства: число/строка -> { ev(env) } (всегда число). */
function bind(value, dflt) { function bind(value, dflt) {
if (value === undefined || value === null) { if (value === undefined || value === null) {
@@ -546,7 +573,8 @@
var prep = { id: o.id || ('obj' + i), type: type, raw: o, b: {} }; 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.fillColor = o.fill || o.fillColor || null;
prep.width = num(o.width, 2); prep.width = num(o.width, 2);
prep.trail = !!o.trail; prep.trail = !!o.trail;
@@ -555,6 +583,27 @@
prep.size = num(o.size, 14); prep.size = num(o.size, 14);
prep.text = o.text != null ? String(o.text) : ''; 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; var B = prep.b;
function bp(key, dflt) { B[key] = bind(o[key], dflt); } function bp(key, dflt) { B[key] = bind(o[key], dflt); }
@@ -1163,7 +1212,7 @@
if (typeof tx === 'number' && typeof ty === 'number') { if (typeof tx === 'number' && typeof ty === 'number') {
var arr = this._trails[o.id] || (this._trails[o.id] = []); var arr = this._trails[o.id] || (this._trails[o.id] = []);
arr.push([tx, ty]); arr.push([tx, ty]);
if (arr.length > 2000) arr.shift(); if (arr.length > o.trailLen) arr.shift();
} }
} else if (o.type === 'plot' && o.trace) { } else if (o.type === 'plot' && o.trace) {
this._accumPlotTrace(o, env); this._accumPlotTrace(o, env);
@@ -1175,7 +1224,7 @@
var ot = this._objs[ti]; var ot = this._objs[ti];
if ((ot.trail || (ot.type === 'plot' && ot.trace)) && if ((ot.trail || (ot.type === 'plot' && ot.trace)) &&
this._trails[ot.id] && this._trails[ot.id].length > 1) { 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.save();
ctx.strokeStyle = color; ctx.strokeStyle = color;
ctx.globalAlpha = 0.55; ctx.lineWidth = lw;
ctx.lineWidth = 1.6;
ctx.lineJoin = 'round'; ctx.lineJoin = 'round';
ctx.beginPath(); ctx.lineCap = 'round';
for (var i = 0; i < pts.length; i++) { ctx.setLineDash([]);
var px = this._toPx(pts[i][0], pts[i][1]); var n = pts.length;
i === 0 ? ctx.moveTo(px[0], px[1]) : ctx.lineTo(px[0], px[1]); 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.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) { SimEngineInstance.prototype._drawObject = function (ctx, o, env) {
var B = o.b; var B = o.b;
switch (o.type) { switch (o.type) {
@@ -1260,11 +1363,7 @@
var p = this._toPx(pxw, pyw); var p = this._toPx(pxw, pyw);
// r точки — экранный радиус в пикселях (выражение допустимо) // r точки — экранный радиус в пикселях (выражение допустимо)
var r = Math.max(1, B.r.ev(env) || 6); var r = Math.max(1, B.r.ev(env) || 6);
ctx.save(); this._drawPoint(ctx, o, p[0], p[1], r);
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();
break; break;
} }
case 'segment': case 'segment':
@@ -1272,9 +1371,20 @@
var a = this._toPx(B.x1.ev(env), B.y1.ev(env)); var a = this._toPx(B.x1.ev(env), B.y1.ev(env));
var b = this._toPx(B.x2.ev(env), B.y2.ev(env)); var b = this._toPx(B.x2.ev(env), B.y2.ev(env));
ctx.save(); ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width; ctx.lineCap = 'round'; this._applyStroke(ctx, o);
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke(); ctx.strokeStyle = o.color;
if (o.type === 'vector') this._arrowHead(ctx, a, b, 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(); ctx.restore();
break; break;
} }
@@ -1284,11 +1394,17 @@
var c0 = this._toPx(cxw, cyw); var c0 = this._toPx(cxw, cyw);
var rad = Math.abs(B.r.ev(env)) * this._scale; var rad = Math.abs(B.r.ev(env)) * this._scale;
ctx.save(); ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width; this._applyStroke(ctx, o);
if (o.fillColor) { ctx.fillStyle = o.fillColor; } 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); ctx.beginPath(); ctx.arc(c0[0], c0[1], rad, 0, Math.PI * 2);
if (o.fillColor) ctx.fill(); if (cFill) { ctx.fillStyle = cFill; ctx.setLineDash([]); ctx.fill(); }
ctx.stroke(); if (o.width > 0) {
var cDash = _dashFor(o.lineStyle, o.width);
ctx.setLineDash(cDash || []);
ctx.stroke();
}
ctx.restore(); ctx.restore();
break; break;
} }
@@ -1298,9 +1414,15 @@
var tl = this._toPx(cx - rw / 2, cy + rh / 2); // верх-лево (Y вверх) var tl = this._toPx(cx - rw / 2, cy + rh / 2); // верх-лево (Y вверх)
var pw = rw * this._scale, ph = rh * this._scale; var pw = rw * this._scale, ph = rh * this._scale;
ctx.save(); ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width; this._applyStroke(ctx, o);
if (o.fillColor) { ctx.fillStyle = o.fillColor; ctx.fillRect(tl[0], tl[1], pw, ph); } ctx.strokeStyle = o.color;
ctx.strokeRect(tl[0], tl[1], pw, ph); 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(); ctx.restore();
break; break;
} }
@@ -1308,16 +1430,28 @@
case 'path': { case 'path': {
if (!o.pts || !o.pts.length) break; if (!o.pts || !o.pts.length) break;
ctx.save(); ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; this._applyStroke(ctx, o);
if (o.fillColor) ctx.fillStyle = o.fillColor; ctx.strokeStyle = o.color;
ctx.beginPath(); // bbox в экранных px для градиента (если задан)
var minX = Infinity, minY = Infinity, maxY = -Infinity;
var screenPts = [];
for (var k = 0; k < o.pts.length; k++) { 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)); 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.closed) ctx.closePath();
if (o.fillColor) ctx.fill(); // заливка только для замкнутого контура (path closed / polygon)
ctx.stroke(); 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(); ctx.restore();
break; break;
} }
@@ -1352,10 +1486,8 @@
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName); var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
ctx.save(); ctx.save();
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color; ctx.strokeStyle = o.color;
ctx.lineWidth = o.width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath(); ctx.beginPath();
var started = false; var started = false;
for (var i = 0; i < n; i++) { for (var i = 0; i < n; i++) {
@@ -1404,15 +1536,71 @@
this._labelLayer.appendChild(el); 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 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.save();
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.lineJoin = 'round';
ctx.translate(b[0], b[1]); ctx.rotate(ang); ctx.translate(b[0], b[1]); ctx.rotate(ang);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.55); ctx.lineTo(-s * 1.6, s * 0.55); ctx.moveTo(0, 0); // остриё
ctx.closePath(); ctx.fill(); 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(); ctx.restore();
}; };
+27
View File
@@ -1,6 +1,33 @@
# Feature Context: Конструктор симуляций (SimForge) # Feature Context: Конструктор симуляций (SimForge)
## Current State ## Current State
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P2 «Качество графики объектов» РЕАЛИЗОВАН** (рабочее дерево, не
закоммичено; ветка `feature/sim-builder`). Файл: только `frontend/js/labs/_sim_engine.js`. Один движок →
эффект и в билдере, и в /lab, и на доске.
- **Чтение стилей** расширено в `_prepareObjects`; применение — через два хелпера: `_applyStroke(ctx,o)`
(ставит globalAlpha=opacity, lineWidth=width, lineJoin/Cap='round', setLineDash по lineStyle, glow→shadow)
и `_fillStyleFor(ctx,o,x0,y0,x1,y1)` (линейный градиент `gradient:[c0,c1]` по bbox ИЛИ сплошной fillColor;
всё — canvas-стоки, мусорный цвет игнорится). Каждая ветка `_drawObject` в своём `save/restore`.
- **Новые поля стиля спеки** (контракт для P4-контролов): `opacity` 0..1, `lineStyle` solid|dashed|dotted,
`fill`/`gradient:[c0,c1]`, `glow:true`/`shadow`, `pointStyle` filled|hollow|cross|ring, `trailFade`/
`trailWidth`/`trailLen`. Полный список с дефолтами — в IMPROVEMENTS.md (Handoff P2→P3/P4).
- **Стрелки векторов** (`_arrowHead`/`_arrowHeadLen`): заполненный барбед-треугольник (вырез у основания),
длина `max(9,width*3.2)`px, тело линии укорочено на длину головы. **Точки** `_drawPoint` — 4 стиля
(filled-деф. = кружок + тонкая белая обводка). **Трассы** `_drawTrail(ctx,pts,o)` — посегментное
затухание (alpha 0.08→0.68 от хвоста к голове) либо одна линия без fade.
- **Палитра по умолчанию** `DEFAULT_PALETTE` (8 холодно-ярких тонов, циклически по индексу) вместо единого
`#06D6E0`; явный `color`/`fill` всегда сохраняется. `_drawPlot` теперь зовёт `_applyStroke` (dash/opacity/
glow на кривых).
- Верификация: `node --check` OK; headless-смоук (vm + DOM/canvas-стаб со счётчиками вызовов + РЕАЛЬНЫЕ
`_sim_expr.js`+`_sim_engine.js`) 23/23: рендер 18-объектной спеки (все типы + все новые поля) ×4 кадра без
throw; ctx не протекает (save/restore-баланс depth=0, globalAlpha→1, shadowBlur→0, lineDash→[] после кадра);
setLineDash/createLinearGradient/fill/stroke/arc вызваны (dashed/dotted/gradient/fills); arrowHeadLen
масштаб; все поля прочитаны; палитра применена + явный color сохранён; трасса накоплена; destroy чист.
Эмодзи нет (скан кодпойнтов: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только
в комментарии стр.15. git status: тронут только `_sim_engine.js`.
- **Следующее (P3):** графики/диаграммы (`_drawPlot`): оси-деления plot, несколько кривых, заливка под
кривой, маркеры точек (переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/
`_drawPoint` готовы к переиспользованию.
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P1 «Рабочее поле» РЕАЛИЗОВАН** (рабочее дерево, не закоммичено; - **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P1 «Рабочее поле» РЕАЛИЗОВАН** (рабочее дерево, не закоммичено;
ветка `feature/sim-builder`, общая с параллельной сессией materials/quota). Файл: только ветка `feature/sim-builder`, общая с параллельной сессией materials/quota). Файл: только
`frontend/js/labs/_sim_engine.js` (sim-builder.html НЕ потребовался). Один движок → эффект и в билдере, и в /lab, и на доске. `frontend/js/labs/_sim_engine.js` (sim-builder.html НЕ потребовался). Один движок → эффект и в билдере, и в /lab, и на доске.
+27 -3
View File
@@ -41,9 +41,33 @@
- **На P2:** качество графики ОБЪЕКТОВ (lineJoin/cap, стрелки векторов, dash/opacity/градиент/glow, стили - **На P2:** качество графики ОБЪЕКТОВ (lineJoin/cap, стрелки векторов, dash/opacity/градиент/glow, стили
точек, затухающие трассы) — это `_drawObject`/`_drawTrail`/`_arrowHead`/`_drawPlot`/`_prepareObjects` в том точек, затухающие трассы) — это `_drawObject`/`_drawTrail`/`_arrowHead`/`_drawPlot`/`_prepareObjects` в том
же файле. Поля стилей объектов уже читаются в `_prepareObjects` (color/fill/width) — расширять там. же файле. Поля стилей объектов уже читаются в `_prepareObjects` (color/fill/width) — расширять там.
- [ ] **P2 — Качество графики объектов.** Скругление/сглаживание линий (lineJoin/cap), красивые стрелки - [x] **P2 — Качество графики объектов.** Скругление/сглаживание линий (lineJoin/cap), красивые стрелки
векторов, стили линий (solid/dashed/dotted), opacity, градиент-заливки, опц. тень/glow, стили точек векторов, стили линий (solid/dashed/dotted), opacity, градиент-заливки, опц. тень/glow, стили точек
(filled/hollow/cross), затухающие трассы; приятная дефолтная палитра. Файл: `_sim_engine.js`. (filled/hollow/cross/ring), затухающие трассы; приятная дефолтная палитра. Файл: `_sim_engine.js`.
**Handoff (P2 → P3/P4): новые поля стиля спеки** (контракт для контролов билдера в P4). Все рендерятся
ТОЛЬКО на canvas (`fillStyle/strokeStyle/createLinearGradient/shadowColor`) — XSS нет, мусорный цвет
игнорится canvas. Читаются в `_prepareObjects`, применяются в `_drawObject` через хелперы `_applyStroke`
(alpha/lineWidth/join/cap/dash/glow) и `_fillStyleFor` (градиент или сплошная заливка):
- `opacity` — число `0..1` (деф. 1) → `globalAlpha` на время отрисовки объекта (восстанавливается).
- `lineStyle` — `'solid'|'dashed'|'dotted'` (деф. solid) → `setLineDash` (паттерн масштабируется от `width`).
- `width` — толщина штриха (деф. 2); для circle/rect `width:0` отключает обводку (только заливка).
- `fill`/`fillColor` — цвет заливки (circle/rect/закрытый path). `gradient:[c0,c1]` — линейный градиент
(вертикальный по bbox), приоритетнее `fill`. Полигон-заливка только при `closed:true`.
- `glow:true` ИЛИ `shadow:'#color'` ИЛИ `shadow:{blur}` — свечение (`shadowColor/shadowBlur`); деф. ВЫКЛ
(производительность). `glowColor`/`glowBlur` — точечная настройка (деф. цвет объекта / blur 12).
- `pointStyle` (point) — `'filled'|'hollow'|'cross'|'ring'` (деф. filled: заполненный кружок + тонкая
белая обводка). hollow — только обводка, ring — толстое кольцо, cross — крестик.
- `trailFade` (деф. true) — затухающая трасса (старые сегменты прозрачнее, посегментно alpha 0.08→0.68);
`trailWidth` (деф. 1.6), `trailLen` (деф. 2000, макс 5000) — толщина/длина следа. `trailColor` — как было.
- **Палитра по умолчанию**: если `color` не задан — циклически `DEFAULT_PALETTE[i % 8]` (cyan/violet/pink/
emerald/amber/blue/rose/green) вместо единого `#06D6E0`. Явный `color` всегда сохраняется.
- **Стрелки векторов** (`_arrowHead`/`_arrowHeadLen`): заполненный «барбед»-треугольник (вырез у основания),
длина = `max(9, width*3.2)` px; тело линии укорочено на длину головы (не торчит сквозь остриё).
- **На P3** (графики/диаграммы): `_drawPlot` уже использует `_applyStroke` (dash/opacity/glow работают на
кривых). Для P3 расширять `_drawPlot` — оси-делений plot, несколько кривых, заливка под кривой, маркеры
точек (можно переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint`
готовы к переиспользованию.
- [ ] **P3 — Графики/диаграммы (визуал charts).** Для plot: оси с делениями/подписями, несколько кривых, - [ ] **P3 — Графики/диаграммы (визуал charts).** Для plot: оси с делениями/подписями, несколько кривых,
заливка под кривой, маркеры точек, легенда; аккуратный стиль диаграмм. Файл: `_sim_engine.js` (+ билдер заливка под кривой, маркеры точек, легенда; аккуратный стиль диаграмм. Файл: `_sim_engine.js` (+ билдер
поля plot). поля plot).
@@ -57,7 +81,7 @@
| Phase | Status | Review | Committed | | Phase | Status | Review | Committed |
|-------|--------|--------|-----------| |-------|--------|--------|-----------|
| P1 Working field | Done | ✅ PASS | ✅ | | P1 Working field | Done | ✅ PASS | ✅ |
| P2 Object graphics | ⬜ | ⬜ | | | P2 Object graphics | Done | ✅ PASS | |
| P3 Charts | ⬜ | ⬜ | ⬜ | | P3 Charts | ⬜ | ⬜ | ⬜ |
| P4 Builder UI | ⬜ | ⬜ | ⬜ | | P4 Builder UI | ⬜ | ⬜ | ⬜ |
| P5 Direct manip + history | ⬜ | ⬜ | ⬜ | | P5 Direct manip + history | ⬜ | ⬜ | ⬜ |