diff --git a/src/apps/rednatco/color-picker.tsx b/src/apps/rednatco/color-picker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3958ea3d937c883c3feb186adf510ee8bd909297 --- /dev/null +++ b/src/apps/rednatco/color-picker.tsx @@ -0,0 +1,736 @@ +/** + * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Lada Biedermannová <Lada.Biedermannova@ibt.cas.cz> + * @author Jiřà Černý <jiri.cerny@ibt.cas.cz> + * @author Michal Malý <michal.maly@ibt.cas.cz> + * @author Bohdan Schneider <Bohdan.Schneider@ibt.cas.cz> + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Colors } from './colors'; +import { PushButton, SpinBox } from './controls'; +import { parseInt as parseIntMS } from '../../mol-io/reader/common/text/number-parser'; + +const PALETTE_CURSOR_HALFSIZE = 10; +const VALUE_CURSOR_THICKNESS = 3; + +const MIN_RGB = 0; +const MAX_RGB = 255; +const MIN_HUE = 0; +const MAX_HUE = 359; +const MIN_SATVAL = 0; +const MAX_SATVAL = 100; + +function isRgbVal(v: number) { + if (isNaN(v)) + return false; + return v >= MIN_RGB && v <= MAX_RGB; +} + +function isHueVal(v: number) { + if (isNaN(v)) + return false; + return v >= MIN_HUE && v <= MAX_HUE; +} + +function isSatValVal(v: number) { + if (isNaN(v)) + return false; + return v >= MIN_SATVAL && v <= MAX_SATVAL; +} + +function stoi(s: string) { + return parseIntMS(s, 0, s.length); +} + +interface State { + h: number; + s: number; + v: number; + restoreOnCancel: boolean; + + hIn: string; + sIn: string; + vIn: string; + rIn: string; + gIn: string; + bIn: string; +} + +export class ColorPicker extends React.Component<ColorPicker.Props, State> { + private paletteRef: React.RefObject<HTMLCanvasElement>; + private valueColumnRef: React.RefObject<HTMLCanvasElement>; + private selfRef: React.RefObject<HTMLDivElement>; + private mouseListenerAttached: boolean; + private touchListenerAttached: boolean; + private lastPaletteX: number; + private lastPaletteY: number; + private lastValueY: number; + + constructor(props: ColorPicker.Props) { + super(props); + + this.paletteRef = React.createRef(); + this.valueColumnRef = React.createRef(); + this.selfRef = React.createRef(); + this.mouseListenerAttached = false; + this.touchListenerAttached = false; + this.lastPaletteX = 0; + this.lastPaletteY = 0; + this.lastValueY = 0; + + const { h, s, v } = Colors.colorToHsv(this.props.initialColor); + const { r, g, b } = Colors.colorToRgb(this.props.initialColor); + this.state = { + h: Math.round(h), + s, + v, + restoreOnCancel: false, + hIn: h.toString(), + sIn: v.toString(), + vIn: s.toString(), + rIn: r.toString(), + gIn: g.toString(), + bIn: b.toString(), + }; + } + + private calcLeft() { + const self = this.selfRef.current; + if (!self) + return this.props.left; + + const bw = document.body.clientWidth; + const right = self.offsetLeft + self.clientWidth; + const overhang = right - bw; + if (overhang > 0) + return bw - self.clientWidth - 25; + return self.offsetLeft; + } + + private calcTop() { + const self = this.selfRef.current; + if (!self) + return this.props.top; + + const bh = document.body.clientHeight; + const bottom = self.offsetTop + self.clientHeight; + const overhang = bottom - bh; + + if (overhang > 0) + return bh - self.clientHeight - 25; + return self.offsetTop; + } + + private changeColorFromPalette(ex: number, ey: number) { + const tainer = this.selfRef.current!; + const palette = this.paletteRef.current!; + let x = ex - tainer.offsetLeft - tainer.clientLeft - palette.offsetLeft - palette.clientLeft; + let y = ey - tainer.offsetTop - tainer.clientTop - palette.offsetTop - palette.clientTop; + + if (x < 0) + x = 0; + else if (x >= palette.width) + x = palette.width - 1; + if (y < 0) + y = 0; + else if (y >= palette.height) + y = palette.height - 1; + + const { h, s } = this.paletteCoordsToHueSat(x, y); + this.updateColorHsv({ h, s, v: this.state.v }); + } + + private changeColorFromValue(ey: number) { + const tainer = this.selfRef.current!; + const valCol = this.valueColumnRef.current!; + let y = ey - tainer.offsetTop - tainer.clientTop - valCol.offsetTop - valCol.clientTop; + if (y < 0) + y = 0; + else if (y >= valCol.height) + y = valCol.height - 1; + const v = this.valueColumnCoordToVal(y); + this.updateColorHsv({ h: this.state.h, s: this.state.s, v }); + } + + private dispose() { + document.body.removeChild(this.props.parentElement); + } + + private drawPalette() { + if (!this.paletteRef.current) + return; + + const canvas = this.paletteRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) + return; + + this.drawPalleteInternal(ctx, canvas.width, canvas.height); + this.drawPaletteCursorInternal(ctx, canvas.width, canvas.height, this.state.h, this.state.s); + } + + private drawPalleteInternal(ctx: CanvasRenderingContext2D, width: number, height: number) { + const hueStep = 360 / width; + const satStep = 1.0 / height; + + ctx.clearRect(0, 0, width, height); + + for (let x = 0; x < width; x++) { + const hue = hueStep * x; + for (let y = 0; y < height; y++) { + const sat = 1.0 - satStep * y; + + ctx.fillStyle = Colors.hsvToHexString(hue, sat, 1.0); + ctx.fillRect(x, y, 1, 1); + } + } + } + + private drawPaletteCursor(hue: number, sat: number) { + if (!this.paletteRef.current) + return; + + const canvas = this.paletteRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) + return; + + this.drawPaletteCursorInternal(ctx, canvas.width, canvas.height, hue, sat); + } + + private drawPaletteCursorInternal(ctx: CanvasRenderingContext2D, width: number, height: number, hue: number, sat: number) { + const hueStep = 360 / width; + const satStep = 1.0 / height; + const fullSize = Math.floor(PALETTE_CURSOR_HALFSIZE * 2); + + let fx = Math.floor(this.lastPaletteX - PALETTE_CURSOR_HALFSIZE - 1); + if (fx < 0) fx = 0; + let tx = fx + fullSize + 2; + if (tx > width) tx = width; + + let fy = Math.floor(this.lastPaletteY - PALETTE_CURSOR_HALFSIZE - 1); + if (fy < 0) fy = 0; + let ty = fy + fullSize + 2; + if (ty > height) ty = height; + + for (let x = fx; x < tx; x++) { + const hue = hueStep * x; + for (let y = fy; y < ty; y++) { + const sat = 1.0 - satStep * y; + + ctx.fillStyle = Colors.hsvToHexString(hue, sat, 1.0); + ctx.fillRect(x, y, 1, 1); + } + } + + const cx = Math.round(hue / hueStep); + const cy = Math.round((1.0 - sat) / satStep); + + ctx.beginPath(); + ctx.fillStyle = 'rgba(0, 0, 0, 1.0)'; + ctx.lineWidth = 2; + ctx.moveTo(cx - PALETTE_CURSOR_HALFSIZE, cy); + ctx.lineTo(cx + PALETTE_CURSOR_HALFSIZE, cy); + ctx.moveTo(cx, cy - PALETTE_CURSOR_HALFSIZE); + ctx.lineTo(cx, cy + PALETTE_CURSOR_HALFSIZE); + ctx.closePath(); + ctx.stroke(); + + this.lastPaletteX = cx; + this.lastPaletteY = cy; + } + + private drawValueColumn() { + if (!this.valueColumnRef.current) + return; + + const canvas = this.valueColumnRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) + return; + + this.drawValueColumnInternal(ctx, canvas.width, canvas.height); + this.drawValueColumnCursorInternal(ctx, canvas.width, canvas.height, this.state.v); + } + + private drawValueColumnInternal(ctx: CanvasRenderingContext2D, width: number, height: number) { + const valStep = 1.0 / height; + + for (let y = 0; y < height; y++) { + const cv = 1.0 - y * valStep; + + ctx.fillStyle = Colors.hsvToHexString(this.state.h, this.state.s, cv); + ctx.fillRect(0, y, width, 1); + } + } + + private drawValueColumnCursor(val: number) { + if (!this.valueColumnRef.current) + return; + + const canvas = this.valueColumnRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) + return; + + this.drawValueColumnCursorInternal(ctx, canvas.width, canvas.height, val); + } + + private drawValueColumnCursorInternal(ctx: CanvasRenderingContext2D, width: number, height: number, val: number) { + const valStep = 1.0 / height; + + let fy = Math.floor(this.lastValueY - 1); + if (fy < 0) fy = 0; + let ty = Math.floor(fy + VALUE_CURSOR_THICKNESS + 2); + if (ty > height) + ty = height; + + for (let y = fy; y < ty; y++) { + const cv = 1.0 - y * valStep; + + ctx.fillStyle = Colors.hsvToHexString(this.state.h, this.state.s, cv); + ctx.fillRect(0, y, width, 1); + } + + const y = Math.round((1.0 - val) / valStep); + const halfWidth = Math.round(width / 2); + + ctx.fillStyle = 'rgba(255, 255, 255, 1.0)'; + ctx.fillRect(0, y, halfWidth, VALUE_CURSOR_THICKNESS); + ctx.fillStyle = 'rgba(0, 0, 0, 1.0)'; + ctx.fillRect(halfWidth, y, width - halfWidth, VALUE_CURSOR_THICKNESS); + + this.lastValueY = y; + } + + private paletteCoordsToHueSat(x: number, y: number) { + const palette = this.paletteRef.current!; + + const h = 360 * x / palette.width; + const s = 1.0 - 1.0 * y / palette.height; + + return { h, s }; + } + + private onGlobalMouseMovedValue = (evt: MouseEvent) => { + if ((evt.buttons & 1) === 0) { + window.removeEventListener('mousemove', this.onGlobalMouseMovedValue); + this.mouseListenerAttached = false; + return; + } + + this.changeColorFromValue(evt.pageY); + }; + + private onGlobalMouseMovedPalette = (evt: MouseEvent) => { + if ((evt.buttons & 1) === 0) { + window.removeEventListener('mousemove', this.onGlobalMouseMovedPalette); + this.mouseListenerAttached = false; + return; + } + + this.changeColorFromPalette(evt.pageX, evt.pageY); + }; + + private onGlobalTouchMovedPalette = (evt: TouchEvent) => { + if (evt.touches.length !== 0) + this.changeColorFromPalette(evt.touches[0].pageX, evt.touches[0].pageY); + }; + + private updateColorHsv(hsv: { h: number, s: number, v: number }) { + const rgb = Colors.hsv2rgb(hsv.h, hsv.s, hsv.v); + this.updateColor(rgb, hsv); + } + + private updateColorRgb(rgb: { r: number, g: number, b: number }) { + const hsv = Colors.rgb2hsv(rgb.r, rgb.g, rgb.b); + this.updateColor(rgb, hsv); + } + + private updateColor(rgb: { r: number, g: number, b: number }, hsv: { h: number, s: number, v: number }) { + let update: Partial<State> = { ...hsv }; + if (this.state.rIn === '') + update = { ...update, rIn: rgb.r.toString() }; + if (this.state.gIn === '') + update = { ...update, gIn: rgb.g.toString() }; + if (this.state.bIn === '') + update = { ...update, bIn: rgb.b.toString() }; + if (this.state.hIn === '') + update = { ...update, hIn: hsv.h.toString() }; + if (this.state.sIn === '') + update = { ...update, sIn: hsv.s.toString() }; + if (this.state.vIn === '') + update = { ...update, vIn: hsv.v.toString() }; + + this.setState({ + ...this.state, + ...update, + }); + } + + private onGlobalTouchMovedValue = (evt: TouchEvent) => { + if (evt.touches.length !== 0) + this.changeColorFromValue(evt.touches[0].pageY); + }; + + private valueColumnCoordToVal(y: number) { + const valCol = this.valueColumnRef.current!; + + return 1.0 - 1.0 * y / valCol.height; + } + + componentDidMount() { + this.drawPalette(); + this.drawValueColumn(); + + this.forceUpdate(); + } + + componentDidUpdate(_prevProps: ColorPicker.Props, prevState: State) { + if (this.state.h !== prevState.h || this.state.s !== prevState.s) { + this.drawPaletteCursor(this.state.h, this.state.s); + this.drawValueColumn(); + } + if (this.state.v !== prevState.v) { + this.drawValueColumnCursor(this.state.v); + } + } + + componentWillUnmount() { + window.removeEventListener('mousemove', this.onGlobalMouseMovedValue); + window.removeEventListener('mousemove', this.onGlobalMouseMovedPalette); + window.removeEventListener('touchmove', this.onGlobalTouchMovedValue); + window.removeEventListener('touchmove', this.onGlobalTouchMovedPalette); + } + + render() { + return ( + <div + ref={this.selfRef} + style={{ + background: 'white', + border: '0.15em solid #ccc', + boxShadow: '0 0 0.3em 0 rgba(0, 0, 0, 0.5)', + left: this.calcLeft(), + padding: '0.5em', + position: 'absolute', + top: this.calcTop(), + zIndex: 99, + }} + > + <div + style={{ + display: 'grid', + gridColumnGap: '0.5em', + gridTemplateColumns: 'auto auto', + marginBottom: '0.5em', + }} + > + <canvas + width={360} + height={256} + ref={this.paletteRef} + onMouseDown={evt => { + if ((evt.buttons & 1) === 0 || this.mouseListenerAttached) + return; + this.changeColorFromPalette(evt.pageX, evt.pageY); + this.mouseListenerAttached = true; + window.addEventListener('mousemove', this.onGlobalMouseMovedPalette); + }} + onMouseUp={evt => { + if (evt.buttons & 1) { + window.removeEventListener('mousemove', this.onGlobalMouseMovedPalette); + this.mouseListenerAttached = false; + } + }} + onTouchStart={evt => { + if (this.touchListenerAttached) + return; + + window.addEventListener('touchmove', this.onGlobalTouchMovedPalette); + this.touchListenerAttached = true; + if (evt.touches.length !== 0) + this.changeColorFromPalette(evt.touches[0].pageX, evt.touches[0].pageY); + + }} + onTouchEnd={_evt => { + window.removeEventListener('touchmove', this.onGlobalTouchMovedPalette); + this.touchListenerAttached = false; + }} + /> + <canvas + width={30} + height={256} + ref={this.valueColumnRef} + style={{ + height: '100%', + width: '1em', + }} + onMouseDown={evt => { + if ((evt.buttons & 1) === 0 || this.mouseListenerAttached) + return; + this.changeColorFromValue(evt.pageY); + this.mouseListenerAttached = true; + window.addEventListener('mousemove', this.onGlobalMouseMovedValue); + }} + onMouseUp={evt => { + if (evt.buttons & 1) { + window.removeEventListener('mousemove', this.onGlobalMouseMovedValue); + this.mouseListenerAttached = false; + } + }} + onTouchStart={evt => { + if (this.touchListenerAttached) + return; + + window.addEventListener('touchmove', this.onGlobalTouchMovedValue); + this.touchListenerAttached = true; + if (evt.touches.length !== 0) + this.changeColorFromValue(evt.touches[0].pageY); + + }} + onTouchEnd={_evt => { + window.removeEventListener('touchmove', this.onGlobalTouchMovedValue); + this.touchListenerAttached = false; + }} + + onWheel={evt => { + if (evt.deltaY === 0) + return; + let v = this.state.v - 0.01 * Math.sign(evt.deltaY); + if (v < 0) + v = 0; + else if (v > 1) + v = 1; + this.setState({ ...this.state, v }); + }} + /> + </div> + <div + style={{ + display: 'flex', + marginBottom: '0.5em', + }} + > + <div + style={{ + background: Colors.colorToHexString(this.props.initialColor), + flex: 1, + height: '2em', + }} + /> + <div + style={{ + background: Colors.colorToHexString(Colors.colorFromHsv(this.state.h, this.state.s, this.state.v)), + flex: 1, + height: '2em', + }} + /> + </div> + <div + style={{ + display: 'grid', + gridColumnGap: '0.5em', + gridTemplateColumns: 'auto 4em auto 4em auto 4em', + marginBottom: '0.5em', + }} + > + <div>R</div> + <SpinBox + min={MIN_RGB} + max={MAX_RGB} + step={1} + value={this.state.rIn === '' ? null : Math.round(Colors.hsv2rgb(this.state.h, this.state.s, this.state.v).r)} + onChange={rIn => { + if (rIn === '') + this.setState({ ...this.state, rIn }); + else { + const r = stoi(rIn); + if (!isRgbVal(r)) + return; + + const { g, b } = Colors.hsv2rgb(this.state.h, this.state.s, this.state.v); + this.updateColorRgb({ r, g, b }); + } + }} + pathPrefix={this.props.pathPrefix} + /> + <div>G</div> + <SpinBox + min={MIN_RGB} + max={MAX_RGB} + step={1} + value={this.state.gIn === '' ? null : Math.round(Colors.hsv2rgb(this.state.h, this.state.s, this.state.v).g)} + onChange={gIn => { + if (gIn === '') + this.setState({ ...this.state, gIn }); + else { + const g = stoi(gIn); + if (!isRgbVal(g)) + return; + + const { r, b } = Colors.hsv2rgb(this.state.h, this.state.s, this.state.v); + this.updateColorRgb({ r, g, b }); + } + }} + pathPrefix={this.props.pathPrefix} + /> + <div>B</div> + <SpinBox + min={MIN_RGB} + max={MAX_RGB} + step={1} + value={this.state.bIn === '' ? null : Math.round(Colors.hsv2rgb(this.state.h, this.state.s, this.state.v).b)} + onChange={bIn => { + if (bIn === '') + this.setState({ ...this.state, bIn }); + else { + const b = stoi(bIn); + if (!isRgbVal(b)) + return; + + const { r, g } = Colors.hsv2rgb(this.state.h, this.state.s, this.state.v); + this.updateColorRgb({ r, g, b }); + } + }} + pathPrefix={this.props.pathPrefix} + /> + </div> + <div + style={{ + display: 'grid', + gridColumnGap: '0.5em', + gridTemplateColumns: 'auto 4em auto 4em auto 4em', + marginBottom: '0.5em', + }} + > + <div>H</div> + <SpinBox + min={MIN_HUE} + max={MAX_HUE} + step={1} + value={this.state.hIn === '' ? null : Math.round(this.state.h)} + onChange={hIn => { + if (hIn === '') + this.setState({ ...this.state, hIn }); + else { + const h = stoi(hIn); + if (!isHueVal(h)) + return; + + this.updateColorHsv({ h, s: this.state.s, v: this.state.v }); + } + }} + pathPrefix={this.props.pathPrefix} + /> + <div>S</div> + <SpinBox + min={MIN_SATVAL} + max={MAX_SATVAL} + step={1} + value={this.state.sIn === '' ? null : Math.round(this.state.s * 100)} + onChange={sIn => { + if (sIn === '') + this.setState({ ...this.state, sIn }); + else { + const s = stoi(sIn); + if (!isSatValVal(s)) + return; + + this.updateColorHsv({ h: this.state.h, s: s / 100, v: this.state.v }); + } + }} + pathPrefix={this.props.pathPrefix} + /> + <div>V</div> + <SpinBox + min={MIN_SATVAL} + max={MAX_SATVAL} + step={1} + value={this.state.vIn === '' ? null : Math.round(this.state.v * 100)} + onChange={vIn => { + if (vIn === '') + this.setState({ ...this.state, vIn }); + else { + const v = stoi(vIn); + if (!isSatValVal(v)) + return; + + this.updateColorHsv({ h: this.state.h, s: this.state.s, v: v / 100 }); + } + }} + pathPrefix={this.props.pathPrefix} + /> + </div> + <div + style={{ + display: 'flex', + gap: '0.5em', + }} + > + <PushButton + text='OK' + onClick={() => { + this.props.onColorPicked(Colors.colorFromHsv(this.state.h, this.state.s, this.state.v)); + this.dispose(); + }} + enabled={true} + /> + <PushButton + text='Preview' + onClick={() => { + this.props.onColorPicked(Colors.colorFromHsv(this.state.h, this.state.s, this.state.v)); + this.setState({ ...this.state, restoreOnCancel: true }); + }} + enabled={true} + /> + <PushButton + text='Cancel' + onClick={() => { + if (this.state.restoreOnCancel) + this.props.onColorPicked(this.props.initialColor); + this.dispose(); + }} + enabled={true} + /> + </div> + </div> + ); + } +} + +export namespace ColorPicker { + export interface OnColorPicked { + (color: number): void; + } + + export interface Props { + initialColor: number; + left: number; + top: number; + onColorPicked: OnColorPicked; + parentElement: HTMLElement; + pathPrefix: string; + } + + export function create<T>(evt: React.MouseEvent<T, MouseEvent>, initialColor: number, handler: OnColorPicked, pathPrefix = '') { + const tainer = document.createElement('div'); + tainer.classList.add('rmsp-color-picker-nest'); + document.body.appendChild(tainer); + + ReactDOM.render( + <ColorPicker + initialColor={initialColor} + left={evt.clientX} + top={evt.clientY} + onColorPicked={handler} + parentElement={tainer} + pathPrefix={pathPrefix} + />, + tainer + ); + } +} diff --git a/src/apps/rednatco/colors.ts b/src/apps/rednatco/colors.ts new file mode 100644 index 0000000000000000000000000000000000000000..10c4f5650d3b98c58fcb291d3b2f40b13cae8d3e --- /dev/null +++ b/src/apps/rednatco/colors.ts @@ -0,0 +1,309 @@ +/** + * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Lada Biedermannová <Lada.Biedermannova@ibt.cas.cz> + * @author Jiřà Černý <jiri.cerny@ibt.cas.cz> + * @author Michal Malý <michal.maly@ibt.cas.cz> + * @author Bohdan Schneider <Bohdan.Schneider@ibt.cas.cz> + */ + +import { Color } from '../../mol-util/color'; + +function ntxs(num: number) { + num = Math.round(num); + const str = num.toString(16); + return num < 16 ? '0' + str : str; +} + +export namespace Colors { + export function colorFromRgb(r: number, g: number, b: number) { + return Color.fromRgb(r, g, b); + } + + export function colorFromHsv(h: number, s: number, v: number) { + const { r, g, b } = hsv2rgb(h, s, v); + return Color.fromRgb(r, g, b); + } + + export function colorToHexString(clr: number) { + const [r, g, b] = Color.toRgb(Color(clr)); + return rgbToHexString(r, g, b); + } + + export function colorToHsv(clr: number) { + const [r, g, b] = Color.toRgb(Color(clr)); + return rgb2hsv(r, g, b); + } + + export function colorToRgb(clr: number) { + const [r, g, b] = Color.toRgb(Color(clr)); + return { r, g, b }; + } + + export function hsv2rgb(h: number, s: number, v: number): { r: number, g: number, b: number } { + const f = (n: number, k = (n + h / 60) % 6) => v - v * s * Math.max(Math.min(k, 4 - k, 1), 0); + return { r: f(5) * 255, g: f(3) * 255, b: f(1) * 255 }; + } + + export function hsvToColor(h: number, s: number, v: number) { + const { r, g, b } = hsv2rgb(h, s, v); + return Color.fromRgb(r, g, b); + } + + export function hsvToHexString(h: number, s: number, v: number) { + const { r, g, b } = hsv2rgb(h, s, v); + return rgbToHexString(r, g, b); + } + + export function rgbToHexString(r: number, g: number, b: number) { + return `#${ntxs(r)}${ntxs(g)}${ntxs(b)}`; + } + + export function rgb2hsv(r: number, g: number, b: number) { + const rabs = r / 255; + const gabs = g / 255; + const babs = b / 255; + const v = Math.max(rabs, gabs, babs); + const diff = v - Math.min(rabs, gabs, babs); + const diffc = (c: number) => (v - c) / 6 / diff + 1 / 2; + + let h = 0; + let s = 0; + + if (diff !== 0) { + s = diff / v; + const rr = diffc(rabs); + const gg = diffc(gabs); + const bb = diffc(babs); + + if (rabs === v) { + h = bb - gg; + } else if (gabs === v) { + h = (1 / 3) + rr - bb; + } else if (babs === v) { + h = (2 / 3) + gg - rr; + } + + if (h < 0) { + h += 1; + } else if (h > 1) { + h -= 1; + } + } + + return { h: h * 360, s, v }; + } +} + +export namespace NtCColors { + export const Classes = { + A: Color(0xFFC1C1), + B: Color(0xC8CFFF), + BII: Color(0x0059DA), + miB: Color(0x3BE8FB), + Z: Color(0x01F60E), + IC: Color(0xFA5CFB), + OPN: Color(0xE90000), + SYN: Color(0xFFFF01), + N: Color(0xF2F2F2), + }; + export type Classes = typeof Classes; + + export const Conformers = { + NANT_Upr: Classes.N, + NANT_Lwr: Classes.N, + AA00_Upr: Classes.A, + AA00_Lwr: Classes.A, + AA02_Upr: Classes.A, + AA02_Lwr: Classes.A, + AA03_Upr: Classes.A, + AA03_Lwr: Classes.A, + AA04_Upr: Classes.A, + AA04_Lwr: Classes.A, + AA08_Upr: Classes.A, + AA08_Lwr: Classes.A, + AA09_Upr: Classes.A, + AA09_Lwr: Classes.A, + AA01_Upr: Classes.A, + AA01_Lwr: Classes.A, + AA05_Upr: Classes.A, + AA05_Lwr: Classes.A, + AA06_Upr: Classes.A, + AA06_Lwr: Classes.A, + AA10_Upr: Classes.A, + AA10_Lwr: Classes.A, + AA11_Upr: Classes.A, + AA11_Lwr: Classes.A, + AA07_Upr: Classes.A, + AA07_Lwr: Classes.A, + AA12_Upr: Classes.A, + AA12_Lwr: Classes.A, + AA13_Upr: Classes.A, + AA13_Lwr: Classes.A, + AB01_Upr: Classes.A, + AB01_Lwr: Classes.B, + AB02_Upr: Classes.A, + AB02_Lwr: Classes.B, + AB03_Upr: Classes.A, + AB03_Lwr: Classes.B, + AB04_Upr: Classes.A, + AB04_Lwr: Classes.B, + AB05_Upr: Classes.A, + AB05_Lwr: Classes.B, + BA01_Upr: Classes.B, + BA01_Lwr: Classes.A, + BA05_Upr: Classes.B, + BA05_Lwr: Classes.A, + BA09_Upr: Classes.B, + BA09_Lwr: Classes.A, + BA08_Upr: Classes.BII, + BA08_Lwr: Classes.A, + BA10_Upr: Classes.B, + BA10_Lwr: Classes.A, + BA13_Upr: Classes.BII, + BA13_Lwr: Classes.A, + BA16_Upr: Classes.BII, + BA16_Lwr: Classes.A, + BA17_Upr: Classes.BII, + BA17_Lwr: Classes.A, + BB00_Upr: Classes.B, + BB00_Lwr: Classes.B, + BB01_Upr: Classes.B, + BB01_Lwr: Classes.B, + BB17_Upr: Classes.B, + BB17_Lwr: Classes.B, + BB02_Upr: Classes.B, + BB02_Lwr: Classes.B, + BB03_Upr: Classes.B, + BB03_Lwr: Classes.B, + BB11_Upr: Classes.B, + BB11_Lwr: Classes.B, + BB16_Upr: Classes.B, + BB16_Lwr: Classes.B, + BB04_Upr: Classes.B, + BB04_Lwr: Classes.BII, + BB05_Upr: Classes.B, + BB05_Lwr: Classes.BII, + BB07_Upr: Classes.BII, + BB07_Lwr: Classes.BII, + BB08_Upr: Classes.BII, + BB08_Lwr: Classes.BII, + BB10_Upr: Classes.miB, + BB10_Lwr: Classes.miB, + BB12_Upr: Classes.miB, + BB12_Lwr: Classes.miB, + BB13_Upr: Classes.miB, + BB13_Lwr: Classes.miB, + BB14_Upr: Classes.miB, + BB14_Lwr: Classes.miB, + BB15_Upr: Classes.miB, + BB15_Lwr: Classes.miB, + BB20_Upr: Classes.miB, + BB20_Lwr: Classes.miB, + IC01_Upr: Classes.IC, + IC01_Lwr: Classes.IC, + IC02_Upr: Classes.IC, + IC02_Lwr: Classes.IC, + IC03_Upr: Classes.IC, + IC03_Lwr: Classes.IC, + IC04_Upr: Classes.IC, + IC04_Lwr: Classes.IC, + IC05_Upr: Classes.IC, + IC05_Lwr: Classes.IC, + IC06_Upr: Classes.IC, + IC06_Lwr: Classes.IC, + IC07_Upr: Classes.IC, + IC07_Lwr: Classes.IC, + OP01_Upr: Classes.OPN, + OP01_Lwr: Classes.OPN, + OP02_Upr: Classes.OPN, + OP02_Lwr: Classes.OPN, + OP03_Upr: Classes.OPN, + OP03_Lwr: Classes.OPN, + OP04_Upr: Classes.OPN, + OP04_Lwr: Classes.OPN, + OP05_Upr: Classes.OPN, + OP05_Lwr: Classes.OPN, + OP06_Upr: Classes.OPN, + OP06_Lwr: Classes.OPN, + OP07_Upr: Classes.OPN, + OP07_Lwr: Classes.OPN, + OP08_Upr: Classes.OPN, + OP08_Lwr: Classes.OPN, + OP09_Upr: Classes.OPN, + OP09_Lwr: Classes.OPN, + OP10_Upr: Classes.OPN, + OP10_Lwr: Classes.OPN, + OP11_Upr: Classes.OPN, + OP11_Lwr: Classes.OPN, + OP12_Upr: Classes.OPN, + OP12_Lwr: Classes.OPN, + OP13_Upr: Classes.OPN, + OP13_Lwr: Classes.OPN, + OP14_Upr: Classes.OPN, + OP14_Lwr: Classes.OPN, + OP15_Upr: Classes.OPN, + OP15_Lwr: Classes.OPN, + OP16_Upr: Classes.OPN, + OP16_Lwr: Classes.OPN, + OP17_Upr: Classes.OPN, + OP17_Lwr: Classes.OPN, + OP18_Upr: Classes.OPN, + OP18_Lwr: Classes.OPN, + OP19_Upr: Classes.OPN, + OP19_Lwr: Classes.OPN, + OP20_Upr: Classes.OPN, + OP20_Lwr: Classes.OPN, + OP21_Upr: Classes.OPN, + OP21_Lwr: Classes.OPN, + OP22_Upr: Classes.OPN, + OP22_Lwr: Classes.OPN, + OP23_Upr: Classes.OPN, + OP23_Lwr: Classes.OPN, + OP24_Upr: Classes.OPN, + OP24_Lwr: Classes.OPN, + OP25_Upr: Classes.OPN, + OP25_Lwr: Classes.OPN, + OP26_Upr: Classes.OPN, + OP26_Lwr: Classes.OPN, + OP27_Upr: Classes.OPN, + OP27_Lwr: Classes.OPN, + OP28_Upr: Classes.OPN, + OP28_Lwr: Classes.OPN, + OP29_Upr: Classes.OPN, + OP29_Lwr: Classes.OPN, + OP30_Upr: Classes.OPN, + OP30_Lwr: Classes.OPN, + OP31_Upr: Classes.OPN, + OP31_Lwr: Classes.OPN, + OPS1_Upr: Classes.OPN, + OPS1_Lwr: Classes.OPN, + OP1S_Upr: Classes.OPN, + OP1S_Lwr: Classes.OPN, + AAS1_Upr: Classes.SYN, + AAS1_Lwr: Classes.A, + AB1S_Upr: Classes.A, + AB1S_Lwr: Classes.SYN, + AB2S_Upr: Classes.A, + AB2S_Lwr: Classes.SYN, + BB1S_Upr: Classes.B, + BB1S_Lwr: Classes.SYN, + BB2S_Upr: Classes.B, + BB2S_Lwr: Classes.SYN, + BBS1_Upr: Classes.SYN, + BBS1_Lwr: Classes.B, + ZZ01_Upr: Classes.Z, + ZZ01_Lwr: Classes.Z, + ZZ02_Upr: Classes.Z, + ZZ02_Lwr: Classes.Z, + ZZ1S_Upr: Classes.Z, + ZZ1S_Lwr: Classes.SYN, + ZZ2S_Upr: Classes.Z, + ZZ2S_Lwr: Classes.SYN, + ZZS1_Upr: Classes.SYN, + ZZS1_Lwr: Classes.Z, + ZZS2_Upr: Classes.SYN, + ZZS2_Lwr: Classes.Z, + }; + export type Conformers = typeof Conformers; +} diff --git a/src/apps/rednatco/controls.tsx b/src/apps/rednatco/controls.tsx new file mode 100644 index 0000000000000000000000000000000000000000..62e751bb9a005bc9f6404753cde5d13949e79a06 --- /dev/null +++ b/src/apps/rednatco/controls.tsx @@ -0,0 +1,113 @@ +import React from 'react'; + +export class PushButton extends React.Component<{ text: string, enabled: boolean, onClick: () => void }> { + render() { + return ( + <div + className={`rmsp-pushbutton ${this.props.enabled ? '' : 'rmsp-pushbutton-disabled'}`} + onClick={() => this.props.enabled ? this.props.onClick() : {}} + > + <div className={`${this.props.enabled ? 'rmsp-pushbutton-text' : 'rmsp-pushbutton-text-disabled'}`}>{this.props.text}</div> + </div> + ); + } +} + +export class ToggleButton extends React.Component<{ text: string, enabled: boolean, switchedOn: boolean, onClick: () => void }> { + render() { + return ( + <div + className={`rmsp-pushbutton ${this.props.enabled ? (this.props.switchedOn ? 'rmsp-togglebutton-switched-on' : 'rmsp-togglebutton-switched-off') : 'rmsp-pushbutton-disabled'}`} + onClick={() => this.props.enabled ? this.props.onClick() : {}} + > + <div className={`${this.props.enabled ? 'rmsp-pushbutton-text' : 'rmsp-pushbutton-text-disabled'}`}>{this.props.text}</div> + </div> + ); + } +} + +export class SpinBox extends React.Component<SpinBox.Props> { + private clsDisabled() { + return this.props.classNameDisabled ?? 'rmsp-spinbox-input-disabled'; + } + + private clsEnabled() { + return this.props.className ?? 'rmsp-spinbox-input'; + } + + private decrease() { + if (this.props.value === null) + return; + const nv = this.props.value - this.props.step; + if (nv >= this.props.min) + this.props.onChange(nv.toString()); + } + + private increase() { + if (this.props.value === null) + return; + const nv = this.props.value + this.props.step; + if (nv >= this.props.min) + this.props.onChange(nv.toString()); + } + + render() { + return ( + <div className='rmsp-spinbox-container'> + <input + type='text' + className={this.props.disabled ? this.clsDisabled() : this.clsEnabled()} + value={this.props.formatter ? this.props.formatter(this.props.value) : this.props.value?.toString() ?? ''} + onChange={evt => this.props.onChange(evt.currentTarget.value)} + onWheel={evt => { + evt.stopPropagation(); + if (this.props.value === null) + return; + if (evt.deltaY < 0) { + const nv = this.props.value + this.props.step; + if (nv <= this.props.max) + this.props.onChange(nv.toString()); + } else if (evt.deltaY > 0) { + const nv = this.props.value - this.props.step; + if (nv >= this.props.min) + this.props.onChange(nv.toString()); + } + }} + /> + <div className='rmsp-spinbox-buttons'> + <img + className='rmsp-spinbox-button' + src={`./${this.props.pathPrefix}assets/imgs/triangle-up.svg`} onClick={() => this.increase()} + /> + <img + className='rmsp-spinbox-button' + src={`./${this.props.pathPrefix}assets/imgs/triangle-down.svg`} onClick={() => this.decrease()} + /> + </div> + </div> + ); + } +} + +export namespace SpinBox { + export interface Formatter { + (v: number|null): string; + } + + export interface OnChange { + (newValue: string): void; + } + + export interface Props { + value: number|null; + onChange: OnChange; + min: number; + max: number; + step: number; + pathPrefix: string; + disabled?: boolean; + className?: string; + classNameDisabled?: string; + formatter?: Formatter; + } +} diff --git a/src/apps/rednatco/idents.ts b/src/apps/rednatco/idents.ts index 7f3a731cebc5b731210f265e6b09c85645eef76c..b83cb834a7ecf37814f9ddc183d61fed46f7516b 100644 --- a/src/apps/rednatco/idents.ts +++ b/src/apps/rednatco/idents.ts @@ -1,8 +1,12 @@ -export type ID ='data'|'structure'|'visual'|'pyramids'; +export type ID ='data'|'trajectory'|'model'|'structure'|'visual'|'pyramids'; export type Substructure = 'nucleic'|'protein'|'water'; export function ID(id: ID, sub: Substructure|'', ref: string) { if (sub === '') - return `${id}_${ref}`; - return `${id}_${sub}_${ref}`; + return `${ref}_${id}`; + return `${ref}_${sub}_${id}`; +} + +export function isVisual(ident: string) { + return ident.endsWith('_visual'); } diff --git a/src/apps/rednatco/index.html b/src/apps/rednatco/index.html index 653f6c175698cc6d9145d64f721a2e15b2fa78b0..675cf4b28021d521a09995abe8fa61f3637236cd 100644 --- a/src/apps/rednatco/index.html +++ b/src/apps/rednatco/index.html @@ -4,8 +4,8 @@ <meta charset="utf-8" /> <link rel="stylesheet" type="text/css" href="molstar.css" /> </head> - <body style="height: 100vh; width: 100hw"> - <div id="xxx-app" style="height: 100%; width: 100%"></div> + <body style="height: 100vh; display: flex; overflow: hidden;"> + <div id="xxx-app" style="flex: 1"></div> <script type="text/javascript" src="./molstar.js"></script> <script> async function loadStructure() { diff --git a/src/apps/rednatco/index.tsx b/src/apps/rednatco/index.tsx index 8ad2b7d1c566adead36f834aa2350b63447fde61..4085262b6ec37991309fb062cf70f8c2ee4aee4f 100644 --- a/src/apps/rednatco/index.tsx +++ b/src/apps/rednatco/index.tsx @@ -1,11 +1,15 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { NtCColors } from './colors'; +import { ColorPicker } from './color-picker'; +import { PushButton, ToggleButton } from './controls'; import * as IDs from './idents'; import { DnatcoConfalPyramids } from '../../extensions/dnatco'; import { ConfalPyramidsParams } from '../../extensions/dnatco/confal-pyramids/representation'; -import { ConfalPyramidsColorThemeParams } from '../../extensions/dnatco/confal-pyramids/color'; +import { BoundaryHelper } from '../../mol-math/geometry/boundary-helper'; import { Loci } from '../../mol-model/loci'; -import { Structure } from '../../mol-model/structure'; +import { Model, Structure, Trajectory } from '../../mol-model/structure'; +import { MmcifFormat } from '../../mol-model-formats/structure/mmcif'; import { PluginBehavior, PluginBehaviors } from '../../mol-plugin/behavior'; import { PluginCommands } from '../../mol-plugin/commands'; import { PluginContext } from '../../mol-plugin/context'; @@ -18,8 +22,9 @@ import { createPluginUI } from '../../mol-plugin-ui'; import { PluginUIContext } from '../../mol-plugin-ui/context'; import { DefaultPluginUISpec, PluginUISpec } from '../../mol-plugin-ui/spec'; import { Representation } from '../../mol-repr/representation'; -import { StateSelection } from '../../mol-state'; +import { StateObjectCell, StateObject, StateSelection } from '../../mol-state'; import { StateTreeSpine } from '../../mol-state/tree/spine'; +import { Script } from '../../mol-script/script'; import { lociLabel } from '../../mol-theme/label'; import { Color } from '../../mol-util/color'; import { arrayMax } from '../../mol-util/array'; @@ -39,6 +44,9 @@ const Extensions = { const BaseRef = 'rdo'; const AnimationDurationMsec = 150; +const SelectAllScript = Script('(sel.atom.atoms true)', 'mol-script'); +const SphereBoundaryHelper = new BoundaryHelper('98'); + function capitalize(s: string) { if (s.length === 0) return s; @@ -46,31 +54,32 @@ function capitalize(s: string) { } -class PushButton extends React.Component<{ caption: string, enabled: boolean, onClick: () => void }> { +class ColorBox extends React.Component<{ caption: string, color: Color }> { render() { return ( - <div - className={`rmsp-pushbutton ${this.props.enabled ? '' : 'rmsp-pushbutton-disabled'}`} - onClick={() => this.props.enabled ? this.props.onClick() : {}} - > - <div className={`${this.props.enabled ? 'rmsp-pushbutton-text' : 'rmsp-pushbutton-text-disabled'}`}>{this.props.caption}</div> + <div className='rmsp-color-box'> + <div className='rmsp-color-box-caption'>{this.props.caption}</div> + <div + className='rmsp-color-box-color' + style={{ backgroundColor: Color.toStyle(this.props.color) }} + /> </div> ); } } -class ToggleButton extends React.Component<{ caption: string, enabled: boolean, switchedOn: boolean, onClick: () => void }> { - render() { - return ( - <div - className={`rmsp-pushbutton ${this.props.enabled ? (this.props.switchedOn ? 'rmsp-togglebutton-switched-on' : 'rmsp-togglebutton-switched-off') : 'rmsp-pushbutton-disabled'}`} - onClick={() => this.props.enabled ? this.props.onClick() : {}} - > - <div className={`${this.props.enabled ? 'rmsp-pushbutton-text' : 'rmsp-pushbutton-text-disabled'}`}>{this.props.caption}</div> - </div> - ); - } -} +const ConformersByClass = { + A: ['AA00_Upr', 'AA00_Lwr', 'AA02_Upr', 'AA02_Lwr', 'AA03_Upr', 'AA03_Lwr', 'AA04_Upr', 'AA04_Lwr', 'AA08_Upr', 'AA08_Lwr', 'AA09_Upr', 'AA09_Lwr', 'AA01_Upr', 'AA01_Lwr', 'AA05_Upr', 'AA05_Lwr', 'AA06_Upr', 'AA06_Lwr', 'AA10_Upr', 'AA10_Lwr', 'AA11_Upr', 'AA11_Lwr', 'AA07_Upr', 'AA07_Lwr', 'AA12_Upr', 'AA12_Lwr', 'AA13_Upr', 'AA13_Lwr', 'AB01_Upr', 'AB02_Upr', 'AB03_Upr', 'AB04_Upr', 'AB05_Upr', 'BA01_Lwr', 'BA05_Lwr', 'BA09_Lwr', 'BA08_Lwr', 'BA10_Lwr', 'BA13_Lwr', 'BA16_Lwr', 'BA17_Lwr', 'AAS1_Lwr', 'AB1S_Upr'], + B: ['AB01_Lwr', 'AB02_Lwr', 'AB03_Lwr', 'AB04_Lwr', 'AB05_Lwr', 'BA09_Upr', 'BA10_Upr', 'BB00_Upr', 'BB00_Lwr', 'BB01_Upr', 'BB01_Lwr', 'BB17_Upr', 'BB17_Lwr', 'BB02_Upr', 'BB02_Lwr', 'BB03_Upr', 'BB03_Lwr', 'BB11_Upr', 'BB11_Lwr', 'BB16_Upr', 'BB16_Lwr', 'BB04_Upr', 'BB05_Upr', 'BB1S_Upr', 'BB2S_Upr', 'BBS1_Lwr'], + BII: ['BA08_Upr', 'BA13_Upr', 'BA16_Upr', 'BA17_Upr', 'BB04_Lwr', 'BB05_Lwr', 'BB07_Upr', 'BB07_Lwr', 'BB08_Upr', 'BB08_Lwr'], + miB: ['BB10_Upr', 'BB10_Lwr', 'BB12_Upr', 'BB12_Lwr', 'BB13_Upr', 'BB13_Lwr', 'BB14_Upr', 'BB14_Lwr', 'BB15_Upr', 'BB15_Lwr', 'BB20_Upr', 'BB20_Lwr'], + IC: ['IC01_Upr', 'IC01_Lwr', 'IC02_Upr', 'IC02_Lwr', 'IC03_Upr', 'IC03_Lwr', 'IC04_Upr', 'IC04_Lwr', 'IC05_Upr', 'IC05_Lwr', 'IC06_Upr', 'IC06_Lwr', 'IC07_Upr', 'IC07_Lwr'], + OPN: ['OP01_Upr', 'OP01_Lwr', 'OP02_Upr', 'OP02_Lwr', 'OP03_Upr', 'OP03_Lwr', 'OP04_Upr', 'OP04_Lwr', 'OP05_Upr', 'OP05_Lwr', 'OP06_Upr', 'OP06_Lwr', 'OP07_Upr', 'OP07_Lwr', 'OP08_Upr', 'OP08_Lwr', 'OP09_Upr', 'OP09_Lwr', 'OP10_Upr', 'OP10_Lwr', 'OP11_Upr', 'OP11_Lwr', 'OP12_Upr', 'OP12_Lwr', 'OP13_Upr', 'OP13_Lwr', 'OP14_Upr', 'OP14_Lwr', 'OP15_Upr', 'OP15_Lwr', 'OP16_Upr', 'OP16_Lwr', 'OP17_Upr', 'OP17_Lwr', 'OP18_Upr', 'OP18_Lwr', 'OP19_Upr', 'OP19_Lwr', 'OP20_Upr', 'OP20_Lwr', 'OP21_Upr', 'OP21_Lwr', 'OP22_Upr', 'OP22_Lwr', 'OP23_Upr', 'OP23_Lwr', 'OP24_Upr', 'OP24_Lwr', 'OP25_Upr', 'OP25_Lwr', 'OP26_Upr', 'OP26_Lwr', 'OP27_Upr', 'OP27_Lwr', 'OP28_Upr', 'OP28_Lwr', 'OP29_Upr', 'OP29_Lwr', 'OP30_Upr', 'OP30_Lwr', 'OP31_Upr', 'OP31_Lwr', 'OPS1_Upr', 'OPS1_Lwr', 'OP1S_Upr', 'OP1S_Lwr'], + SYN: ['AAS1_Upr', 'AB1S_Lwr', 'AB2S_Lwr', 'BB1S_Lwr', 'BB2S_Lwr', 'BBS1_Upr', 'ZZ1S_Lwr', 'ZZ2S_Lwr', 'ZZS1_Upr', 'ZZS2_Upr'], + Z: ['ZZ01_Upr', 'ZZ01_Lwr', 'ZZ02_Upr', 'ZZ02_Lwr', 'ZZ1S_Upr', 'ZZ2S_Upr', 'ZZS1_Lwr', 'ZZS2_Lwr'], + N: ['NANT_Upr', 'NANT_Lwr'], +}; +type ConformersByClass = typeof ConformersByClass; const Display = { representation: 'cartoon', @@ -80,8 +89,15 @@ const Display = { showWater: false, showPyramids: true, + pyramidsTransparent: false, + + showBalls: false, + ballsTransparent: false, modelNumber: 1, + + classColors: { ...NtCColors.Classes }, + conformerColors: { ...NtCColors.Conformers }, }; type Display = typeof Display; @@ -228,23 +244,61 @@ class ReDNATCOMspViewer { return this.plugin.state.data.build().to(IDs.ID(id, sub, ref)); } - private pyramidsParams(colors: Map<string, Color>, visible: Map<string, boolean>, transparent: boolean) { + private getStructureParent(cell: StateObjectCell) { + if (!cell.sourceRef) + return undefined; + const parent = this.plugin.state.data.cells.get(cell.sourceRef); + if (!parent) + return undefined; + return parent.obj?.type.name === 'Structure' ? parent.obj : undefined; + } + + private pyramidsParams(colors: NtCColors.Conformers, visible: Map<string, boolean>, transparent: boolean) { const typeParams = {} as PD.Values<ConfalPyramidsParams>; for (const k of Reflect.ownKeys(ConfalPyramidsParams) as (keyof ConfalPyramidsParams)[]) { if (ConfalPyramidsParams[k].type === 'boolean') (typeParams[k] as any) = visible.get(k) ?? ConfalPyramidsParams[k]['defaultValue']; } - const colorParams = {} as Record<string, Color>; // HAKZ until we implement changeable pyramid colors in Molstar !!! - for (const k of Reflect.ownKeys(ConfalPyramidsColorThemeParams) as (keyof ConfalPyramidsColorThemeParams)[]) - colorParams[k] = colors.get(k) ?? ConfalPyramidsColorThemeParams[k]['defaultValue']; - return { type: { name: 'confal-pyramids', params: { ...typeParams, alpha: transparent ? 0.5 : 1.0 } }, - colorTheme: { name: 'confal-pyramids', params: colorParams } + colorTheme: { name: 'confal-pyramids', params: colors } }; } + private resetCameraRadius() { + if (!this.plugin.canvas3d) + return; + + const spheres = []; + for (const [ref, cell] of Array.from(this.plugin.state.data.cells)) { + if (!IDs.isVisual(ref)) + continue; + const parent = this.getStructureParent(cell); + if (parent) { + const s = Loci.getBoundingSphere(Script.toLoci(SelectAllScript, parent.data)); + if (s) + spheres.push(s); + } + } + + if (spheres.length === 0) + return; + + SphereBoundaryHelper.reset(); + for (const s of spheres) + SphereBoundaryHelper.includePositionRadius(s.center, s.radius); + SphereBoundaryHelper.finishedIncludeStep(); + for (const s of spheres) + SphereBoundaryHelper.radiusPositionRadius(s.center, s.radius); + const bs = SphereBoundaryHelper.getSphere(); + + const snapshot = this.plugin.canvas3d.camera.getSnapshot(); + snapshot.radius = bs.radius; + snapshot.target = bs.center; + PluginCommands.Camera.SetSnapshot(this.plugin, { snapshot, durationMs: AnimationDurationMsec }); + } + static async create(target: HTMLElement) { const defaultSpec = DefaultPluginUISpec(); const spec: PluginUISpec = { @@ -281,6 +335,50 @@ class ReDNATCOMspViewer { return new ReDNATCOMspViewer(plugin); } + async changeNtCColors(display: Partial<Display>) { + if (!this.has('pyramids', 'nucleic')) + return; + + const b = this.plugin.state.data.build().to(IDs.ID('pyramids', 'nucleic', BaseRef)); + b.update( + StateTransforms.Representation.StructureRepresentation3D, + old => ({ + ...old, + colorTheme: { name: 'confal-pyramids', params: display.conformerColors ?? NtCColors.Conformers }, + }) + ); + + await b.commit(); + } + + async changePyramids(display: Partial<Display>) { + if (display.showPyramids) { + if (!this.has('pyramids', 'nucleic')) { + const b = this.getBuilder('structure', 'nucleic'); + if (b) { + b.apply( + StateTransforms.Representation.StructureRepresentation3D, + this.pyramidsParams(display.conformerColors ?? NtCColors.Conformers, new Map(), display.pyramidsTransparent ?? false), + { ref: IDs.ID('pyramids', 'nucleic', BaseRef) } + ); + await b.commit(); + } + } else { + const b = this.getBuilder('pyramids', 'nucleic'); + b.update( + StateTransforms.Representation.StructureRepresentation3D, + old => ({ + ...old, + ...this.pyramidsParams(display.conformerColors ?? NtCColors.Conformers, new Map(), display.pyramidsTransparent ?? false), + }) + ); + await b.commit(); + } + } else { + await PluginCommands.State.RemoveObject(this.plugin, { state: this.plugin.state.data, ref: IDs.ID('pyramids', 'nucleic', BaseRef) }); + } + } + async changeRepresentation(display: Partial<Display>) { const b = this.plugin.state.data.build(); const repr = display.representation ?? 'cartoon'; @@ -301,18 +399,74 @@ class ReDNATCOMspViewer { await b.commit(); } + getModelCount() { + const obj = this.plugin.state.data.cells.get(IDs.ID('trajectory', '', BaseRef))?.obj; + if (!obj) + return 0; + return (obj as StateObject<Trajectory>).data.frameCount; + } + + getPresentConformers() { + const obj = this.plugin.state.data.cells.get(IDs.ID('model', '', BaseRef))?.obj; + if (!obj) + return []; + const model = (obj as StateObject<Model>); + const modelNum = model.data.modelNum; + const sourceData = model.data.sourceData; + if (MmcifFormat.is(sourceData)) { + const tableSum = sourceData.data.frame.categories['ndb_struct_ntc_step_summary']; + const tableStep = sourceData.data.frame.categories['ndb_struct_ntc_step']; + if (!tableSum || !tableStep) { + console.warn('NtC information not present'); + return []; + } + + const _stepIds = tableSum.getField('step_id'); + const _assignedNtCs = tableSum.getField('assigned_NtC'); + const _ids = tableStep.getField('id'); + const _modelNos = tableStep.getField('PDB_model_number'); + if (!_stepIds || !_assignedNtCs || !_ids || !_modelNos) { + console.warn('Expected fields are not present in NtC categories'); + return []; + } + + const stepIds = _stepIds.toIntArray(); + const assignedNtCs = _assignedNtCs.toStringArray(); + const ids = _ids.toIntArray(); + const modelNos = _modelNos.toIntArray(); + + const present = new Array<string>(); + for (let row = 0; row < stepIds.length; row++) { + const idx = ids.indexOf(stepIds[row]); + if (modelNos[idx] === modelNum) { + const ntc = assignedNtCs[row]; + if (!present.includes(ntc)) + present.push(ntc); + } + } + + present.sort(); + return present; + } + return []; + } + has(id: IDs.ID, sub: IDs.Substructure|'' = '', ref = BaseRef) { return !!this.plugin.state.data.cells.get(IDs.ID(id, sub, ref))?.obj; } + isReady() { + return this.has('structure', '', BaseRef); + } + async loadStructure(data: string, type: 'pdb'|'cif', display: Partial<Display>) { await this.plugin.state.data.build().toRoot().commit(); const b = (t => type === 'pdb' - ? t.apply(StateTransforms.Model.TrajectoryFromPDB) - : t.apply(StateTransforms.Data.ParseCif).apply(StateTransforms.Model.TrajectoryFromMmCif) + ? t.apply(StateTransforms.Model.TrajectoryFromPDB, {}, { ref: IDs.ID('trajectory', '', BaseRef) }) + : t.apply(StateTransforms.Data.ParseCif).apply(StateTransforms.Model.TrajectoryFromMmCif, {}, { ref: IDs.ID('trajectory', '', BaseRef) }) )(this.plugin.state.data.build().toRoot().apply(RawData, { data }, { ref: IDs.ID('data', '', BaseRef) })) - .apply(StateTransforms.Model.ModelFromTrajectory, { modelIndex: 0 }) + .apply(StateTransforms.Model.ModelFromTrajectory, { modelIndex: display.modelNumber ? display.modelNumber - 1 : 0 }, { ref: IDs.ID('model', '', BaseRef) }) .apply(StateTransforms.Model.StructureFromModel, {}, { ref: IDs.ID('structure', '', BaseRef) }) // Extract substructures .apply(StateTransforms.Model.StructureComplexElement, { type: 'nucleic' }, { ref: IDs.ID('structure', 'nucleic', BaseRef) }) @@ -338,7 +492,7 @@ class ReDNATCOMspViewer { bb.to(IDs.ID('structure', 'nucleic', BaseRef)) .apply( StateTransforms.Representation.StructureRepresentation3D, - this.pyramidsParams(new Map(), new Map(), false), + this.pyramidsParams(display.conformerColors ?? NtCColors.Conformers, new Map(), false), { ref: IDs.ID('pyramids', 'nucleic', BaseRef) } ); } @@ -367,24 +521,17 @@ class ReDNATCOMspViewer { await bb.commit(); } - isReady() { - return this.has('structure', '', BaseRef); - } + async switchModel(display: Partial<Display>) { + const b = this.getBuilder('model', '', BaseRef); + b.update( + StateTransforms.Model.ModelFromTrajectory, + old => ({ + ...old, + modelIndex: display.modelNumber ? display.modelNumber - 1 : 0 + }) + ); - async togglePyramids(display: Partial<Display>) { - if (display.showPyramids && !this.has('pyramids', 'nucleic')) { - const b = this.getBuilder('structure', 'nucleic'); - if (b) { - b.apply( - StateTransforms.Representation.StructureRepresentation3D, - this.pyramidsParams(new Map(), new Map(), false), - { ref: IDs.ID('pyramids', 'nucleic', BaseRef) } - ); - await b.commit(); - } - } else { - await PluginCommands.State.RemoveObject(this.plugin, { state: this.plugin.state.data, ref: IDs.ID('pyramids', 'nucleic', BaseRef) }); - } + await b.commit(); } async toggleSubstructure(sub: IDs.Substructure, display: Partial<Display>) { @@ -404,28 +551,67 @@ class ReDNATCOMspViewer { ); await b.commit(); } - } else + } else { await PluginCommands.State.RemoveObject(this.plugin, { state: this.plugin.state.data, ref: IDs.ID('visual', sub, BaseRef) }); + this.resetCameraRadius(); + } } } interface State { display: Display; + showControls: boolean; } class ReDNATCOMsp extends React.Component<ReDNATCOMsp.Props, State> { + private presentConformers: string[] = []; private viewer: ReDNATCOMspViewer|null = null; + private classColorToConformers(k: keyof ConformersByClass, color: Color) { + const updated: Partial<NtCColors.Conformers> = {}; + ConformersByClass[k].map(cfmr => updated[cfmr as keyof NtCColors.Conformers] = color); + + return updated; + } + + private updateClassColor(k: keyof NtCColors.Classes, color: number) { + const clr = Color(color); + const classColors = { ...this.state.display.classColors }; + classColors[k] = clr; + + const conformerColors = { + ...this.state.display.conformerColors, + ...this.classColorToConformers(k as keyof ConformersByClass, clr), + }; + + const display = { ...this.state.display, classColors, conformerColors }; + this.viewer!.changeNtCColors(display); + this.setState({ ...this.state, display }); + } + + private updateConformerColor(k: keyof NtCColors.Conformers, color: number) { + const conformerColors = { ...this.state.display.conformerColors }; + conformerColors[k] = Color(color); + + const display = { ...this.state.display, conformerColors }; + this.viewer!.changeNtCColors(display); + this.setState({ ...this.state, display }); + } + constructor(props: ReDNATCOMsp.Props) { super(props); this.state = { display: { ...Display }, + showControls: false, }; } loadStructure(data: string, type: 'pdb'|'cif') { if (this.viewer) - this.viewer.loadStructure(data, type, this.state.display).then(() => this.forceUpdate()); + this.viewer.loadStructure(data, type, this.state.display).then(() => { + this.presentConformers = this.viewer!.getPresentConformers(); + this.forceUpdate(); + }); } componentDidMount() { @@ -452,102 +638,211 @@ class ReDNATCOMsp extends React.Component<ReDNATCOMsp.Props, State> { <div className='rmsp-app'> <div id={this.props.elemId + '-viewer'} className='rmsp-viewer'></div> <div> - <div>Display and control</div> - <div className='rmsp-controls'> - <div className='rmsp-controls-section-caption'>Representation</div> - <div className='rmsp-controls-line'> - <div className='rmsp-control-item'> - <PushButton - caption={capitalize(this.state.display.representation)} - enabled={ready} - onClick={() => { - const display = { - ...this.state.display, - representation: this.state.display.representation === 'cartoon' ? 'ball-and-stick' : 'cartoon', - }; - this.viewer!.changeRepresentation(display); - this.setState({ ...this.state, display }); - }} - /> + <div + onClick={() => this.setState({ ...this.state, showControls: !this.state.showControls })} + > + Display and control + </div> + {this.state.showControls ? + <div className='rmsp-controls'> + <div className='rmsp-controls-section-caption'>Representation</div> + <div className='rmsp-controls-line'> + <div className='rmsp-control-item'> + <PushButton + text={capitalize(this.state.display.representation)} + enabled={ready} + onClick={() => { + const display = { + ...this.state.display, + representation: this.state.display.representation === 'cartoon' ? 'ball-and-stick' : 'cartoon', + }; + this.viewer!.changeRepresentation(display); + this.setState({ ...this.state, display }); + }} + /> + </div> </div> - </div> - <div className='rmsp-controls-section-caption'>Substructure parts</div> - <div className='rmsp-controls-line'> - <div className='rmsp-control-item'> - <ToggleButton - caption='Nucleic' - enabled={hasNucleic} - switchedOn={this.state.display.showNucleic} - onClick={() => { - const display = { - ...this.state.display, - showNucleic: !this.state.display.showNucleic, - }; - this.viewer!.toggleSubstructure('nucleic', display); - this.setState({ ...this.state, display }); - }} - /> - </div> - <div className='rmsp-control-item'> - <ToggleButton - caption='Protein' - enabled={hasProtein} - switchedOn={this.state.display.showProtein} - onClick={() => { - const display = { - ...this.state.display, - showProtein: !this.state.display.showProtein, - }; - this.viewer!.toggleSubstructure('protein', display); - this.setState({ ...this.state, display }); - }} - /> + <div className='rmsp-controls-section-caption'>Substructure parts</div> + <div className='rmsp-controls-line'> + <div className='rmsp-control-item'> + <ToggleButton + text='Nucleic' + enabled={hasNucleic} + switchedOn={this.state.display.showNucleic} + onClick={() => { + const display = { + ...this.state.display, + showNucleic: !this.state.display.showNucleic, + }; + this.viewer!.toggleSubstructure('nucleic', display); + this.setState({ ...this.state, display }); + }} + /> + </div> + <div className='rmsp-control-item'> + <ToggleButton + text='Protein' + enabled={hasProtein} + switchedOn={this.state.display.showProtein} + onClick={() => { + const display = { + ...this.state.display, + showProtein: !this.state.display.showProtein, + }; + this.viewer!.toggleSubstructure('protein', display); + this.setState({ ...this.state, display }); + }} + /> + </div> + <div className='rmsp-control-item'> + <ToggleButton + text='Water' + enabled={hasWater} + switchedOn={this.state.display.showWater} + onClick={() => { + const display = { + ...this.state.display, + showWater: !this.state.display.showWater, + }; + this.viewer!.toggleSubstructure('water', display); + this.setState({ ...this.state, display }); + }} + /> + </div> </div> - <div className='rmsp-control-item'> - <ToggleButton - caption='Water' - enabled={hasWater} - switchedOn={this.state.display.showWater} - onClick={() => { - const display = { - ...this.state.display, - showWater: !this.state.display.showWater, - }; - this.viewer!.toggleSubstructure('water', display); - this.setState({ ...this.state, display }); - }} - /> + + <div className='rmsp-controls-section-caption'>NtC visuals</div> + <div className='rmsp-controls-line'> + <div className='rmsp-control-item-group'> + <div className='rmsp-control-item'> + <ToggleButton + text='Pyramids' + enabled={ready} + switchedOn={this.state.display.showPyramids} + onClick={() => { + const display = { + ...this.state.display, + showPyramids: !this.state.display.showPyramids, + }; + this.viewer!.changePyramids(display); + this.setState({ ...this.state, display }); + }} + /> + </div> + <div className='rmsp-control-item'> + <PushButton + text={this.state.display.pyramidsTransparent ? 'Transparent' : 'Solid'} + enabled={this.state.display.showPyramids} + onClick={() => { + const display = { + ...this.state.display, + pyramidsTransparent: !this.state.display.pyramidsTransparent, + }; + this.viewer!.changePyramids(display); + this.setState({ ...this.state, display }); + }} + /> + </div> + </div> + <div className='rmsp-control-item-group'> + <div className='rmsp-control-item'> + <ToggleButton + text='Balls' + enabled={false} + switchedOn={false} + onClick={() => {}} + /> + </div> + <div className='rmsp-control-item'> + <PushButton + text={this.state.display.ballsTransparent ? 'Transparent' : 'Solid'} + enabled={this.state.display.showBalls} + onClick={() => { + const display = { + ...this.state.display, + ballsTransparent: !this.state.display.ballsTransparent, + }; + /* No balls today... */ + this.setState({ ...this.state, display }); + }} + /> + </div> + </div> </div> - </div> - <div className='rmsp-controls-section-caption'>NtC visuals</div> - <div className='rmsp-controls-line'> - <div className='rmsp-control-item'> - <ToggleButton - caption='Pyramids' - enabled={ready} - switchedOn={this.state.display.showPyramids} - onClick={() => { - const display = { - ...this.state.display, - pyramidsShown: !this.state.display.showPyramids, - }; - this.viewer!.togglePyramids(display); - this.setState({ ...this.state, display }); - }} - /> + <div className='rmsp-controls-section-caption'>NtC classes colors</div> + <div className='rmsp-controls-line'> + {(['A', 'B', 'BII', 'miB', 'Z', 'IC', 'OPN', 'SYN', 'N'] as (keyof NtCColors.Classes)[]).map(k => + <div className='rmsp-control-item-group' key={k}> + <div + className='rmsp-control-item' + onClick={evt => ColorPicker.create( + evt, + this.state.display.classColors[k], + color => this.updateClassColor(k, color) + )} + > + <ColorBox caption={k} color={this.state.display.classColors[k]} /> + </div> + <PushButton + text='R' + onClick={() => this.updateClassColor(k, NtCColors.Classes[k])} + enabled={true} + /> + </div> + )} </div> - <div className='rmsp-control-item'> - <ToggleButton - caption='Balls' - enabled={false} - switchedOn={false} - onClick={() => {}} - /> + + <div className='rmsp-controls-section-caption'>NtC colors</div> + <div className='rmsp-controls-line'> + {this.presentConformers.map(ntc => { + const uprKey = ntc + '_Upr' as keyof NtCColors.Conformers; + const lwrKey = ntc + '_Lwr' as keyof NtCColors.Conformers; + + return ( + <div className='rmsp-control-item' key={ntc}> + <div className='rmsp-control-item-group'> + <div + className='rmsp-control-item' + onClick={evt => ColorPicker.create( + evt, + this.state.display.conformerColors[uprKey], + color => this.updateConformerColor(uprKey, color) + )} + > + <ColorBox caption={`${ntc} Upr`} color={this.state.display.conformerColors[uprKey]} /> + </div> + <PushButton + text='R' + onClick={() => this.updateConformerColor(uprKey, NtCColors.Conformers[uprKey])} + enabled={true} + /> + </div> + <div className='rmsp-control-item-group'> + <div + className='rmsp-control-item' + onClick={evt => ColorPicker.create( + evt, + this.state.display.conformerColors[lwrKey], + color => this.updateConformerColor(lwrKey, color) + )} + > + <ColorBox caption={`${ntc} Lwr`} color={this.state.display.conformerColors[lwrKey]} /> + </div> + <PushButton + text='R' + onClick={() => this.updateConformerColor(lwrKey, NtCColors.Conformers[lwrKey])} + enabled={true} + /> + </div> + </div> + ); + })} </div> </div> - </div> + : undefined + } </div> </div> ); diff --git a/src/apps/rednatco/rednatco-molstar.css b/src/apps/rednatco/rednatco-molstar.css index 56e69831bfe95d95a1fd0091ad3cb8aa1b9480f7..8b25615cbc29c7b60af92a19fb8cc09528203f36 100644 --- a/src/apps/rednatco/rednatco-molstar.css +++ b/src/apps/rednatco/rednatco-molstar.css @@ -28,7 +28,7 @@ .rmsp-app { display: flex; flex-direction: column; - height: 90%; + height: 100%; width: 100%; font-family: Verdana, sans-serif; @@ -36,6 +36,20 @@ line-height: 1.5; } +.rmsp-color-box { + display: flex; +} + +.rmsp-color-box-color { + flex: 1; +} + +.rmsp-color-picker-nest { + font-family: Verdana, sans-serif; + font-size: 12pt; + line-height: 1.5; +} + .rmsp-controls { display: grid; grid-template-columns: auto 1fr; @@ -43,6 +57,12 @@ overflow: scroll; } +.rmsp-control-item-group { + display: flex; + flex: 1; + gap: 0; +} + .rmsp-control-item { flex: 1; } @@ -90,6 +110,41 @@ margin: 0.15em; } +.rmsp-spinbox-container { + background-color: white; + border: 0.15em solid #ccc; + border-radius: 0; + display: grid; + grid-template-columns: auto 1fr; +} + +.rmsp-spinbox-button { + height: 0.6em; + text-align: center; +} +.rmsp-spinbox-button:hover { + background-color: #aaa; +} + +.rmsp-spinbox-buttons { + display: flex; + flex-direction: column; + gap: 0.05em; + margin: 0.1em; +} + +.rmsp-spinbox-input { + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + border: none; + + width: 100%; +} +.rmsp-spinbox-input:focus { + outline: none; +} + .rmsp-viewer { flex: 1; position: relative;