Basic support for Predicted Aligned Error parsing and plotting (#1302)

* proof of concept

* typos

* plot interactivity

* centerline

* better axis labels, darkmode support

* plot label

* data source selection

* interactivity

* better plot labels

* make pae overpaint ghosts

* config option

* update labels

* standalone mode and AFDB example

* fix interactivity

* pr feedback

* changelog
This commit is contained in:
David Sehnal
2024-10-27 07:36:22 +01:00
committed by GitHub
parent 84e292b3e2
commit 7526535a8b
17 changed files with 990 additions and 17 deletions

View File

@@ -20,6 +20,10 @@ Note that since we don't clearly distinguish between a public and private interf
- MolViewSpec: MVP Support for geometrical primitives (mesh, lines, line, label, distance measurement)
- Mesoscale Explorer: Add support for 4-character PDB IDs (e.g., 8ZZC) in PDB-Dev loader
- Fix Sequence View in Safari 18
- ModelArchive QualityAssessment extension:
- Add support for ma_qa_metric_local_pairwise mmCIF category
- Add PAE plot component
- Added new AlphaFoldDB-PAE example
- Add support for LAMMPS data and dump formats
## [v4.7.1] - 2024-09-30

View File

@@ -876,6 +876,17 @@ ma_qa_metric_local.metric_value
ma_qa_metric_local.model_id
ma_qa_metric_local.ordinal_id
ma_qa_metric_local_pairwise.ordinal_id
ma_qa_metric_local_pairwise.model_id
ma_qa_metric_local_pairwise.label_asym_id_1
ma_qa_metric_local_pairwise.label_comp_id_1
ma_qa_metric_local_pairwise.label_seq_id_1
ma_qa_metric_local_pairwise.label_asym_id_2
ma_qa_metric_local_pairwise.label_comp_id_2
ma_qa_metric_local_pairwise.label_seq_id_2
ma_qa_metric_local_pairwise.metric_id
ma_qa_metric_local_pairwise.metric_value
ma_software_group.group_id
ma_software_group.ordinal_id
ma_software_group.software_id
1 atom_sites.entry_id
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892

View File

@@ -12,7 +12,7 @@ import { Backgrounds } from '../../extensions/backgrounds';
import { DnatcoNtCs } from '../../extensions/dnatco';
import { G3DFormat, G3dProvider } from '../../extensions/g3d/format';
import { GeometryExport } from '../../extensions/geo-export';
import { MAQualityAssessment, QualityAssessmentPLDDTPreset, QualityAssessmentQmeanPreset } from '../../extensions/model-archive/quality-assessment/behavior';
import { MAQualityAssessment, MAQualityAssessmentConfig, QualityAssessmentPLDDTPreset, QualityAssessmentQmeanPreset } from '../../extensions/model-archive/quality-assessment/behavior';
import { QualityAssessment } from '../../extensions/model-archive/quality-assessment/prop';
import { ModelExport } from '../../extensions/model-export';
import { Mp4Export } from '../../extensions/mp4-export';
@@ -48,7 +48,7 @@ import { createPluginUI } from '../../mol-plugin-ui';
import { renderReact18 } from '../../mol-plugin-ui/react18';
import { DefaultPluginUISpec, PluginUISpec } from '../../mol-plugin-ui/spec';
import { PluginCommands } from '../../mol-plugin/commands';
import { PluginConfig } from '../../mol-plugin/config';
import { PluginConfig, PluginConfigItem } from '../../mol-plugin/config';
import { PluginLayoutControlsDisplay } from '../../mol-plugin/layout';
import { PluginSpec } from '../../mol-plugin/spec';
import { PluginState } from '../../mol-plugin/state';
@@ -125,6 +125,8 @@ const DefaultViewerOptions = {
rcsbAssemblySymmetryDefaultServerType: AssemblySymmetryConfig.DefaultServerType.defaultValue,
rcsbAssemblySymmetryDefaultServerUrl: AssemblySymmetryConfig.DefaultServerUrl.defaultValue,
rcsbAssemblySymmetryApplyColors: AssemblySymmetryConfig.ApplyColors.defaultValue,
config: [] as [PluginConfigItem, any][],
};
type ViewerOptions = typeof DefaultViewerOptions;
@@ -204,6 +206,7 @@ export class Viewer {
[AssemblySymmetryConfig.DefaultServerType, o.rcsbAssemblySymmetryDefaultServerType],
[AssemblySymmetryConfig.DefaultServerUrl, o.rcsbAssemblySymmetryDefaultServerUrl],
[AssemblySymmetryConfig.ApplyColors, o.rcsbAssemblySymmetryApplyColors],
...(o.config ?? []),
]
};
@@ -616,4 +619,9 @@ export const ViewerAutoPreset = StructureRepresentationPresetProvider({
export const PluginExtensions = {
wwPDBStructConn: wwPDBStructConnExtensionFunctions,
mvs: { MVSData, loadMVS },
modelArchive: {
qualityAssessment: {
config: MAQualityAssessmentConfig
}
}
};

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<title>Mol* AlphaFold DB Predicted Aligned Error Example</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: sans-serif;
}
#app {
position: absolute;
top: 20px;
left: 20px;
width: 640px;
height: 480px;
}
#plot {
position: absolute;
left: 680px;
top: 20px;
width: 480px;
height: 480px;
}
#controls {
position: absolute;
left: 20px;
top: 520px;
font-family: sans-serif;
font-size: smaller;
}
</style>
<link rel="stylesheet" type="text/css" href="molstar.css" />
<script type="text/javascript" src="./index.js"></script>
</head>
<body>
<div id='controls'>
<input type='text' id='af-id' value='Q8W3K0' />
<button id='af-load'>Load</button>
</div>
<div id='app'></div>
<div id='plot'></div>
<script>
AlphaFoldPAEExample.init({ pluginContainerId: 'app', plotContainerId: 'plot' }).then(example => {
example.load('Q8W3K0')
});
function $(id) { return document.getElementById(id); }
$('af-load').onclick = () => AlphaFoldPAEExample.load($('af-id').value)
</script>
<!-- __MOLSTAR_ANALYTICS__ -->
</body>
</html>

View File

