diff --git a/CLAUDE.md b/CLAUDE.md index 0863a0b..4d2b4fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,3 +146,15 @@ git push origin master - Иконки кнопок (`_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 доказывает отсутствие утечек. - **На 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` готовы к переиспользованию. diff --git a/frontend/js/labs/_sim_engine.js b/frontend/js/labs/_sim_engine.js index b8aa993..996d782 100644 --- a/frontend/js/labs/_sim_engine.js +++ b/frontend/js/labs/_sim_engine.js @@ -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(); }; diff --git a/plans/sim-builder/CONTEXT.md b/plans/sim-builder/CONTEXT.md index 278145e..9e1a98c 100644 --- a/plans/sim-builder/CONTEXT.md +++ b/plans/sim-builder/CONTEXT.md @@ -1,6 +1,33 @@ # Feature Context: Конструктор симуляций (SimForge) ## 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 «Рабочее поле» РЕАЛИЗОВАН** (рабочее дерево, не закоммичено; ветка `feature/sim-builder`, общая с параллельной сессией materials/quota). Файл: только `frontend/js/labs/_sim_engine.js` (sim-builder.html НЕ потребовался). Один движок → эффект и в билдере, и в /lab, и на доске. diff --git a/plans/sim-builder/IMPROVEMENTS.md b/plans/sim-builder/IMPROVEMENTS.md index 9f2e186..8fc4891 100644 --- a/plans/sim-builder/IMPROVEMENTS.md +++ b/plans/sim-builder/IMPROVEMENTS.md @@ -41,9 +41,33 @@ - **На P2:** качество графики ОБЪЕКТОВ (lineJoin/cap, стрелки векторов, dash/opacity/градиент/glow, стили точек, затухающие трассы) — это `_drawObject`/`_drawTrail`/`_arrowHead`/`_drawPlot`/`_prepareObjects` в том же файле. Поля стилей объектов уже читаются в `_prepareObjects` (color/fill/width) — расширять там. -- [ ] **P2 — Качество графики объектов.** Скругление/сглаживание линий (lineJoin/cap), красивые стрелки +- [x] **P2 — Качество графики объектов.** Скругление/сглаживание линий (lineJoin/cap), красивые стрелки векторов, стили линий (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: оси с делениями/подписями, несколько кривых, заливка под кривой, маркеры точек, легенда; аккуратный стиль диаграмм. Файл: `_sim_engine.js` (+ билдер поля plot). @@ -57,7 +81,7 @@ | Phase | Status | Review | Committed | |-------|--------|--------|-----------| | P1 Working field | Done | ✅ PASS | ✅ | -| P2 Object graphics | ⬜ | ⬜ | ⬜ | +| P2 Object graphics | Done | ✅ PASS | ✅ | | P3 Charts | ⬜ | ⬜ | ⬜ | | P4 Builder UI | ⬜ | ⬜ | ⬜ | | P5 Direct manip + history | ⬜ | ⬜ | ⬜ |