Разбор минифицированных бандлов: drag-and-drop, анимации, докинг/сетка, рендер-планировщик и pan/zoom — с verbatim-сниппетами из кода.
Корректировки прежних гипотез (по факту кода): график — это форк TradingView Lightweight Charts (а не просто «похожий API»), с рендером, заменённым на Pixi/WebGL. Семьи «ease*» не существует — это была ошибка чтения подстрок (increase/release). Drag-and-drop — не @lumino/dragdrop, а нативный HTML5 + тач-мост (поэтому Playwright-drag мышью не вызывал оверлей). Раскладка хранится на бэкенде, не в браузере.
handleScale.axisPressedMouseMove.{time,price}, handleScroll.mouseWheel, kineticScroll.{mouse,touch}, rightBarStaysOnScroll, fixLeftEdge, классы TimeScale/PriceScale/KineticAnimation.drawElementsInstanced, instanceBuffer), шейдеры GLSL инлайн.zoom(e,t){const i=this.coordinateToFloatIndex(e),r=this.barSpacing(),n=r+t*(r/10);this.setBarSpacing(n),this.internalOptions().rightBarStaysOnScroll||this.setRightOffset(this.rightOffset()+(i-this.coordinateToFloatIndex(e)))}Зум меняет barSpacing на 10% за единицу дельты и держит бар под курсором (rightOffset-коррекция).
deltaY/100, инверсия знака, учёт deltaMode (×120 page / ×32 line), затем model.zoomTime(x, r) — зум якорится к курсору.scrollChart(-80·t).zoomPrice, чувствительность 10% за «щелчок».startScrollTime → scrollTimeTo → endScrollTime, переход в режим navigate только после порога 20 px (чтобы клик не считался драгом).kineticScroll:{mouse:false,touch:true}). KineticAnimation(minSpeed=.2, maxSpeed=7, dumpingCoeff=.997, minMove=15), экспоненциальное затухание на rAF.setVisibleRange/fitContent/autoscale применяются мгновенно. Единственная анимированная траектория — kinetic-скролл.minBarSpacing=.5, сверху 0.5·width, дефолт barSpacing=6._onMousewheel=e=>{let t=e.deltaX/100,i=-e.deltaY/100;...switch(e.deltaMode){case e.DOM_DELTA_PAGE:t*=120,i*=120;break;case e.DOM_DELTA_LINE:t*=32}if(i!==0&&...mouseWheel){const r=Math.sign(i)*Math.min(1,Math.abs(i)),n=e.clientX-this._element.getBoundingClientRect().left;this.model().zoomTime(n,r)}t!==0&&...&&this.model().scrollChart(-80*t)}Главный wheel-handler: вертикаль → зум времени с якорем под курсором, горизонталь → скролл ленты (×80).
getPosition(e){const t=Gt(this._animationStartPosition),i=e-t.time;return t.position+this._speedPxPerMsec*(Math.pow(this._dumpingCoeff,i)-1)/Math.log(this._dumpingCoeff)}Инерция: путь = startPos + speed·(dumping^Δt − 1)/ln(dumping) — экспоненциальное затухание.
_initKineticScrollIfNeeded(e,...){...const r=this._chart.internalOptions().kineticScroll;(e&&r.touch||!e&&r.mouse)&&(this._scrollXAnimation=new Xue(.2,7,.997,15),...)}Инерция включается только для touch (по дефолту); KineticAnimation с коэффициентом затухания .997.
ticker.autoStart=false; ticker.stop().requestAnimationFrame-цикл, который на каждом кадре опрашивает сцены и зовёт ticker.update() (= реальный рендер) только если scenes.some(isNeedRedraw()).isNeedRedraw() — иерархический обход dirty-флагов; флаг модели потребляется (сброс в false) при чтении.()=>this.needRedraw=true; смена опций/данных/hover поднимает флаг.minFPS/maxFPS → _minElapsedMS); по умолчанию выключен → потолок = частота дисплея.r.ticker.autoStart=!1,r.ticker.stop();const n=()=>{r?.scenes.some(a=>a.isNeedRedraw())&&r.ticker.update(),requestAnimationFrame(n)};return requestAnimationFrame(n),rАвтотикер Pixi выключен; свой rAF зовёт update() (рендер) только когда сцена «грязная».
isNeedRedraw(){if(this._rootCont.visible===!1)return!1;if(this._chartModel.needRedraw)return this._chartModel.needRedraw=!1,!0;... for(const t of e){...if(n.paneViews().some(a=>a.needRedraw)||...)return!0}return!1}Gate-проверка: обходит флаги модели/crosshair/grid/пейнов; флаг модели сбрасывается при чтении (consume).
DockPanel.ILayoutConfig: узлы split-area (orientation, sizes — относительные доли, children) и tab-area (widgets[{ID}], currentIndex). Рекурсивные сплиты + табы внутри панелей.v2e() подменяет ID на живые luminoWidget и зовёт lm.applyConfig() (аналог restoreLayout).C3wOGcEP.js); до загрузки рендер виджета бросает «LayoutManager was not loaded».addWidget() → placeWidget(widget,{clientX,clientY}) (drop-зона дока сама выбирает split/tab по позиции).lm.showFullscreen/cancelFullscreen.lm.handleModifications и синхронизируются обратно в стор.layout:{main:{type:"split-area",orientation:"horizontal",sizes:[.3333,.6666],children:[{type:"tab-area",widgets:[{ID:s.ID}],currentIndex:0}]}}Дефолтная раскладка: горизонтальный сплит (доли 0.33/0.66) с tab-area внутри — нативный формат Lumino.
applyLayout=()=>{const e={},{layout:t,widgets:i}=G(this);Object.values(i).map(n=>{const{luminoWidget:a}=n;e[n.ID]=a});const r=v2e(t,e);if(!Yi.lm)throw new Error("Lumino was not initialized");Yi.lm.applyConfig(r),this.#r()}Восстановление докинга: ID→luminoWidget, v2e гидратирует конфиг, applyConfig пересобирает DockPanel.
@lumino/dragdrop (MimeData/proposedAction/overrideCursor/lm-mod-drag/lm-DockPanel-overlay) в бандле отсутствуют — DnD реализован кастомно.dragstart в dataTransfer кладётся {action:"move", ID}, ghost = заранее срендеренный DOM-узел типа виджета через setDragImage(node,0,0) (свой «призрак» не рисуется — браузерный).dashboardContentNode) диспатчатся нативные dragover, и Lumino DockPanel сам показывает оверлей drop-зон; вне зоны — lm.hideOverlay().touchstart/move/end вручную собирается new DataTransfer() и под пальцем синтезируются new DragEvent("dragover"/"drop", ...) — переиспользуется тот же пайплайн. (Именно поэтому drag мышью через Playwright не вызывал оверлей: нужны HTML5 drag-события.)getBoundingClientRect.fe.startWidgetDrag(),mt.dataTransfer&&(mt.dataTransfer.setData("text/plain",JSON.stringify({action:"move",ID:F.ID})),mt.dataTransfer.effectAllowed="move");const Nt=ia[F.type]?.htmlElement;Nt&&mt.dataTransfer?.setDragImage(Nt,0,0)Старт нативного drag: action/ID в dataTransfer + drag-image из готового DOM-узла типа виджета.
const T=h.changedTouches[0],A=new DataTransfer;A.setData("text/plain",JSON.stringify({action:"move",ID:t}));const N=document.elementFromPoint(T.clientX,T.clientY);N.dispatchEvent(new DragEvent("drop",{clientX:T.clientX,clientY:T.clientY,dataTransfer:A,bubbles:!0}))Тач-мост: вручную собирает DataTransfer и синтезирует drop в элемент под пальцем.
_handleMouseMoveInDown(e,t){const i={x:t.clientX,y:t.clientY},{manhattanDistance:r}=_1(i,e.startClientPosition);r>=zI&&(this._transitionTo({type:"mouse-drag"}),this._callbacks.firePressedPointerMove(t))}In-chart pointer-машина: переход в drag только после смещения ≥5px (manhattan), затем pressed-move.
ease*»-семьи (easeNumber/easeCapture/easeProxy…) нет — это артефакты подстрок (increaseNumber, releaseCapture, releaseProxy). cubic-bezier, spring-физики тоже нет.Ticker + сырой rAF; инерция пана — экспоненциальная kinetic-модель (см. Pan/Zoom).fly (со смещением y/x), fade, slide; длительности 100–600 мс (часто 150/200/300), easing по умолчанию cubicInOut (у fly/scale — cubicOut).DefaultAnimationDuration=400 мс (timescale), AnimationDurationMs=1000 мс (price/last-price). Пульсация маркера последней цены lastPriceAnimation по умолчанию disabled.transition: transform Nms ease (не JS-анимации); подсказка тач-жеста — чистая CSS-анимация со снятием по animationend.cs(3,x,()=>Wl,()=>({duration:200,easing:Uv,y:32})) /* Wl = fly: едет по Y на 32px */Скомпилированный Svelte-transition: fly 200мс с easing-функцией и смещением y.
function w(t){return t<.5?4*t*t*t:.5*Math.pow(2*t-2,3)+1}cubicInOut — дефолтный easing модуля Svelte transitions.
s[s.DefaultAnimationDuration=400]="DefaultAnimationDuration"; s[s.AnimationDurationMs=1e3]="AnimationDurationMs"Дефолтные длительности анимаций графика: 400мс и 1000мс.
lm-DockPanel-handle; pointer-математику ручки делает Lumino. Размеры в конфиге — относительные доли (sizes:[.3333,.6666]). На модификацию Lumino дёргает handleModifications → новый конфиг пишется в стор.indexedDB/openDB — 0 совпадений; localStorage — только алерты и onboarding-шаблон. Раскладка/воркспейсы живут в асинхронном (remote) сторе настроек — сохраняются на бэкенд, не в браузере.{fuchsia, blueberry, lime, aqua}. Дефолт нового виджета — fuchsia.ChannelStore + SyncToggleController (тумблеры: security/dateRange/timeframe/crosshair/indicators/chartType) + drawings. Подписка setChannel() с визуальным флэшем fade().subscribers и гейтит по toggles — что именно синкать. Crosshair-синхрон — отдельный тумблер.subscribers.size===0) сбрасывает стейт.#r=()=>{const e=vo(this.#n);Yi.lm?.handleModifications(e),this.#t=()=>{Yi.lm?.unhandleModifications(e)}};#n=e=>{this.update(t=>({...t,layout:_2e(e)}))}Подписка на изменения Lumino (вкл. ресайз ручкой): новый конфиг с пересчитанными sizes пишется в layout стора.
setChannel(e,t=!1){this.channel.update(i=>{i?.removeSubscriber(this.ID);const r=e?this.workspace.channels[e]:null;return!this.channelIndependent&&r&&(t&&r.fade(),r.addSubscriber(this.ID)),r})}Привязка виджета к каналу-цвету: отписка от старого, подписка на новый + флэш fade().
class qte{security;dateRange;timeframe;crosshair;indicators;chartType;...}Канальные тумблеры синка: что реплицировать связанным виджетам.