@@ -0,0 +1,96 @@
/**
* Copyright (c) 2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { createRoot } from 'react-dom/client';
import { Viewer } from '../../apps/viewer/app';
import { MAPairwiseScorePlot } from '../../extensions/model-archive/quality-assessment/pairwise/ui';
import { QualityAssessment } from '../../extensions/model-archive/quality-assessment/prop';
import { Model, ResidueIndex } from '../../mol-model/structure';
import './index.html';
require('mol-plugin-ui/skin/light.scss');
export class AlphaFoldPAEExample {
viewer: Viewer;
plotContainerId: string;
async init(options: { pluginContainerId: string, plotContainerId: string }) {
this.plotContainerId = options.plotContainerId;
this.viewer = await Viewer.create(options.pluginContainerId, {
layoutIsExpanded: false,
layoutShowControls: false,
layoutShowLeftPanel: false,
layoutShowLog: false,
});
return this;
}
async load(afId: string) {
const id = afId.trim().toUpperCase();
const plotRoot = createRoot(document.getElementById(this.plotContainerId)!);
plotRoot.render(<div>Loading...</div>);
await this.viewer.plugin.clear();
await this.viewer.loadAlphaFoldDb(id);
try {
const req = await fetch(`https://alphafold.ebi.ac.uk/files/AF-${id}-F1-predicted_aligned_error_v4.json`);
const json = await req.json();
const model = this.viewer.plugin.managers.structure.hierarchy.current.models[0]?.cell.obj?.data!;
const metric = pairwiseMetricFromAlphaFoldDbJson(model, json)!;
createRoot(document.getElementById(this.plotContainerId)!).render(
<div className='msp-plugin' style={{ background: 'white' }}>
<MAPairwiseScorePlot plugin={this.viewer.plugin} pairwiseMetric={metric} model={model} />
</div>
);
} catch (err) {
plotRoot.render(<div>Error: {String(err)}</div>);
}
}
}
function pairwiseMetricFromAlphaFoldDbJson(model: Model, data: any): QualityAssessment.Pairwise | undefined {
if (!Array.isArray(data) || !data[0]?.predicted_aligned_error) return undefined;
const { residues, residueAtomSegments, atomSourceIndex } = model.atomicHierarchy;
const sortedResidueIndices = new Array(residues._rowCount).fill(0).map((_, i) => i);
sortedResidueIndices.sort((a, b) => {
const idxA = atomSourceIndex.value(residueAtomSegments.offsets[a]);
const idxB = atomSourceIndex.value(residueAtomSegments.offsets[b]);
return idxA - idxB;
});
const metricData = data[0].predicted_aligned_error as number[][];
const metric: QualityAssessment.Pairwise = {
id: 0,
name: 'AlphaFold DB PAE',
residueRange: [0 as ResidueIndex, (residues._rowCount - 1) as ResidueIndex],
valueRange: [0, data[0].max_predicted_aligned_error],
values: {}
};
for (let i = 0; i < metricData.length; i++) {
const rA = sortedResidueIndices[i];
if (typeof rA !== 'number') continue;
const row = metricData[i];
const xs: any = (metric.values[rA as ResidueIndex] = {});
for (let j = 0; j < row.length; j++) {
const rB = sortedResidueIndices[j];
if (typeof rB !== 'number') continue;
xs[rB] = row[j];
}
}
return metric;
}
(window as any).AlphaFoldPAEExample = new AlphaFoldPAEExample();

View File

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2021-24 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
@@ -17,6 +18,12 @@ import { cantorPairing } from '../../../mol-data/util';
import { QmeanScoreColorThemeProvider } from './color/qmean';
import { PresetStructureRepresentations, StructureRepresentationPresetProvider } from '../../../mol-plugin-state/builder/structure/representation-preset';
import { StateObjectRef } from '../../../mol-state';
import { MAPairwiseScorePlotPanel } from './pairwise/ui';
import { PluginConfigItem } from '../../../mol-plugin/config';
export const MAQualityAssessmentConfig = {
EnablePairwiseScorePlot: new PluginConfigItem('ma-quality-assessment-prop.enable-pairwise-score-plot', true),
};
export const MAQualityAssessment = PluginBehavior.create<{ autoAttach: boolean, showTooltip: boolean }>({
name: 'ma-quality-assessment-prop',
@@ -52,6 +59,10 @@ export const MAQualityAssessment = PluginBehavior.create<{ autoAttach: boolean,
this.ctx.builders.structure.representation.registerPreset(QualityAssessmentPLDDTPreset);
this.ctx.builders.structure.representation.registerPreset(QualityAssessmentQmeanPreset);
if (this.ctx.config.get(MAQualityAssessmentConfig.EnablePairwiseScorePlot)) {
this.ctx.customStructureControls.set('ma-quality-assessment-pairwise-plot', MAPairwiseScorePlotPanel as any);
}
}
update(p: { autoAttach: boolean, showTooltip: boolean }) {
@@ -76,6 +87,8 @@ export const MAQualityAssessment = PluginBehavior.create<{ autoAttach: boolean,
this.ctx.builders.structure.representation.unregisterPreset(QualityAssessmentPLDDTPreset);
this.ctx.builders.structure.representation.unregisterPreset(QualityAssessmentQmeanPreset);
this.ctx.customStructureControls.delete('ma-quality-assessment-pairwise-plot');
}
},
params: () => ({

View File

@@ -0,0 +1,83 @@
/**
* Copyright (c) 2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { Model, ResidueIndex } from '../../../../mol-model/structure';
import { AtomicHierarchy } from '../../../../mol-model/structure/model/properties/atomic';
import { Color } from '../../../../mol-util/color';
import { QualityAssessment } from '../prop';
const DefaultMetricColorRange = [0x00441B, 0xF7FCF5] as [Color, Color];
export type MAResidueRangeInfo = { startOffset: number, endOffset: number, label: string };
function drawMetricPNG(model: Model, metric: QualityAssessment.Pairwise, colorRange: [Color, Color], noDataColor: Color) {
const [minResidueIndex, maxResidueIndex] = metric.residueRange;
const [minMetric, maxMetric] = metric.valueRange;
const [minColor, maxColor] = colorRange;
const range = maxResidueIndex - minResidueIndex + 1;
const valueRange = maxMetric - minMetric;
const values = metric.values;
const canvas = document.createElement('canvas');
canvas.width = range;
canvas.height = range;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = Color.toStyle(noDataColor);
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let rA = minResidueIndex; rA <= maxResidueIndex; rA++) {
const row = values[rA];
if (!row) continue;
for (let rB = minResidueIndex; rB <= maxResidueIndex; rB++) {
const value = row[rB];
if (typeof value !== 'number') continue;
const x = rA - minResidueIndex;
const y = rB - minResidueIndex;
const t = (value - minMetric) / valueRange;
const color = Color.interpolate(minColor, maxColor, t);
ctx.fillStyle = Color.toStyle(color);
ctx.fillRect(x, y, 1, 1);
ctx.fillRect(y, x, 1, 1);
}
}
const chains: MAResidueRangeInfo[] = [];
const hierarchy = model.atomicHierarchy;
const { label_asym_id } = hierarchy.chains;
let cI = AtomicHierarchy.residueChainIndex(hierarchy, minResidueIndex as ResidueIndex);
let currentChain: MAResidueRangeInfo = { startOffset: 0, endOffset: 1, label: label_asym_id.value(cI) };
chains.push(currentChain);
for (let i = 1; i < range; i++) {
cI = AtomicHierarchy.residueChainIndex(hierarchy, (minResidueIndex + i) as ResidueIndex);
const asym_id = label_asym_id.value(cI);
if (asym_id === currentChain.label) {
currentChain.endOffset = i + 1;
} else {
currentChain = { startOffset: i, endOffset: i + 1, label: asym_id };
chains.push(currentChain);
}
}
return {
model,
metric,
chains,
colorRange: [Color.toStyle(colorRange[0]), Color.toStyle(colorRange[1])] as const,
png: canvas.toDataURL('png')
};
}
export function maDrawPairwiseMetricPNG(model: Model, metric: QualityAssessment.Pairwise) {
return drawMetricPNG(model, metric, DefaultMetricColorRange, Color(0xE2E2E2));
}
export type MAPairwiseMetricDrawing = ReturnType<typeof drawMetricPNG>

View File

@@ -0,0 +1,504 @@
/**
* Copyright (c) 2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { CSSProperties, Fragment, memo, ReactNode, useEffect, useRef } from 'react';
import { BehaviorSubject, combineLatest, distinctUntilChanged, throttleTime } from 'rxjs';
import { clamp } from '../../../../mol-math/interpolate';
import { Model, ResidueIndex, StructureElement, StructureProperties, StructureQuery } from '../../../../mol-model/structure';
import { AtomicHierarchy } from '../../../../mol-model/structure/model/properties/atomic';
import { atoms } from '../../../../mol-model/structure/query/queries/generators';
import { PluginStateObject } from '../../../../mol-plugin-state/objects';
import { OverpaintStructureRepresentation3DFromBundle } from '../../../../mol-plugin-state/transforms/representation';
import { CollapsableControls, CollapsableState } from '../../../../mol-plugin-ui/base';
import { ScatterPlotSvg } from '../../../../mol-plugin-ui/controls/icons';
import { ParameterControls } from '../../../../mol-plugin-ui/controls/parameters';
import { useBehavior } from '../../../../mol-plugin-ui/hooks/use-behavior';
import { PluginContext } from '../../../../mol-plugin/context';
import { StateBuilder, StateTransform } from '../../../../mol-state';
import { round } from '../../../../mol-util';
import { Color } from '../../../../mol-util/color';
import { ParamDefinition as PD } from '../../../../mol-util/param-definition';
import { SingleAsyncQueue } from '../../../../mol-util/single-async-queue';
import { QualityAssessment } from '../prop';
import { maDrawPairwiseMetricPNG, MAPairwiseMetricDrawing } from './plot';
type State = ReturnType<typeof getPropsAndValues>
export class MAPairwiseScorePlotPanel extends CollapsableControls<{}, State> {
protected defaultState(): State & CollapsableState {
return {
header: 'Predicted Aligned Error',
isCollapsed: false,
isHidden: true,
brand: { accent: 'purple', svg: ScatterPlotSvg },
params: {} as any,
values: undefined as any,
dataSources: [],
};
}
toggleCollapsed() {
if (!this.state.isCollapsed) {
this.setState({ isCollapsed: true });
} else {
const state = getPropsAndValues(this.plugin, this.state.values);
this.setState({
...state,
isCollapsed: false,
isHidden: state.params.data.options.length === 0 || state.params.model.options.length === 0
});
}
};
interactivity = new BehaviorSubject<PlotInteractivityState>({});
queue = new SingleAsyncQueue();
componentDidMount() {
this.subscribe(combineLatest([
this.plugin.state.data.events.changed,
this.plugin.behaviors.state.isAnimating
]), ([_, anim]) => {
if (anim || this.state.isCollapsed) return;
const state = getPropsAndValues(this.plugin, this.state.values);
this.setState({
...state,
isHidden: state.params.data.options.length === 0 || state.params.model.options.length === 0
});
});
this.subscribe(filterHighlightState(this.interactivity), state => {
highlightState(this.plugin, state);
});
this.subscribe(filterOverpaintState(this.interactivity), state => {
this.queue.enqueue(() => overpaintState(this.plugin, state));
});
}
protected renderControls(): JSX.Element | null {
const { params, values, dataSources } = this.state;
return <>
<ParameterControls params={params} values={values} onChangeValues={values => this.setState({ values })} />
<PlotWrapper plugin={this.plugin} values={values} dataSources={dataSources} interactivity={this.interactivity} />
</>;
}
}
export function MAPairwiseScorePlot({ plugin, model, pairwiseMetric }: { plugin: PluginContext, model: Model, pairwiseMetric: QualityAssessment.Pairwise }) {
const _interactivity = useRef<BehaviorSubject<PlotInteractivityState>>();
const interactivity = _interactivity.current ??= new BehaviorSubject<PlotInteractivityState>({});
useEffect(() => {
const queue = new SingleAsyncQueue();
const highlight = filterHighlightState(interactivity).subscribe(state => highlightState(plugin, state));
const paint = filterOverpaintState(interactivity).subscribe(state => queue.enqueue(() => overpaintState(plugin, state)));
return () => {
highlight.unsubscribe();
paint.unsubscribe();
queue.enqueue(() => overpaintState(plugin, interactivity.value));
};
}, [model, pairwiseMetric]);
return <MAPairwiseScorePlotBase model={model} pairwiseMetric={pairwiseMetric} interactivity={interactivity} />;
}
function filterHighlightState(state: BehaviorSubject<PlotInteractivityState>) {
return state.pipe(
throttleTime(16, undefined, { leading: true, trailing: true }),
distinctUntilChanged((a, b) => a.crosshairOffset === b.crosshairOffset)
);
}
function filterOverpaintState(state: BehaviorSubject<PlotInteractivityState>) {
return state.pipe(
throttleTime(66, undefined, { leading: true, trailing: true }),
distinctUntilChanged((a, b) => a.boxStart === b.boxStart && (a.mouseDown ? a.crosshairOffset : a.boxEnd) === (b.mouseDown ? b.crosshairOffset : b.boxEnd))
);
}
const PlotWrapper = memo(({ plugin, values, dataSources, interactivity }: { plugin: PluginContext, values: State['values'], dataSources: State['dataSources'], interactivity: BehaviorSubject<PlotInteractivityState> }) => {
const model: Model | undefined = plugin.managers.structure.hierarchy.current.models.find(m => m.cell.transform.ref === values.model)?.cell.obj?.data;
const src = dataSources.find(src => src.id === values.data);
const cif: PluginStateObject.Format.Cif | undefined = plugin.state.data.cells.get(src?.dataRef!)?.obj;
const block = cif?.data.blocks[src?.blockIndex!];
if (!model || !block || !src) return <div className='msp-description'>Data not available</div>;
const metric = QualityAssessment.pairwiseMetricFromModelArchiveCIF(model, block, src.metridId);
if (!metric) return <div className='msp-description'>Data not available</div>;
return <MAPairwiseScorePlotBase interactivity={interactivity} model={model} pairwiseMetric={metric} />;
}, (prev, next) => prev.values.data === next.values.data && prev.values.model === next.values.model);
function getPropsAndValues(plugin: PluginContext, current?: { model?: string, data?: string }) {
const models = plugin.managers.structure.hierarchy.current.models;
const cifs = plugin.state.data.selectQ(q => q.root.subtree().ofType(PluginStateObject.Format.Cif));
const dataSources: {
id: string,
label: string,
metridId: number,
dataRef: StateTransform.Ref,
blockIndex: number,
}[] = [];
for (const cif of cifs) {
if (!cif.obj?.data.blocks) continue;
let blockIndex = 0;
for (const block of cif.obj.data.blocks) {
for (const pae of QualityAssessment.findModelArchiveCIFPAEMetrics(block)) {
dataSources.push({
id: `${cif.transform.ref}:${blockIndex}:${pae.id}`,
metridId: pae.id,
label: `${block.header}: ${pae.name}`,
dataRef: cif.transform.ref,
blockIndex,
});
}
blockIndex++;
}
}
const params = {
model: PD.Select(models[0]?.cell.transform.ref, models.map(m => [m.cell.transform.ref, m.cell.obj?.data.label!]), { isHidden: models.length <= 1 }),
data: PD.Select(dataSources[0]?.id, dataSources.map(o => [o.id, o.label]), { isHidden: dataSources.length <= 1 })
};
const values = {
model: params.model.options.find(o => o[0] === current?.model)?.[0] ?? params.model.options[0]?.[0],
data: params.data.options.find(o => o[0] === current?.data)?.[0] ?? params.data.options[0]?.[0],
};
return { params, values, dataSources };
}
const PlotSize = 1000;
const PlotOffset = 120;
const PlotColors = {
ScoredOverpaint: Color(0xFFA500),
ScoredLabel: Color(0xBC7100),
AlignedOverpaint: Color(0x1AFFBB),
AlignedLabel: Color(0x0F8E68),
};
interface PlotInteractivityState {
model?: Model;
drawing?: MAPairwiseMetricDrawing;
crosshairOffset?: [number, number];
inside?: boolean;
mouseDown?: boolean;
boxStart?: [number, number];
boxEnd?: [number, number];
}
export const MAPairwiseScorePlotBase = memo(({ model, pairwiseMetric, interactivity }: { model: Model, pairwiseMetric: QualityAssessment.Pairwise, interactivity: BehaviorSubject<PlotInteractivityState> }) => {
const interactivityRect = useRef<SVGRectElement>();
const drawing = maDrawPairwiseMetricPNG(model, pairwiseMetric);
useEffect(() => {
if (!drawing) {
interactivity.next({});
return;
}
interactivity.next({ model, drawing });
const moveEvent = (ev: MouseEvent) => {
const current = interactivity.value;
if (!current.inside && !current.mouseDown) return;
const offset = getPlotMouseOffsetBase(interactivityRect.current!, ev.clientX, ev.clientY);
interactivity.next({ ...current, crosshairOffset: offset });
};
const mouseUpEvent = (ev: MouseEvent) => {
if (!interactivity.value.mouseDown) return;
const offset = getPlotMouseOffsetBase(interactivityRect.current!, ev.clientX, ev.clientY);
interactivity.next({ ...interactivity.value, mouseDown: false, boxEnd: offset });
};
window.addEventListener('mousemove', moveEvent);
window.addEventListener('mouseup', mouseUpEvent);
return () => {
window.removeEventListener('mousemove', moveEvent);
window.removeEventListener('mouseup', mouseUpEvent);
};
}, [model, interactivity, drawing]);
if (!drawing) return <>Not available</>;
const { metric, colorRange, chains, png } = drawing;
const nResidues = metric.residueRange[1] - metric.residueRange[0];
const border = '#333';
const line = '#000';
const legendHeight = 80;
const legendOffsetY = PlotOffset + PlotSize + 50;
const viewBox = '0 0 1140 1270';
return <div style={{ margin: '8px 8px 0 8px', position: 'relative' }}>
<svg viewBox={viewBox} width='100%'>
<image x={PlotOffset + 1} y={PlotOffset + 1} width={PlotSize - 1} height={PlotSize - 1} href={png} />
<line x1={PlotOffset} x2={PlotOffset + PlotSize} y1={PlotOffset} y2={PlotOffset + PlotSize} style={{ stroke: line, strokeDasharray: '15,15' }} />
<linearGradient id='legend-gradient' x1={0} x2={1} y1={0} y2={0}>
<stop offset='0%' stopColor={colorRange[0]} />
<stop offset='100%' stopColor={colorRange[1]} />
</linearGradient>
<rect x={PlotOffset} y={legendOffsetY} width={PlotSize} height={legendHeight} style={{ fill: 'url(#legend-gradient)', strokeWidth: 1, stroke: border }} />
<text x={PlotOffset + 20} y={legendOffsetY + legendHeight - 22} style={{ fontSize: '45px', fill: 'white', fontWeight: 'bold' }}>{round(metric.valueRange[0], 2)} Å</text>
<text x={PlotOffset + PlotSize - 20} y={legendOffsetY + legendHeight - 22} style={{ fontSize: '45px', fill: 'black', fontWeight: 'bold' }} textAnchor='end'>{round(metric.valueRange[1], 2)} Å</text>
<text x={PlotOffset + PlotSize / 2} y={legendOffsetY + legendHeight - 22} style={{ fontSize: '45px', fill: 'black' }} textAnchor='middle'>Predicted Aligned Error</text>
<text x={PlotOffset + PlotSize / 2} y={50} style={{ fontSize: '45px', fontWeight: 'bold', fill: Color.toStyle(PlotColors.ScoredLabel) }} textAnchor='middle'>Scored Residue</text>
<text className='msp-svg-text' style={{ fontSize: '50px', fontWeight: 'bold', fill: Color.toStyle(PlotColors.AlignedLabel) }} transform={`translate(50, ${PlotOffset + PlotSize / 2}) rotate(270)`} textAnchor='middle'>Aligned Residue</text>
{chains.map(({ startOffset, endOffset, label }) => {
const textOffset = PlotOffset + PlotSize * (startOffset + (endOffset - startOffset) / 2) / nResidues;
const endLineOffset = PlotOffset + PlotSize * endOffset / nResidues;
const startLineOffset = PlotOffset + PlotSize * startOffset / nResidues;
const seq_id = model.atomicHierarchy.residues.label_seq_id;
const startIndex = seq_id.value(metric.residueRange[0] + startOffset);
const endIndex = seq_id.value(metric.residueRange[0] + endOffset - 1);
return <Fragment key={startOffset}>
<text x={textOffset} y={PlotOffset - 15} className='msp-svg-text' style={{ fontSize: '40px' }} textAnchor='middle'>{label} {startIndex}-{endIndex}</text>
<text className='msp-svg-text' style={{ fontSize: '40px' }} transform={`translate(${PlotOffset - 15}, ${textOffset}) rotate(270)`} textAnchor='middle'>{label} {startIndex}-{endIndex}</text>
<line x1={startLineOffset} x2={startLineOffset} y1={PlotOffset - 20} y2={PlotOffset + PlotSize + 20} style={{ stroke: line, strokeDasharray: '15,15' }} />
<line x1={endLineOffset} x2={endLineOffset} y1={PlotOffset - 20} y2={PlotOffset + PlotSize + 20} style={{ stroke: line, strokeDasharray: '15,15' }} />
<line x1={PlotOffset - 20} x2={PlotOffset + PlotSize + 20} y1={startLineOffset} y2={startLineOffset} style={{ stroke: line, strokeDasharray: '15,15' }} />
<line x1={PlotOffset - 20} x2={PlotOffset + PlotSize + 20} y1={endLineOffset} y2={endLineOffset} style={{ stroke: line, strokeDasharray: '15,15' }} />
</Fragment>;
})}
</svg>
<svg viewBox={viewBox} style={{ position: 'absolute', inset: 0 }}>
<rect x={PlotOffset} y={PlotOffset} width={PlotSize} height={PlotSize} style={{ fill: 'transparent', cursor: 'crosshair' }}
ref={interactivityRect as any}
onMouseMove={(ev) => {
interactivity.next({ ...interactivity.value, inside: true });
ev.currentTarget.style.stroke = 'black';
ev.currentTarget.style.strokeWidth = '4px';
}}
onMouseDown={(ev) => {
interactivity.next({ ...interactivity.value, mouseDown: true, boxStart: getPlotMouseOffset(ev) });
}}
onMouseLeave={(ev) => {
interactivity.next({ ...interactivity.value, inside: false, crosshairOffset: undefined });
ev.currentTarget.style.stroke = '#333';
ev.currentTarget.style.strokeWidth = '1px';
}} />
<PlotInteractivity drawing={drawing} interactity={interactivity} />
</svg>
</div>;
}, (prev, next) => prev.model === next.model && prev.pairwiseMetric === next.pairwiseMetric);
function PlotInteractivity({ drawing, interactity }: { drawing: MAPairwiseMetricDrawing, interactity: BehaviorSubject<PlotInteractivityState> }) {
const state = useBehavior(interactity);
const { crosshairOffset, inside } = state;
const box = getBox(state);
const label = getCrosshairLabel(state);
let labelNode: ReactNode | undefined;
if (label) {
const labelStyle: CSSProperties | undefined = label ? { fontSize: '45px', fill: 'black', fontWeight: 'bold', pointerEvents: 'none', userSelect: 'none' } : undefined;
let x: number, y: number, anchor: string;
if (crosshairOffset![0] < PlotSize / 2) {
x = PlotOffset + crosshairOffset![0] + 20;
anchor = 'start';
} else {
x = PlotOffset + crosshairOffset![0] - 20;
anchor = 'end';
}
if (crosshairOffset![1] < PlotSize / 2) {
y = PlotOffset + crosshairOffset![1] + 65;
} else {
y = PlotOffset + crosshairOffset![1] - (label[2] ? 3 * 45 : 2 * 45) + 20;
}
labelNode = <text y={y} style={labelStyle} textAnchor={anchor}>
<tspan x={x}>S: {label[0]}</tspan>
<tspan x={x} dy={45}>A: {label[1]}</tspan>
{label[2] && <tspan x={x} dy={45}>{label[2]}</tspan>}
</text>;
}
return <>
{inside && crosshairOffset && <line x1={crosshairOffset[0] + PlotOffset} x2={crosshairOffset[0] + PlotOffset} y1={PlotOffset} y2={PlotOffset + PlotSize} style={{ pointerEvents: 'none', stroke: 'black', strokeDasharray: '5,5' }} />}
{inside && crosshairOffset && <line x1={PlotOffset} x2={PlotOffset + PlotSize} y1={crosshairOffset[1] + PlotOffset} y2={crosshairOffset[1] + PlotOffset} style={{ pointerEvents: 'none', stroke: 'black', strokeDasharray: '5,5' }} />}
{box && <rect x={PlotOffset + box[0]} y={PlotOffset + box[1]} width={box[2]} height={box[3]} style={{ stroke: '#eee', strokeWidth: 4, fill: 'rgba(0, 0, 0, 0.15)', pointerEvents: 'none' }} />}
{labelNode}
</>;
}
function getCrosshairLabel(state: PlotInteractivityState) {
if (!state.drawing || !state.crosshairOffset || !state.inside) return;
const { drawing } = state;
const rA = getResidueIndex(drawing, clamp(state.crosshairOffset[0], 0, PlotSize));
const rB = getResidueIndex(drawing, clamp(state.crosshairOffset[1], 0, PlotSize));
const value = drawing.metric.values[rA]?.[rB] ?? drawing.metric.values[rB]?.[rA];
const valueLabel = typeof value === 'number' ? `${round(value, 2)} Å` : '';
return [getResidueLabel(drawing, rA), getResidueLabel(drawing, rB), valueLabel];
}
function getResidueIndex(drawing: MAPairwiseMetricDrawing, offset: number) {
const rI = drawing.metric.residueRange[0] + Math.round(offset / PlotSize * (drawing.metric.residueRange[1] - drawing.metric.residueRange[0] + 1)) as ResidueIndex;
return clamp(rI, drawing.metric.residueRange[0], drawing.metric.residueRange[1]) as ResidueIndex;
}
function getResidueLabel(drawing: MAPairwiseMetricDrawing, rI: ResidueIndex) {
const hierarchy = drawing.model.atomicHierarchy;
const asym_id = hierarchy.chains.label_asym_id;
const seq_id = hierarchy.residues.label_seq_id;
const comp_id = hierarchy.atoms.label_comp_id;
return `${asym_id.value(AtomicHierarchy.residueChainIndex(hierarchy, rI))} ${seq_id.value(rI)} ${comp_id.value(AtomicHierarchy.residueFirstAtomIndex(hierarchy, rI))}`;
}
function getBox(state: PlotInteractivityState) {
const start = state.boxStart;
const end = state.mouseDown ? state.crosshairOffset : state.boxEnd;
if (!start || !end) return undefined;
const x = clamp(Math.min(start[0], end[0]), 0, PlotSize);
const width = clamp(Math.max(start[0], end[0]), 0, PlotSize) - x;
const y = clamp(Math.min(start[1], end[1]), 0, PlotSize);
const height = clamp(Math.max(start[1], end[1]), 0, PlotSize) - y;
if (width < 1 && height < 1) return undefined;
return [x, y, width, height];
}
function getPlotMouseOffset(ev: React.MouseEvent<SVGRectElement, MouseEvent>) {
return getPlotMouseOffsetBase(ev.currentTarget, ev.clientX, ev.clientY);
}
function getPlotMouseOffsetBase(target: HTMLElement | SVGRectElement, clientX: number, clientY: number) {
const rect = target.getBoundingClientRect();
const offsetX = PlotSize * (clientX - rect.left) / rect.width;
const offsetY = PlotSize * (clientY - rect.top) / rect.height;
return [offsetX, offsetY] as [number, number];
}
function findModelRef(plugin: PluginContext, model: Model | undefined) {
if (!model) return undefined;
for (const m of plugin.managers.structure.hierarchy.current.models) {
if (m.cell.obj?.data === model) return m;
}
return undefined;
}
function highlightState(plugin: PluginContext, state: PlotInteractivityState) {
const structure = findModelRef(plugin, state.model)?.structures[0]?.cell.obj?.data;
if (!state.drawing || !state.crosshairOffset || !state.inside || !structure) {
plugin.managers.interactivity.lociHighlights.clearHighlights();
return;
}
const { drawing } = state;
const rA = getResidueIndex(drawing, clamp(state.crosshairOffset[0], 0, PlotSize));
const rB = getResidueIndex(drawing, clamp(state.crosshairOffset[1], 0, PlotSize));
const resIdx = StructureProperties.residue.key;
const loci = StructureQuery.loci(atoms({
residueTest: ctx => {
const rI = resIdx(ctx.element);
return rI === rA || rI === rB;
},
}), structure);
plugin.managers.interactivity.lociHighlights.highlightOnly({ loci });
}
async function overpaintState(plugin: PluginContext, state: PlotInteractivityState) {
const tag = 'modelarchive-pae-overpaint';
const overpaints = plugin.state.data.selectQ(q => q.root.subtree().withTag(tag));
const update = plugin.build();
for (const overpaint of overpaints) update.delete(overpaint);
const model = findModelRef(plugin, state.model);
const structure = model?.structures[0]?.cell.obj?.data;
if (!state.drawing || !state.boxStart || !(state.boxEnd || state.crosshairOffset) || !structure) {
if (!overpaints) return;
return reApplyRepresentationStates(plugin, update);
}
const start = state.boxStart;
const end = state.mouseDown ? state.crosshairOffset! : state.boxEnd!;
const x0 = clamp(Math.min(start[0], end[0]), 0, PlotSize);
const x1 = clamp(Math.max(start[0], end[0]), 0, PlotSize);
const y0 = clamp(Math.min(start[1], end[1]), 0, PlotSize);
const y1 = clamp(Math.max(start[1], end[1]), 0, PlotSize);
if (x1 - x0 <= 1 || y1 - y0 <= 1) {
if (!overpaints) return;
return reApplyRepresentationStates(plugin, update);
}
const representations = plugin.state.data.selectQ(q =>
q.byRef(model.cell.transform.ref!)
.subtree()
.ofType(PluginStateObject.Molecule.Structure.Representation3D)
);
const resIdx = StructureProperties.residue.key;
const startScored = getResidueIndex(state.drawing, x0);
const endScored = getResidueIndex(state.drawing, x1);
const lociScored = StructureQuery.loci(atoms({
residueTest: ctx => {
const rI = resIdx(ctx.element);
return rI >= startScored && rI <= endScored;
},
}), structure);
const startAligned = getResidueIndex(state.drawing, y0);
const endAligned = getResidueIndex(state.drawing, y1);
const lociAligned = StructureQuery.loci(atoms({
residueTest: ctx => {
const rI = resIdx(ctx.element);
return rI >= startAligned && rI <= endAligned;
},
}), structure);
const layers = [{
bundle: StructureElement.Bundle.fromSubStructure(structure, structure),
color: Color(0x777777),
clear: false,
}, {
bundle: StructureElement.Bundle.fromLoci(lociScored),
color: PlotColors.ScoredOverpaint,
clear: false,
}, {
bundle: StructureElement.Bundle.fromLoci(lociAligned),
color: PlotColors.AlignedOverpaint,
clear: false,
}];
for (const repr of representations) {
update.to(repr).apply(OverpaintStructureRepresentation3DFromBundle, { layers }, { tags: [tag], state: { isGhost: true } });
}
return update.commit();
}
async function reApplyRepresentationStates(plugin: PluginContext, update: StateBuilder.Root) {
await update.commit();
const states = plugin.state.data.selectQ(q => q.root.subtree().ofType(PluginStateObject.Molecule.Structure.Representation3DState));
for (const state of states) {
const data = state.obj?.data;
if (!data) continue;
data.repr.setState(data.state);
plugin.canvas3d?.update(data.repr);
}
}

View File

@@ -1,21 +1,24 @@
/**
* Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2021-24 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { Unit } from '../../../mol-model/structure';
import { CustomProperty } from '../../../mol-model-props/common/custom-property';
import { CifFrame } from '../../../mol-io/reader/cif';
import { toDatabase } from '../../../mol-io/reader/cif/schema';
import { mmCIF_Schema } from '../../../mol-io/reader/cif/schema/mmcif';
import { MmcifFormat } from '../../../mol-model-formats/structure/mmcif';
import { CustomModelProperty } from '../../../mol-model-props/common/custom-model-property';
import { CustomProperty } from '../../../mol-model-props/common/custom-property';
import { CustomPropertyDescriptor } from '../../../mol-model/custom-property';
import { Unit } from '../../../mol-model/structure';
import { Model, ResidueIndex } from '../../../mol-model/structure/model';
import { QuerySymbolRuntime } from '../../../mol-script/runtime/query/compiler';
import { AtomicIndex } from '../../../mol-model/structure/model/properties/atomic';
import { CustomPropSymbol } from '../../../mol-script/language/symbol';
import { Type } from '../../../mol-script/language/type';
import { CustomPropertyDescriptor } from '../../../mol-model/custom-property';
import { MmcifFormat } from '../../../mol-model-formats/structure/mmcif';
import { AtomicIndex } from '../../../mol-model/structure/model/properties/atomic';
import { QuerySymbolRuntime } from '../../../mol-script/runtime/query/compiler';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
export { QualityAssessment };
@@ -26,10 +29,19 @@ interface QualityAssessment {
}
namespace QualityAssessment {
export interface Pairwise {
id: number
name: string
residueRange: [ResidueIndex, ResidueIndex]
valueRange: [number, number]
values: Record<ResidueIndex, Record<ResidueIndex, number | undefined> | undefined>
}
const Empty = {
value: {
localMetrics: new Map()
}
localMetrics: new Map(),
} satisfies QualityAssessment
};
export function isApplicable(model?: Model, localMetricName?: 'pLDDT' | 'qmean'): boolean {
@@ -106,6 +118,101 @@ namespace QualityAssessment {
};
}
const PairwiseSchema = {
ma_qa_metric: mmCIF_Schema.ma_qa_metric,
ma_qa_metric_local_pairwise: mmCIF_Schema.ma_qa_metric_local_pairwise
};
export function findModelArchiveCIFPAEMetrics(frame: CifFrame) {
const { ma_qa_metric, ma_qa_metric_local_pairwise } = toDatabase(PairwiseSchema, frame);
const result: { id: number, name: string }[] = [];
if (ma_qa_metric_local_pairwise._rowCount === 0) return result;
for (let i = 0, il = ma_qa_metric._rowCount; i < il; i++) {
if (ma_qa_metric.mode.value(i) !== 'local-pairwise') continue;
const id = ma_qa_metric.id.value(i);
const name = ma_qa_metric.name.value(i);
if (!name.toLowerCase().includes('pae')) continue;
result.push({ id, name });
}
return result;
}
export function pairwiseMetricFromModelArchiveCIF(model: Model, frame: CifFrame, metricId: number): Pairwise | undefined {
const db = toDatabase(PairwiseSchema, frame);
if (!db.ma_qa_metric_local_pairwise._rowCount) return undefined;
const { ma_qa_metric, ma_qa_metric_local_pairwise } = db;
const { model_id, label_asym_id_1, label_seq_id_1, label_asym_id_2, label_seq_id_2, metric_id, metric_value } = db.ma_qa_metric_local_pairwise;
const { index } = model.atomicHierarchy;
let metric: Pairwise | undefined;
for (let i = 0, il = ma_qa_metric._rowCount; i < il; i++) {
if (ma_qa_metric.mode.value(i) !== 'local-pairwise') continue;
const id = ma_qa_metric.id.value(i);
if (id !== metricId) continue;
const name = ma_qa_metric.name.value(i);
metric = {
id,
name,
residueRange: [Number.MAX_SAFE_INTEGER as ResidueIndex, Number.MIN_SAFE_INTEGER as ResidueIndex],
valueRange: [Number.MAX_VALUE, -Number.MAX_VALUE],
values: {}
};
}
if (!metric) return undefined;
const { values, residueRange, valueRange } = metric;
const residueKey: AtomicIndex.ResidueLabelKey = {
label_entity_id: '',
label_asym_id: '',
label_seq_id: 0,
pdbx_PDB_ins_code: undefined,
};
for (let i = 0, il = ma_qa_metric_local_pairwise._rowCount; i < il; i++) {
if (model_id.value(i) !== model.modelNum || metric_id.value(i) !== metricId) continue;
let labelAsymId = label_asym_id_1.value(i);
let entityIndex = index.findEntity(labelAsymId);
residueKey.label_entity_id = model.entities.data.id.value(entityIndex);
residueKey.label_asym_id = labelAsymId;
residueKey.label_seq_id = label_seq_id_1.value(i);
const rI_1 = index.findResidueLabel(residueKey);
if (rI_1 < 0) continue;
labelAsymId = label_asym_id_2.value(i);
entityIndex = index.findEntity(labelAsymId);
residueKey.label_entity_id = model.entities.data.id.value(entityIndex);
residueKey.label_asym_id = labelAsymId;
residueKey.label_seq_id = label_seq_id_2.value(i);
const rI_2 = index.findResidueLabel(residueKey);
if (rI_1 < 0) continue;
let r1 = values[rI_1];
if (!r1) {
r1 = {};
values[rI_1] = r1;
}
const value = metric_value.value(i);
r1[rI_2] = value;
if (rI_1 < residueRange[0]) residueRange[0] = rI_1;
if (rI_2 < residueRange[0]) residueRange[0] = rI_2;
if (rI_1 > residueRange[1]) residueRange[1] = rI_1;
if (rI_2 > residueRange[1]) residueRange[1] = rI_2;
if (value < valueRange[0]) valueRange[0] = value;
if (value > valueRange[1]) valueRange[1] = value;
}
return metric;
}
export const symbols = {
pLDDT: QuerySymbolRuntime.Dynamic(CustomPropSymbol('ma', 'quality-assessment.pLDDT', Type.Num),
ctx => {

View File

@@ -5216,6 +5216,70 @@ export const mmCIF_Schema = {
*/
metric_value: float,
},
ma_qa_metric_local_pairwise: {
/**
* A unique identifier for the category.
*/
ordinal_id: int,
/**
* The identifier for the structural model, for which local QA metric is provided.
* This data item is a pointer to _ma_model_list.model_id
* in the MA_MODEL_LIST category.
*/
model_id: int,
/**
* The identifier for the asym id of the residue in the
* structural model, for which local QA metric is provided.
* This data item is a pointer to _atom_site.label_asym_id
* in the ATOM_SITE category.
*/
label_asym_id_1: str,
/**
* The identifier for the sequence index of the residue
* in the structural model, for which local QA metric is provided.
* This data item is a pointer to _atom_site.label_seq_id
* in the ATOM_SITE category.
*/
label_seq_id_1: int,
/**
* The component identifier for the residue in the
* structural model, for which local QA metric is provided.
* This data item is a pointer to _atom_site.label_comp_id
* in the ATOM_SITE category.
*/
label_comp_id_1: str,
/**
* The identifier for the asym id of the residue in the
* structural model, for which local QA metric is provided.
* This data item is a pointer to _atom_site.label_asym_id
* in the ATOM_SITE category.
*/
label_asym_id_2: str,
/**
* The identifier for the sequence index of the residue
* in the structural model, for which local QA metric is provided.
* This data item is a pointer to _atom_site.label_seq_id
* in the ATOM_SITE category.
*/
label_seq_id_2: int,
/**
* The component identifier for the residue in the
* structural model, for which local QA metric is provided.
* This data item is a pointer to _atom_site.label_comp_id
* in the ATOM_SITE category.
*/
label_comp_id_2: str,
/**
* The identifier for the QA metric.
* This data item is a pointer to _ma_qa_metric.id in the
* MA_QA_METRIC category.
*/
metric_id: int,
/**
* The value of the local QA metric.
*/
metric_value: float,
},
};
export type mmCIF_Schema = typeof mmCIF_Schema;

