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