@@ -132,14 +132,11 @@
var objs = Array . isArray ( spec . objects ) ? spec . objects : [ ] ;
st . objects = [ ] ; st . plots = [ ] ;
objs . forEach ( function ( o ) {
var clone = Object . assign ( { _uid : uid ( 'o' ) } , o ) ;
if ( o . type === 'plot' ) {
// Восстановить UI-поля диапазона из spec range[a,b], иначе при пересохранении
// normalizePlotForSpec не увидит range_a/range_b и диапазон молча потеряется.
if ( Array . isArray ( o . range ) ) { clone . range _a = o . range [ 0 ] ; clone . range _b = o . range [ 1 ] ; }
delete clone . range ;
st . plots . push ( clone ) ;
} else st . objects . push ( clone ) ;
st . plots . push ( loadPlot ( o ) ) ;
} else {
st . objects . push ( Object . assign ( { _uid : uid ( 'o' ) } , o ) ) ;
}
} ) ;
// physics
var ph = spec . physics || { } ;
@@ -162,10 +159,10 @@
Builder . prototype . buildSpec = function ( ) {
var st = this . st ;
var objects = [ ] ;
// обычные объекты
st . objects . forEach ( function ( o ) { objects . push ( stripObj ( o ) ) ; } ) ;
// обычные объекты (скрытые hidden:true не попадают в спеку — движок не знает о hidden)
st . objects . forEach ( function ( o ) { if ( o . hidden ) return ; objects . push ( stripObj ( o ) ) ; } ) ;
// plot-объекты
st . plots . forEach ( function ( o ) { objects . push ( stripObj ( o ) ) ; } ) ;
st . plots . forEach ( function ( o ) { if ( o . hidden ) return ; objects . push ( stripObj ( o ) ) ; } ) ;
var spec = {
specVersion : SPEC _VERSION ,
@@ -199,13 +196,26 @@
return spec ;
} ;
/* ── Удаление UI-метаданных (_uid и пуст ых полей) из объекта спеки ── */
/* ── Удаление UI-метаданных (_uid, пустых и дефолтных стилев ых полей) из объекта спеки.
Дефолты стиля не сериализуем — спека минимальна и round-trip стабилен (loadFromSim
восстанавливает их обратно из дефолтов контролов). hidden никогда не идёт в спеку. ── */
function isDefaultStyle ( k , v ) {
if ( k === 'hidden' ) return true ; // UI-флаг, не для движка
if ( k === 'glow' && v === false ) return true ;
if ( k === 'trail' && v === false ) return true ;
if ( k === 'closed' && v === false ) return true ;
if ( k === 'lineStyle' && v === 'solid' ) return true ;
if ( k === 'pointStyle' && v === 'filled' ) return true ;
if ( k === 'opacity' && ( v === 1 || v === '1' ) ) return true ;
return false ;
}
function stripObj ( o ) {
var out = { } ;
Object . keys ( o ) . forEach ( function ( k ) {
if ( k === '_uid' ) return ;
var v = o [ k ] ;
if ( v === '' || v === undefined || v === null ) return ;
if ( isDefaultStyle ( k , v ) ) return ;
out [ k ] = v ;
} ) ;
return out ;
@@ -467,17 +477,27 @@
var total = st . objects . length + st . plots . length ;
if ( total > LIMITS . objects ) errs . push ( 'Слишком много объектов (макс ' + LIMITS . objects + ').' ) ;
// выражения ( объекты + графики)
// выражения объектов
var self = this ;
st . objects . concat ( st . plots ) . forEach ( function ( o , i ) {
function checkExpr ( v , where ) {
if ( typeof v !== 'string' || v === '' ) return ;
if ( v . length > LIMITS . exprLen ) errs . push ( where + ': выражение длиннее ' + LIMITS . exprLen + ' симв.' ) ;
var c = global . SimExpr ? global . SimExpr . compile ( v ) : { error : null } ;
if ( c . error ) errs . push ( where + ': ' + c . error ) ;
}
st . objects . forEach ( function ( o , i ) {
exprFieldsOf ( o ) . forEach ( function ( f ) {
var v = o [ f ] ;
if ( typeof v !== 'string' || v === '' ) return ;
if ( v . length > LIMITS . exprLen ) errs . push ( 'Объект #' + ( i + 1 ) + ': выражение «' + f + '» длиннее ' + LIMITS . exprLen + ' симв.' ) ;
var c = global . SimExpr ? global . SimExpr . compile ( v ) : { error : null } ;
if ( c . error ) errs . push ( 'Объект #' + ( i + 1 ) + ' (' + o . type + '), поле «' + f + '»: ' + c . error ) ;
checkExpr ( o [ f ] , 'Объект #' + ( i + 1 ) + ' (' + o . type + '), поле «' + f + '»' ) ;
} ) ;
} ) ;
// выражения графиков: кривые + границы диапазона
st . plots . forEach ( function ( o , i ) {
( Array . isArray ( o . curves ) ? o . curves : [ ] ) . forEach ( function ( cv , ci ) {
checkExpr ( cv . expr , 'График #' + ( i + 1 ) + ', кривая ' + ( ci + 1 ) ) ;
} ) ;
checkExpr ( typeof o . range _a === 'string' ? o . range _a : '' , 'График #' + ( i + 1 ) + ', «от»' ) ;
checkExpr ( typeof o . range _b === 'string' ? o . range _b : '' , 'График #' + ( i + 1 ) + ', «до»' ) ;
} ) ;
// physics
if ( st . physics . enabled ) {
@@ -643,13 +663,17 @@
/* Редактор одного объекта: поля зависят от типа. */
Builder . prototype . objectEditor = function ( o , i ) {
var selected = ( this . _selObjId === o . _uid ) ;
var hidden = ! ! o . hidden ;
var n = this . st . objects . length ;
var fields = OBJ _FIELDS [ o . type ] || [ ] ;
var inner = fields . map ( function ( f ) {
if ( f . kind === 'check' ) {
return '<label class="sbu-of-check"><input type="checkbox" data-of="' + f . key + '"' + ( o [ f . key ] ? ' checked' : '' ) + '/> ' + esc ( f . label ) + '</label>' ;
}
if ( f . kind === 'color' ) {
return miniField ( f . label , '<input class="sbu-in sbu-in-color" type="text" data-of="' + f . key + '" value="' + esc ( o [ f . key ] == null ? '' : o [ f . key ] ) + '" placeholder="#06D6E0" />' ) ;
// fill/trailColor — очищаемые («нет заливки»); основной color — нет
var clearable = ( f . key === 'fill' || f . key === 'fillColor' || f . key === 'trailColor' ) ;
return colorCtl ( f . label , 'data-of="' + f . key + '"' , o [ f . key ] , clearable ) ;
}
if ( f . kind === 'text' ) {
return miniField ( f . label , '<input class="sbu-in" data-of="' + f . key + '" value="' + esc ( o [ f . key ] == null ? '' : o [ f . key ] ) + '" placeholder="' + esc ( f . ph || '' ) + '" />' ) ;
@@ -665,51 +689,135 @@
( err ? '<span class="sbu-of-err">' + esc ( err ) + '</span>' : '' ) +
'</div>' ;
} ) . join ( '' ) ;
// ── блок «Стиль» (P4): opacity/lineStyle/pointStyle/glow/gradient ──
var style = STYLE _FOR [ o . type ] ? this . styleBlock ( o ) : '' ;
// ── label с LaTeX-превью ──
var latexPrev = '' ;
if ( o . type === 'label' && o . text ) {
latexPrev = '<div class="sbu-latex" data-latex="' + esc ( o . text ) + '"></div>' ;
}
return '<div class="sbu-obj' + ( selected ? ' sel' : '' ) + '" data-oi="' + i + '">' +
return '<div class="sbu-obj' + ( selected ? ' sel' : '' ) + ( hidden ? ' is-hidden' : '' ) + '" data-oi="' + i + '">' +
'<div class="sbu-obj-hdr">' +
'<span class="sbu-obj-type">' + ( TYPE _LABEL [ o . type ] || o . type ) + '</span>' +
'<input class="sbu-in sbu-in-id" data-of="id" value="' + esc ( o . id == null ? '' : o . id ) + '" placeholder="id" title="Идентификатор (для ссылок obj.x/obj.y)" />' +
'<button class="sbu-icon-btn sbu-zord" data-oup="' + i + '" title="Выше"' + ( i === 0 ? ' disabled' : '' ) + '>' + ICON . up + '</button>' +
'<button class="sbu-icon-btn sbu-zord" data-odown="' + i + '" title="Ниже"' + ( i === n - 1 ? ' disabled' : '' ) + '>' + ICON . down + '</button>' +
'<button class="sbu-icon-btn' + ( hidden ? ' active' : '' ) + '" data-ohide="' + i + '" title="' + ( hidden ? 'Показать' : 'Скрыть' ) + '">' + ( hidden ? ICON . eyeOff : ICON . eye ) + '</button>' +
'<button class="sbu-icon-btn sbu-dup" data-odup="' + i + '" title="Дублировать">' + ICON . copy + '</button>' +
'<button class="sbu-icon-btn sbu-place" data-place="' + o . _uid + '" title="Поставить/двигать на сцене">' + ICON . target + '</button>' +
'<button class="sbu-icon-btn sbu-del" data-odel="' + i + '" title="Удалить">' + ICON . trash + '</button>' +
'</div>' +
'<div class="sbu-obj-fields">' + inner + latexPrev + '</div>' +
style +
'</div>' ;
} ;
/* Блок «Стиль» объекта: непрозрачность + стиль линии/точки + glow + градиент.
Применимость полей зависит от типа (STYLE_FOR[type] = {opacity,line,point,glow,grad}). */
Builder . prototype . styleBlock = function ( o ) {
var cfg = STYLE _FOR [ o . type ] ;
var ctrls = [ ] ;
if ( cfg . opacity ) ctrls . push ( rangeCtl ( 'непрозр.' , 'data-of="opacity"' , o . opacity , 0 , 1 , 0.05 ) ) ;
if ( cfg . line ) ctrls . push ( selectCtl ( 'линия' , 'data-of="lineStyle"' , o . lineStyle || 'solid' , LINE _STYLE _OPTS ) ) ;
if ( cfg . point ) ctrls . push ( selectCtl ( 'стиль точки' , 'data-of="pointStyle"' , o . pointStyle || 'filled' , POINT _STYLE _OPTS ) ) ;
var grad = '' ;
if ( cfg . grad ) {
var g = Array . isArray ( o . gradient ) ? o . gradient : [ ] ;
var on = ( g . length >= 2 ) ;
grad =
'<label class="sbu-of-check"><input type="checkbox" data-grad-on' + ( on ? ' checked' : '' ) + '/> Градиент-заливка</label>' +
'<div class="sbu-grad-row"' + ( on ? '' : ' style="display:none"' ) + '>' +
colorCtl ( 'от' , 'data-grad="0"' , g [ 0 ] || toHexColor ( o . color ) , false ) +
colorCtl ( 'до' , 'data-grad="1"' , g [ 1 ] || '#1b1b2e' , false ) +
'</div>' ;
}
var glow = cfg . glow
? '<label class="sbu-of-check"><input type="checkbox" data-of="glow"' + ( o . glow ? ' checked' : '' ) + '/> Свечение (glow)</label>'
: '' ;
return '<div class="sbu-obj-style">' +
'<div class="sbu-sub">Стиль</div>' +
( ctrls . length ? '<div class="sbu-style-row">' + ctrls . join ( '' ) + '</div>' : '' ) +
glow + grad +
'</div>' ;
} ;
/* Редактор одного графика (plot): plot-уровневые поля (var/range/trace/fill/marker/legend)
+ список кривых (curveEditor). */
Builder . prototype . plotEditor = function ( o , i ) {
var hidden = ! ! o . hidden ;
var n = this . st . plots . length ;
var rangeErr = ( o . range _a !== '' && o . range _a != null ) ? exprError ( o . range _a ) : ( exprError ( o . range _b ) || '' ) ;
var curves = Array . isArray ( o . curves ) ? o . curves : [ ] ;
var curveHtml = curves . map ( function ( cv , ci ) { return curveEditor ( cv , i , ci , ( curves . length > 1 ) ) ; } ) . join ( '' ) ;
return '<div class="sbu-plot' + ( hidden ? ' is-hidden' : '' ) + '" data-plti="' + i + '">' +
'<div class="sbu-obj-hdr">' +
'<span class="sbu-obj-type">График</span>' +
'<span style="flex:1"></span>' +
'<button class="sbu-icon-btn sbu-zord" data-pltup="' + i + '" title="Выше"' + ( i === 0 ? ' disabled' : '' ) + '>' + ICON . up + '</button>' +
'<button class="sbu-icon-btn sbu-zord" data-pltdown="' + i + '" title="Ниже"' + ( i === n - 1 ? ' disabled' : '' ) + '>' + ICON . down + '</button>' +
'<button class="sbu-icon-btn' + ( hidden ? ' active' : '' ) + '" data-plthide="' + i + '" title="' + ( hidden ? 'Показать' : 'Скрыть' ) + '">' + ( hidden ? ICON . eyeOff : ICON . eye ) + '</button>' +
'<button class="sbu-icon-btn sbu-del" data-pltdel="' + i + '" title="Удалить">' + ICON . trash + '</button>' +
'</div>' +
'<div class="sbu-curves">' + curveHtml + '</div>' +
'<button class="sbu-add sbu-add-sm" data-curveadd="' + i + '">' + ICON . plus + ' Кривая</button>' +
'<div class="sbu-row4">' +
miniField ( 'перем.' , '<input class="sbu-in" data-plf="var" value="' + esc ( o [ 'var' ] == null ? 'x' : o [ 'var' ] ) + '" placeholder="x" />' ) +
miniField ( 'от' , '<input class="sbu-in" data-plf="range_a" value="' + esc ( o . range _a == null ? '' : o . range _a ) + '" placeholder="xmin" />' ) +
miniField ( 'до' , '<input class="sbu-in" data-plf="range_b" value="' + esc ( o . range _b == null ? '' : o . range _b ) + '" placeholder="xmax" />' ) +
miniField ( 'точек' , '<input class="sbu-in" type="number" data-plf="samples" value="' + esc ( o . samples == null ? '' : o . samples ) + '" placeholder="200" />' ) +
'</div>' +
'<div class="sbu-style-row">' +
selectCtl ( 'маркеры' , 'data-plf="plotMarker"' , o . plotMarker || 'none' , MARKER _OPTS ) +
'<label class="sbu-mini"><span class="sbu-mini-lbl">заливка под всеми</span>' +
'<label class="sbu-of-check" style="height:32px"><input type="checkbox" data-plf="plotFill"' + ( o . plotFill ? ' checked' : '' ) + '/> вкл</label></label>' +
'</div>' +
'<div class="sbu-checks">' +
'<label class="sbu-of-check"><input type="checkbox" data-plf="trace"' + ( o . trace ? ' checked' : '' ) + '/> След по времени (trace)</label>' +
'<label class="sbu-of-check"><input type="checkbox" data-plf="legend"' + ( o . legend !== false ? ' checked' : '' ) + '/> Легенда</label>' +
'</div>' +
( rangeErr ? '<span class="sbu-of-err">диапазон: ' + esc ( rangeErr ) + '</span>' : '' ) +
'</div>' ;
} ;
/* Редактор одной кривой графика. data-cvf — поле кривой; data-cvfx — fx-палитра. */
function curveEditor ( cv , pi , ci , removable ) {
var exprErr = exprError ( cv . expr ) ;
return '<div class="sbu-curve" data-pi="' + pi + '" data-ci="' + ci + '">' +
'<div class="sbu-of' + ( exprErr ? ' has-err' : '' ) + '">' +
'<label class="sbu-of-lbl">выражение' +
'<span style="display:flex;gap:4px;align-items:center">' +
'<button class="sbu-fx" data-cvfx>fx</button>' +
( removable ? '<button class="sbu-icon-btn sbu-del sbu-curve-del" data-curvedel title="Удалить кривую">' + ICON . trash + '</button>' : '' ) +
'</span>' +
'</label>' +
'<input class="sbu-in sbu-in-expr" data-cvf="expr" value="' + esc ( cv . expr == null ? '' : cv . expr ) + '" placeholder="sin(x)" />' +
( exprErr ? '<span class="sbu-of-err">' + esc ( exprErr ) + '</span>' : '' ) +
'</div>' +
'<div class="sbu-row2">' +
colorCtl ( 'цвет' , 'data-cvf="color"' , cv . color , true ) +
miniField ( 'подпись' , '<input class="sbu-in sbu-in-sm" data-cvf="label" value="' + esc ( cv . label == null ? '' : cv . label ) + '" placeholder="легенда" />' ) +
'</div>' +
'<div class="sbu-style-row">' +
miniField ( 'толщ.' , '<input class="sbu-in sbu-in-sm" type="number" step="0.5" data-cvf="width" value="' + esc ( cv . width == null ? '' : cv . width ) + '" placeholder="2" />' ) +
selectCtl ( 'линия' , 'data-cvf="lineStyle"' , cv . lineStyle || 'solid' , LINE _STYLE _OPTS ) +
selectCtl ( 'маркер' , 'data-cvf="marker"' , cv . marker || 'none' , MARKER _OPTS ) +
'</div>' +
'<div class="sbu-style-row">' +
rangeCtl ( 'непрозр.' , 'data-cvf="opacity"' , cv . opacity , 0 , 1 , 0.05 ) +
'<label class="sbu-mini"><span class="sbu-mini-lbl">заливка</span>' +
'<label class="sbu-of-check" style="height:32px"><input type="checkbox" data-cvf="fill"' + ( cv . fill ? ' checked' : '' ) + '/> вкл</label></label>' +
( cv . fill ? colorCtl ( 'цвет зал.' , 'data-cvf="fillColor"' , cv . fillColor , true ) : '<span></span>' ) +
'</div>' +
'</div>' ;
}
/* ── Графики + Физика ── */
Builder . prototype . sectionPlotsPhysics = function ( ) {
var self = this ;
// plots
var plotRows = this . st . plots . map ( function ( o , i ) {
var exprErr = exprError ( o . expr ) ;
var rangeErr = ( o . range _a !== '' && o . range _a != null ) ? exprError ( o . range _a ) : ( exprError ( o . range _b ) || '' ) ;
return '<div class="sbu-plot" data-plti="' + i + '">' +
'<div class="sbu-obj-hdr">' +
'<span class="sbu-obj-type">График</span>' +
'<button class="sbu-icon-btn sbu-del" data-pltdel="' + i + '" title="Удалить">' + ICON . trash + '</button>' +
'</div>' +
'<div class="sbu-of' + ( exprErr ? ' has-err' : '' ) + '">' +
'<label class="sbu-of-lbl">f(' + esc ( o . var || 'x' ) + ')<button class="sbu-fx" data-pltfx="expr:' + i + '">fx</button></label>' +
'<input class="sbu-in sbu-in-expr" data-plf="expr" value="' + esc ( o . expr == null ? '' : o . expr ) + '" placeholder="sin(x)" />' +
( exprErr ? '<span class="sbu-of-err">' + esc ( exprErr ) + '</span>' : '' ) +
'</div>' +
'<div class="sbu-row4">' +
miniField ( 'перем.' , '<input class="sbu-in" data-plf="var" value="' + esc ( o . var == null ? 'x' : o . var ) + '" placeholder="x" />' ) +
miniField ( 'от' , '<input class="sbu-in" data-plf="range_a" value="' + esc ( o . range _a == null ? '' : o . range _a ) + '" placeholder="xmin" />' ) +
miniField ( 'до' , '<input class="sbu-in" data-plf="range_b" value="' + esc ( o . range _b == null ? '' : o . range _b ) + '" placeholder="xmax" />' ) +
miniField ( 'цвет' , '<input class="sbu-in sbu-in-color" data-plf="color" value="' + esc ( o . color == null ? '' : o . color ) + '" placeholder="#F15BB5" />' ) +
'</div>' +
'<label class="sbu-of-check"><input type="checkbox" data-plf="trace"' + ( o . trace ? ' checked' : '' ) + '/> След по времени (trace)</label>' +
( rangeErr ? '<span class="sbu-of-err">диапазон: ' + esc ( rangeErr ) + '</span>' : '' ) +
'</div>' ;
} ) . join ( '' ) ;
// plots — каждый график: список кривых + plot-уровневые поля
var plotRows = this . st . plots . map ( function ( o , i ) { return self . plotEditor ( o , i ) ; } ) . join ( '' ) ;
var plotsBody = ( plotRows || '<div class="sbu-empty-sm">Нет графиков.</div>' ) +
var plotsBody = ( plotRows || '<div class="sbu-empty-sm">Нет графиков. Добавьте график функции — можно несколько кривых. </div>' ) +
'<button class="sbu-add" data-add="plot">' + ICON . plus + ' График</button>' ;
// physics
@@ -838,12 +946,38 @@
el . addEventListener ( evt , function ( ) {
var k = el . getAttribute ( 'data-of' ) ;
if ( el . type === 'checkbox' ) self . st . objects [ i ] [ k ] = el . checked ;
else self . st . objects [ i ] [ k ] = el . value ;
else if ( el . type === 'range' ) {
self . st . objects [ i ] [ k ] = ( el . value === '' ? '' : parseFloat ( el . value ) ) ;
var vb = el . closest ( '.sbu-range-mini' ) ; var lbl = vb && vb . querySelector ( '.sbu-range-val' ) ;
if ( lbl ) lbl . textContent = el . value ;
} else self . st . objects [ i ] [ k ] = el . value ;
// обновить inline-ошибку выражения и LaTeX-превью без полного рендера
self . updateFieldFeedback ( el , self . st . objects [ i ] ) ;
self . scheduleRemount ( false ) ;
} ) ;
} ) ;
// нативный color-picker -> синхрон с текстовым полем рядом (текст = источник истины)
self . wireColorControls ( row , function ( ) { self . scheduleRemount ( false ) ; } ) ;
// градиент-заливка: тумблер показывает пару color-input-ов; снятие -> удалить gradient
var gOn = row . querySelector ( '[data-grad-on]' ) ;
if ( gOn ) gOn . addEventListener ( 'change' , function ( ) {
var obj = self . st . objects [ i ] ;
var gr = row . querySelector ( '.sbu-grad-row' ) ;
if ( gOn . checked ) {
if ( gr ) gr . style . display = '' ;
var c0 = row . querySelector ( '[data-grad="0"]' ) , c1 = row . querySelector ( '[data-grad="1"]' ) ;
obj . gradient = [ ( c0 && c0 . value ) || '#06D6E0' , ( c1 && c1 . value ) || '#1b1b2e' ] ;
} else { delete obj . gradient ; if ( gr ) gr . style . display = 'none' ; }
self . scheduleRemount ( false ) ;
} ) ;
row . querySelectorAll ( '[data-grad]' ) . forEach ( function ( el ) {
el . addEventListener ( 'input' , function ( ) {
var obj = self . st . objects [ i ] ;
var c0 = row . querySelector ( '[data-grad="0"]' ) , c1 = row . querySelector ( '[data-grad="1"]' ) ;
obj . gradient = [ ( c0 && c0 . value ) || '#06D6E0' , ( c1 && c1 . value ) || '#1b1b2e' ] ;
self . scheduleRemount ( false ) ;
} ) ;
} ) ;
} ) ;
p . querySelectorAll ( '[data-odel]' ) . forEach ( function ( b ) {
b . addEventListener ( 'click' , function ( ) {
@@ -854,6 +988,44 @@
self . renderPanels ( ) ; self . scheduleRemount ( false ) ;
} ) ;
} ) ;
// z-order: вверх (раньше в массиве = под низом отрисовки) / вниз
p . querySelectorAll ( '[data-oup]' ) . forEach ( function ( b ) {
b . addEventListener ( 'click' , function ( ) {
var i = parseInt ( b . getAttribute ( 'data-oup' ) , 10 ) ;
if ( i > 0 ) { var a = self . st . objects ; var t = a [ i ] ; a [ i ] = a [ i - 1 ] ; a [ i - 1 ] = t ; }
self . renderPanels ( ) ; self . scheduleRemount ( false ) ;
} ) ;
} ) ;
p . querySelectorAll ( '[data-odown]' ) . forEach ( function ( b ) {
b . addEventListener ( 'click' , function ( ) {
var i = parseInt ( b . getAttribute ( 'data-odown' ) , 10 ) ;
var a = self . st . objects ;
if ( i < a . length - 1 ) { var t = a [ i ] ; a [ i ] = a [ i + 1 ] ; a [ i + 1 ] = t ; }
self . renderPanels ( ) ; self . scheduleRemount ( false ) ;
} ) ;
} ) ;
// видимость: hidden:true -> объект не попадёт в buildSpec (движок не трогаем)
p . querySelectorAll ( '[data-ohide]' ) . forEach ( function ( b ) {
b . addEventListener ( 'click' , function ( ) {
var i = parseInt ( b . getAttribute ( 'data-ohide' ) , 10 ) ;
var o = self . st . objects [ i ] ; if ( ! o ) return ;
if ( o . hidden ) delete o . hidden ; else o . hidden = true ;
self . renderPanels ( ) ; self . scheduleRemount ( false ) ;
} ) ;
} ) ;
// дублировать объект (новый _uid + новая ссылка; вставить сразу после)
p . querySelectorAll ( '[data-odup]' ) . forEach ( function ( b ) {
b . addEventListener ( 'click' , function ( ) {
var i = parseInt ( b . getAttribute ( 'data-odup' ) , 10 ) ;
if ( self . st . objects . length + self . st . plots . length >= LIMITS . objects ) { global . LS . toast ( 'Достигнут лимит объектов' , 'warn' ) ; return ; }
var o = self . st . objects [ i ] ; if ( ! o ) return ;
var clone = JSON . parse ( JSON . stringify ( o ) ) ;
clone . _uid = uid ( 'o' ) ;
if ( clone . id ) clone . id = clone . id + '_copy' ;
self . st . objects . splice ( i + 1 , 0 , clone ) ;
self . renderPanels ( ) ; self . scheduleRemount ( false ) ;
} ) ;
} ) ;
p . querySelectorAll ( '[data-place]' ) . forEach ( function ( b ) {
b . addEventListener ( 'click' , function ( ) {
var uidv = b . getAttribute ( 'data-place' ) ;
@@ -874,6 +1046,7 @@
// plots
p . querySelectorAll ( '.sbu-plot' ) . forEach ( function ( row ) {
var i = parseInt ( row . getAttribute ( 'data-plti' ) , 10 ) ;
// plot-уровневые поля (var/range/samples/trace/legend/plotFill/plotMarker)
row . querySelectorAll ( '[data-plf]' ) . forEach ( function ( el ) {
var evt = el . type === 'checkbox' ? 'change' : 'input' ;
el . addEventListener ( evt , function ( ) {
@@ -884,6 +1057,51 @@
self . scheduleRemount ( false ) ;
} ) ;
} ) ;
// кривые
row . querySelectorAll ( '.sbu-curve' ) . forEach ( function ( cr ) {
var ci = parseInt ( cr . getAttribute ( 'data-ci' ) , 10 ) ;
cr . querySelectorAll ( '[data-cvf]' ) . forEach ( function ( el ) {
var evt = ( el . type === 'checkbox' ) ? 'change' : 'input' ;
el . addEventListener ( evt , function ( ) {
var k = el . getAttribute ( 'data-cvf' ) ;
var cv = self . st . plots [ i ] . curves [ ci ] ; if ( ! cv ) return ;
if ( el . type === 'checkbox' ) {
cv [ k ] = el . checked ;
if ( k === 'fill' ) { self . renderPanels ( ) ; self . scheduleRemount ( false ) ; return ; } // показать/скрыть «цвет зал.»
} else if ( el . type === 'range' ) {
cv [ k ] = ( el . value === '' ? '' : parseFloat ( el . value ) ) ;
var vb = el . closest ( '.sbu-range-mini' ) ; var lbl = vb && vb . querySelector ( '.sbu-range-val' ) ;
if ( lbl ) lbl . textContent = el . value ;
} else cv [ k ] = el . value ;
self . updateFieldFeedback ( el , null ) ;
self . scheduleRemount ( false ) ;
} ) ;
} ) ;
self . wireColorControls ( cr ) ;
// fx-палитра для выражения кривой
var fx = cr . querySelector ( '[data-cvfx]' ) ;
if ( fx ) fx . addEventListener ( 'click' , function ( ) {
self . openPalette ( cr . querySelector ( '[data-cvf="expr"]' ) ) ;
} ) ;
// удалить кривую
var cdel = cr . querySelector ( '[data-curvedel]' ) ;
if ( cdel ) cdel . addEventListener ( 'click' , function ( ) {
var arr = self . st . plots [ i ] . curves ;
if ( arr . length > 1 ) arr . splice ( ci , 1 ) ;
self . renderPanels ( ) ; self . scheduleRemount ( false ) ;
} ) ;
} ) ;
// plot-level color-контролы (на будущее; кривые имеют свои)
self . wireColorControls ( row ) ;
} ) ;
p . querySelectorAll ( '[data-curveadd]' ) . forEach ( function ( b ) {
b . addEventListener ( 'click' , function ( ) {
var i = parseInt ( b . getAttribute ( 'data-curveadd' ) , 10 ) ;
var plt = self . st . plots [ i ] ; if ( ! plt ) return ;
plt . curves = Array . isArray ( plt . curves ) ? plt . curves : [ ] ;
plt . curves . push ( defaultCurve ( '' , '' ) ) ;
self . renderPanels ( ) ; self . scheduleRemount ( false ) ;
} ) ;
} ) ;
p . querySelectorAll ( '[data-pltdel]' ) . forEach ( function ( b ) {
b . addEventListener ( 'click' , function ( ) {
@@ -891,10 +1109,27 @@
self . renderPanels ( ) ; self . scheduleRemount ( false ) ;
} ) ;
} ) ;
p . querySelectorAll ( '[data-pltfx ]' ) . forEach ( function ( b ) {
p . querySelectorAll ( '[data-pltup ]' ) . forEach ( function ( b ) {
b . addEventListener ( 'click' , function ( ) {
var input = b . closest ( '.sbu-of' ) . querySelector ( '[ data-plf]' ) ;
self . openPalette ( input ) ;
var i = parseInt ( b . getAttribute ( 'data-pltup' ) , 10 ) ;
if ( i > 0 ) { var a = self . st . plots ; var t = a [ i ] ; a [ i ] = a [ i - 1 ] ; a [ i - 1 ] = t ; }
self . renderPanels ( ) ; self . scheduleRemount ( false ) ;
} ) ;
} ) ;
p . querySelectorAll ( '[data-pltdown]' ) . forEach ( function ( b ) {
b . addEventListener ( 'click' , function ( ) {
var i = parseInt ( b . getAttribute ( 'data-pltdown' ) , 10 ) ;
var a = self . st . plots ;
if ( i < a . length - 1 ) { var t = a [ i ] ; a [ i ] = a [ i + 1 ] ; a [ i + 1 ] = t ; }
self . renderPanels ( ) ; self . scheduleRemount ( false ) ;
} ) ;
} ) ;
p . querySelectorAll ( '[data-plthide]' ) . forEach ( function ( b ) {
b . addEventListener ( 'click' , function ( ) {
var i = parseInt ( b . getAttribute ( 'data-plthide' ) , 10 ) ;
var o = self . st . plots [ i ] ; if ( ! o ) return ;
if ( o . hidden ) delete o . hidden ; else o . hidden = true ;
self . renderPanels ( ) ; self . scheduleRemount ( false ) ;
} ) ;
} ) ;
@@ -954,6 +1189,33 @@
this . renderLatexPreviews ( ) ;
} ;
/* Синхронизация color-контролов внутри row: нативный пикер -> текст (и dispatch input,
чтобы сработал основной обработчик data-of/data-plf/data-cvf); текст -> пикер;
кнопка очистки -> пустое значение («нет заливки»). onChange — доп. ремонт (для grad). */
Builder . prototype . wireColorControls = function ( row , onChange ) {
row . querySelectorAll ( '.sbu-color-wrap' ) . forEach ( function ( wrap ) {
var pick = wrap . querySelector ( '[data-color-pick]' ) ;
var txt = wrap . querySelector ( 'input.sbu-in-color' ) ;
var clr = wrap . querySelector ( '[data-color-clear]' ) ;
if ( pick && txt ) {
pick . addEventListener ( 'input' , function ( ) {
txt . value = pick . value ;
txt . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
} ) ;
txt . addEventListener ( 'input' , function ( ) {
var h = toHexColor ( txt . value ) ;
if ( h !== '#000000' || /^#0{3,6}$/i . test ( String ( txt . value ) . trim ( ) ) ) pick . value = h ;
} ) ;
}
if ( clr && txt ) {
clr . addEventListener ( 'click' , function ( ) {
txt . value = '' ;
txt . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
} ) ;
}
} ) ;
} ;
/* Привести checkbox data-grp атрибуты в соответствие (vp/time) — генерим в checkbox()
с data-grp/data-k. Но проще: переиспользуем общий обработчик. */
@@ -968,7 +1230,11 @@
this . st . objects . push ( defaultObject ( type ) ) ;
} else if ( what === 'plot' ) {
if ( this . st . objects . length + this . st . plots . length >= LIMITS . objects ) { global . LS . toast ( 'Достигнут лимит объектов' , 'warn' ) ; return ; }
this . st . plots . push ( { _uid : uid ( 'plt' ) , type : 'plot' , expr : 'sin(x)' , var : 'x' , range _a : '' , range _b : '' , color : '#F15BB5' , trace : false } ) ;
this . st . plots . push ( {
_uid : uid ( 'plt' ) , type : 'plot' , 'var' : 'x' , range _a : '' , range _b : '' , samples : '' ,
trace : false , legend : true , plotFill : false , plotMarker : 'none' ,
curves : [ defaultCurve ( 'sin(x)' , '#F15BB5' ) ]
} ) ;
} else if ( what === 'wall' ) {
if ( this . st . physics . walls . length >= LIMITS . walls ) { global . LS . toast ( 'Достигнут лимит стен' , 'warn' ) ; return ; }
this . st . physics . walls . push ( { _uid : uid ( 'w' ) , side : 'bottom' , x1 : '' , y1 : '' , x2 : '' , y2 : '' } ) ;
@@ -980,15 +1246,103 @@
this . scheduleRemount ( false ) ;
} ;
/* Перед сборкой spec plot-объект нужно «материализовать»: range + убрать UI-поля . */
/* дефолтная кривая plot (UI-модель) . */
function defaultCurve ( expr , color ) {
return {
_uid : uid ( 'cv' ) , expr : ( expr == null ? '' : expr ) , color : ( color || '' ) ,
label : '' , width : '' , lineStyle : 'solid' , opacity : '' , fill : '' , fillColor : '' , marker : 'none'
} ;
}
/* Загрузка spec-plot -> UI-модель: список curves[] + plot-уровневые поля.
Поддерживает легаси (одиночный expr/exprs[]) и P3-формат (curves[]). */
function loadPlot ( o ) {
var ui = { _uid : uid ( 'plt' ) , type : 'plot' } ;
ui [ 'var' ] = o [ 'var' ] || 'x' ;
if ( Array . isArray ( o . range ) ) { ui . range _a = o . range [ 0 ] ; ui . range _b = o . range [ 1 ] ; }
else { ui . range _a = '' ; ui . range _b = '' ; }
ui . trace = ! ! o . trace ;
ui . samples = ( o . samples != null ? o . samples : '' ) ;
ui . plotFill = ( o . fill === true ) ? true : ( typeof o . fill === 'string' ? o . fill : false ) ;
ui . plotMarker = ( o . marker === 'dot' || o . marker === 'ring' ) ? o . marker : 'none' ;
ui . legend = ( o . legend === false ) ? false : true ;
if ( o . hidden ) ui . hidden = true ;
// источник кривых: curves[] -> exprs[] -> expr (легаси)
var defs = [ ] ;
if ( Array . isArray ( o . curves ) && o . curves . length ) {
defs = o . curves . map ( function ( cv ) { return ( cv && typeof cv === 'object' ) ? cv : { expr : cv } ; } ) ;
} else if ( Array . isArray ( o . exprs ) && o . exprs . length ) {
defs = o . exprs . map ( function ( ex ) { return { expr : ex } ; } ) ;
} else {
defs = [ { expr : o . expr != null ? o . expr : '' , color : o . color } ] ;
}
// plot-уровневые стили (легаси width/lineStyle/opacity) наследуются кривой, если у неё не задано
ui . curves = defs . map ( function ( cv ) {
cv = cv || { } ;
var c = defaultCurve ( cv . expr , cv . color || '' ) ;
if ( cv . label != null ) c . label = String ( cv . label ) ;
var w = ( cv . width != null && cv . width !== '' ) ? cv . width : o . width ;
if ( w != null && w !== '' && isFinite ( parseFloat ( w ) ) ) c . width = w ;
var ls = ( cv . lineStyle === 'dashed' || cv . lineStyle === 'dotted' ) ? cv . lineStyle
: ( ( o . lineStyle === 'dashed' || o . lineStyle === 'dotted' ) ? o . lineStyle : '' ) ;
if ( ls ) c . lineStyle = ls ;
var op = ( cv . opacity != null && cv . opacity !== '' ) ? cv . opacity : o . opacity ;
if ( op != null && op !== '' && isFinite ( parseFloat ( op ) ) ) c . opacity = op ;
if ( cv . fill === true ) c . fill = true ;
else if ( typeof cv . fill === 'string' && cv . fill ) { c . fill = true ; c . fillColor = cv . fill ; }
if ( cv . marker === 'dot' || cv . marker === 'ring' ) c . marker = cv . marker ;
return c ;
} ) ;
if ( ! ui . curves . length ) ui . curves = [ defaultCurve ( '' , '' ) ] ;
return ui ;
}
/* Перед сборкой spec plot-объект нужно «материализовать»: range + curves + убрать UI-поля.
Если ровно одна «простая» кривая (только expr + опц. color) и нет plot-уровневых стилей —
эмитим легаси-форму (expr/color) для обратной совместимости и стабильного round-trip. */
function normalizePlotForSpec ( o ) {
var out = { type : 'plot' , expr : o . expr == null ? '' : o . expr , var : o . var || 'x' } ;
if ( o . color ) out . color = o . color ;
if ( o . trace ) out . trace = true ;
var out = { type : 'plot' , var : o [ 'var' ] || 'x' } ;
var a = o . range _a , b = o . range _b ;
if ( ! ( ( a === '' || a == null ) && ( b === '' || b == null ) ) ) {
out . range = [ parseRangeVal ( a ) , parseRangeVal ( b ) ] ;
}
if ( o . trace ) out . trace = true ;
if ( o . samples !== '' && o . samples != null && isFinite ( parseFloat ( o . samples ) ) ) out . samples = parseFloat ( o . samples ) ;
if ( o . plotFill === true ) out . fill = true ;
else if ( typeof o . plotFill === 'string' && o . plotFill ) out . fill = o . plotFill ;
if ( o . plotMarker === 'dot' || o . plotMarker === 'ring' ) out . marker = o . plotMarker ;
var curves = Array . isArray ( o . curves ) ? o . curves : [ ] ;
var built = curves . map ( stripCurve ) . filter ( function ( c ) { return c . expr !== '' && c . expr != null ; } ) ;
// легенда: явно выключаем, если стоит false (по умолчанию движок включает при наличии label)
if ( o . legend === false ) out . legend = false ;
var single = ( built . length === 1 ) ? built [ 0 ] : null ;
var simpleSingle = single && ! single . label && single . width == null && ( ! single . lineStyle || single . lineStyle === 'solid' ) &&
single . opacity == null && single . fill == null && ( ! single . marker || single . marker === 'none' ) ;
if ( simpleSingle && out . fill == null && out . marker == null ) {
// легаси-форма: одиночное выражение + цвет
out . expr = single . expr ;
if ( single . color ) out . color = single . color ;
} else if ( built . length ) {
out . curves = built ;
} else {
out . expr = '' ; // пустой график (валидация поймает)
}
return out ;
}
/* кривую (UI) -> минимальный объект кривой спеки (без _uid/дефолтов). */
function stripCurve ( cv ) {
var out = { expr : ( cv . expr == null ? '' : cv . expr ) } ;
if ( cv . color ) out . color = cv . color ;
if ( cv . label ) out . label = cv . label ;
if ( cv . width !== '' && cv . width != null && isFinite ( parseFloat ( cv . width ) ) ) out . width = parseFloat ( cv . width ) ;
if ( cv . lineStyle === 'dashed' || cv . lineStyle === 'dotted' ) out . lineStyle = cv . lineStyle ;
if ( cv . opacity !== '' && cv . opacity != null && isFinite ( parseFloat ( cv . opacity ) ) ) out . opacity = parseFloat ( cv . opacity ) ;
if ( cv . fill === true ) out . fill = ( cv . fillColor && String ( cv . fillColor ) . trim ( ) ) ? cv . fillColor : true ;
if ( cv . marker === 'dot' || cv . marker === 'ring' ) out . marker = cv . marker ;
return out ;
}
function parseRangeVal ( v ) {
@@ -1110,6 +1464,58 @@
return '<label class="sbu-chk"><input type="checkbox" data-grp="' + grp + '" data-k="' + key + '"' + ( checked ? ' checked' : '' ) + '/> ' + esc ( label ) + '</label>' ;
}
/* ── Контролы стиля (P4) ──────────────────────────────────────────────────
Все генерят input-ы с data-of (для объектов) или data-plf/data-cvf (plot/кривая).
attr — строка вида 'data-of="color"' (атрибут привязки события).
Цвет: нативный <input type=color> (sync) + текст (точное значение / rgba/named) +
кнопка очистки (для fill/trailColor «нет заливки»). Текст — источник истины. */
// привести произвольный цвет к #rrggbb для нативного пикера (иначе #000000)
function toHexColor ( v ) {
var s = String ( v == null ? '' : v ) . trim ( ) ;
if ( /^#[0-9a-fA-F]{6}$/ . test ( s ) ) return s . toLowerCase ( ) ;
if ( /^#[0-9a-fA-F]{3}$/ . test ( s ) ) {
return ( '#' + s [ 1 ] + s [ 1 ] + s [ 2 ] + s [ 2 ] + s [ 3 ] + s [ 3 ] ) . toLowerCase ( ) ;
}
return '#000000' ;
}
// colorAttr — 'data-of="color"' и т.п.; clearable — показывать кнопку «нет»
function colorCtl ( label , colorAttr , value , clearable ) {
var v = ( value == null ? '' : value ) ;
var hex = toHexColor ( v ) ;
var clr = clearable
? '<button type="button" class="sbu-color-clr" data-color-clear title="Нет заливки">' + ICON . clearX + '</button>'
: '' ;
return '<label class="sbu-mini sbu-color-mini">' +
'<span class="sbu-mini-lbl">' + esc ( label ) + '</span>' +
'<span class="sbu-color-wrap">' +
'<input type="color" class="sbu-color-pick" data-color-pick value="' + esc ( hex ) + '" title="Выбрать цвет" />' +
'<input class="sbu-in sbu-in-sm sbu-in-color" ' + colorAttr + ' value="' + esc ( v ) + '" placeholder="#06D6E0" />' +
clr +
'</span>' +
'</label>' ;
}
// слайдер 0..1 (opacity) с числовым отображением
function rangeCtl ( label , attr , value , mn , mx , st ) {
var num = ( value == null || value === '' ) ? '' : value ;
var sliderVal = ( num === '' ) ? mx : num ;
return '<label class="sbu-mini sbu-range-mini">' +
'<span class="sbu-mini-lbl">' + esc ( label ) + ' <b class="sbu-range-val">' + esc ( num === '' ? mx : num ) + '</b></span>' +
'<input type="range" class="sbu-range" ' + attr + ' min="' + mn + '" max="' + mx + '" step="' + st + '" value="' + esc ( sliderVal ) + '" />' +
'</label>' ;
}
// select по списку [{v,l}]
function selectCtl ( label , attr , value , opts ) {
var o = opts . map ( function ( op ) {
return '<option value="' + esc ( op . v ) + '"' + ( String ( value || '' ) === String ( op . v ) ? ' selected' : '' ) + '>' + esc ( op . l ) + '</option>' ;
} ) . join ( '' ) ;
return '<label class="sbu-mini"><span class="sbu-mini-lbl">' + esc ( label ) + '</span>' +
'<select class="sbu-in sbu-in-sm" ' + attr + '>' + o + '</select></label>' ;
}
var LINE _STYLE _OPTS = [ { v : 'solid' , l : 'сплошная' } , { v : 'dashed' , l : 'штрих' } , { v : 'dotted' , l : 'точки' } ] ;
var POINT _STYLE _OPTS = [ { v : 'filled' , l : 'заполн.' } , { v : 'hollow' , l : 'контур' } , { v : 'ring' , l : 'кольцо' } , { v : 'cross' , l : 'крест' } ] ;
var MARKER _OPTS = [ { v : 'none' , l : 'нет' } , { v : 'dot' , l : 'точка' } , { v : 'ring' , l : 'кольцо' } ] ;
/* Ошибка компиляции выражения (строка) или '' если число/пусто/валидно. */
function exprError ( v ) {
if ( v === '' || v == null ) return '' ;
@@ -1182,6 +1588,19 @@
readout : [ { key : 'label' , label : 'подпись' , kind : 'text' , ph : 'R' } , { key : 'expr' , label : 'выражение' , kind : 'expr' } , { key : 'unit' , label : 'ед.' , kind : 'text' } , { key : 'precision' , label : 'знаков' , kind : 'expr' } , { key : 'x' , label : 'x (опц.)' , kind : 'expr' } , { key : 'y' , label : 'y (опц.)' , kind : 'expr' } , { key : 'color' , label : 'цвет' , kind : 'color' } ]
} ;
/* Какие style-контролы (P4) показывать у типа.
opacity/glow — почти у всех рисуемых; line (lineStyle) — у линий/контуров;
point (pointStyle) — только point; grad (gradient-заливка) — у circle/rect (есть заливка). */
var STYLE _FOR = {
point : { opacity : true , glow : true , point : true } ,
segment : { opacity : true , glow : true , line : true } ,
vector : { opacity : true , glow : true , line : true } ,
circle : { opacity : true , glow : true , line : true , grad : true } ,
rect : { opacity : true , glow : true , line : true , grad : true } ,
polyline : { opacity : true , glow : true , line : true } ,
path : { opacity : true , glow : true , line : true }
} ;
var TYPE _LABEL = {
point : 'Точка' , segment : 'Отрезок' , vector : 'Вектор' , circle : 'Окружность' ,
rect : 'Прямоугольник' , polyline : 'Ломаная' , path : 'Путь' , label : 'Подпись' ,
@@ -1202,7 +1621,13 @@
target : '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5"/><line x1="12" y1="1" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="1" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="23" y2="12"/></svg>' ,
cog : '<svg viewBox="0 0 24 24" width="13" height="13" style="vertical-align:-2px" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' ,
template : '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>' ,
unpublish : '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M3 3l18 18"/><path d="M10.5 5.1A15.3 15.3 0 0 1 12 5a15.3 15.3 0 0 1 4 7M6.3 6.3A15.3 15.3 0 0 0 12 19a15.3 15.3 0 0 0 3-4"/><path d="M3 12h7m5 0h6"/></svg>'
unpublish : '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M3 3l18 18"/><path d="M10.5 5.1A15.3 15.3 0 0 1 12 5a15.3 15.3 0 0 1 4 7M6.3 6.3A15.3 15.3 0 0 0 12 19a15.3 15.3 0 0 0 3-4"/><path d="M3 12h7m5 0h6"/></svg>' ,
up : '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="18 15 12 9 6 15"/></svg>' ,
down : '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="6 9 12 15 18 9"/></svg>' ,
copy : '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>' ,
eye : '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>' ,
eyeOff : '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>' ,
clearX : '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
} ;
/* ── Встроенные шаблоны стартовых спек (Фаза 6) ──────────────────────────