View File

@@ -263,4 +263,16 @@ export namespace AtomicHierarchy {
export function chainResidueCount(segs: AtomicSegments, cI: ChainIndex) {
return chainEndResidueIndexExcl(segs, cI) - chainStartResidueIndex(segs, cI);
}
export function residueFirstAtomIndex(hierarchy: AtomicHierarchy, rI: ResidueIndex) {
return hierarchy.residueAtomSegments.offsets[rI];
}
export function atomChainIndex(hierarchy: AtomicHierarchy, eI: ElementIndex) {
return hierarchy.chainAtomSegments.index[eI];
}
export function residueChainIndex(hierarchy: AtomicHierarchy, rI: ResidueIndex) {
return hierarchy.chainAtomSegments.index[hierarchy.residueAtomSegments.offsets[rI]];
}
}

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2017 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2017-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
@@ -13,6 +13,11 @@ namespace StructureQuery {
export function run(query: StructureQuery, structure: Structure, options?: QueryContextOptions) {
return query(new QueryContext(structure, options));
}
export function loci(query: StructureQuery, structure: Structure, options?: QueryContextOptions) {
const sel = query(new QueryContext(structure, options));
return StructureSelection.toLociWithSourceUnits(sel);
}
}
export { StructureQuery };

View File

@@ -80,7 +80,7 @@ export type CollapsableState = {
}
export abstract class CollapsableControls<P = {}, S = {}, SS = {}> extends PluginUIComponent<P & CollapsableProps, S & CollapsableState, SS> {
toggleCollapsed = () => {
toggleCollapsed() {
this.setState({ isCollapsed: !this.state.isCollapsed } as (S & CollapsableState));
};
@@ -102,7 +102,7 @@ export abstract class CollapsableControls<P = {}, S = {}, SS = {}> extends Plugi
return <div className={wrapClass}>
<div id={divid} className='msp-transform-header'>
<Button icon={this.state.brand ? void 0 : this.state.isCollapsed ? ArrowRightSvg : ArrowDropDownSvg} noOverflow onClick={this.toggleCollapsed}
<Button icon={this.state.brand ? void 0 : this.state.isCollapsed ? ArrowRightSvg : ArrowDropDownSvg} noOverflow onClick={() => this.toggleCollapsed()}
className={this.state.brand ? `msp-transform-header-brand msp-transform-header-brand-${this.state.brand.accent}` : void 0} title={`Click to ${this.state.isCollapsed ? 'expand' : 'collapse'}`}>
{/* {this.state.brand && <div className={`msp-accent-bg-${this.state.brand.accent}`}>{this.state.brand.svg ? <Icon svg={this.state.brand.svg} /> : this.state.brand.name}</div>} */}
<Icon svg={this.state.brand?.svg} inline />

View File

@@ -34,6 +34,10 @@
margin: $control-spacing;
}
.msp-svg-text {
fill: $font-color;
}
& {
background: $default-background;
}

View File

@@ -1,3 +1,5 @@
@use "sass:color";
$default-background: #2D3E50;
$font-color: #EDF1F2;
$hover-font-color: #3B9AD9;

View File

@@ -1,3 +1,5 @@
@use "sass:color";
$default-background: #111318;
$font-color: #ccd4e0;
$hover-font-color: #51A2FB;

View File

@@ -1,6 +1,6 @@
const { createApp, createExample, createBrowserTest } = require('./webpack.config.common.js');
const examples = ['proteopedia-wrapper', 'basic-wrapper', 'lighting', 'alpha-orbitals'];
const examples = ['proteopedia-wrapper', 'basic-wrapper', 'lighting', 'alpha-orbitals', 'alphafolddb-pae'];
const tests = [
'font-atlas',
'marching-cubes',