diff --git a/src/mol-geo/geometry/direct-volume/direct-volume.ts b/src/mol-geo/geometry/direct-volume/direct-volume.ts index 7e6b9f54b7c4f7b13199a6053301bfc8445142ab..443b4c8b32019a121bf2e22b76738d3fbb93461a 100644 --- a/src/mol-geo/geometry/direct-volume/direct-volume.ts +++ b/src/mol-geo/geometry/direct-volume/direct-volume.ts @@ -8,9 +8,9 @@ import { ValueCell } from 'mol-util' import { Sphere3D, Box3D } from 'mol-math/geometry' import { ParamDefinition as PD } from 'mol-util/param-definition'; import { DirectVolumeValues } from 'mol-gl/renderable/direct-volume'; -import { Vec3, Mat4 } from 'mol-math/linear-algebra'; +import { Vec3, Mat4, Vec2 } from 'mol-math/linear-algebra'; import { Box } from '../../primitive/box'; -import { getControlPointsFromString, createTransferFunctionTexture } from './transfer-function'; +import { createTransferFunctionTexture, getControlPointsFromVec2Array } from './transfer-function'; import { Texture } from 'mol-gl/webgl/texture'; import { LocationIterator } from 'mol-geo/util/location-iterator'; import { TransformData } from '../transform-data'; @@ -72,7 +72,7 @@ export namespace DirectVolume { ...Geometry.Params, isoValue: PD.Numeric(0.22, { min: -1, max: 1, step: 0.01 }), renderMode: PD.Select('isosurface', RenderModeOptions), - controlPoints: PD.Text('0.19:0.1, 0.2:0.5, 0.21:0.1, 0.4:0.3'), + controlPoints: PD.LineGraph([Vec2.create(0.19, 0.1), Vec2.create(0.2, 0.5), Vec2.create(0.21, 0.1), Vec2.create(0.4, 0.3)]), } export type Params = typeof Params @@ -93,7 +93,7 @@ export namespace DirectVolume { transform.aTransform.ref.value, transform.instanceCount.ref.value ) - const controlPoints = getControlPointsFromString(props.controlPoints) + const controlPoints = getControlPointsFromVec2Array(props.controlPoints) const transferTex = createTransferFunctionTexture(controlPoints) const maxSteps = Math.ceil(Vec3.magnitude(gridDimension.ref.value)) * 2 @@ -130,7 +130,7 @@ export namespace DirectVolume { ValueCell.updateIfChanged(values.dUseFog, props.useFog) ValueCell.updateIfChanged(values.dRenderMode, props.renderMode) - const controlPoints = getControlPointsFromString(props.controlPoints) + const controlPoints = getControlPointsFromVec2Array(props.controlPoints) createTransferFunctionTexture(controlPoints, values.tTransferTex) } diff --git a/src/mol-geo/geometry/direct-volume/transfer-function.ts b/src/mol-geo/geometry/direct-volume/transfer-function.ts index e93515f9c3e814445f6701078fb4dddcb13a83e2..8c34542df4d2cf1d561f96daa35637f7b312b9cd 100644 --- a/src/mol-geo/geometry/direct-volume/transfer-function.ts +++ b/src/mol-geo/geometry/direct-volume/transfer-function.ts @@ -9,6 +9,7 @@ import { spline } from 'mol-math/interpolate'; import { ColorScale } from 'mol-util/color'; import { ColorMatplotlib } from 'mol-util/color/tables'; import { ValueCell } from 'mol-util'; +import { Vec2 } from 'mol-math/linear-algebra'; export interface ControlPoint { x: number, alpha: number } @@ -18,6 +19,11 @@ export function getControlPointsFromString(s: string): ControlPoint[] { return { x: parseFloat(ps[0]), alpha: parseFloat(ps[1]) } }) } + +export function getControlPointsFromVec2Array(array: Vec2[]): ControlPoint[] { + return array.map(v => ({ x: v[0], alpha: v[1] })) +} + // TODO move core function to mol-canvas3d/color export function createTransferFunctionTexture(controlPoints: ControlPoint[], texture?: ValueCell<TextureImage<Uint8Array>>): ValueCell<TextureImage<Uint8Array>> { const cp = [ diff --git a/src/mol-plugin/skin/base/components/line-graph.scss b/src/mol-plugin/skin/base/components/line-graph.scss new file mode 100644 index 0000000000000000000000000000000000000000..45ff0a757b6d6ea37e4b7ecaf7e9d0c111a2fb6c --- /dev/null +++ b/src/mol-plugin/skin/base/components/line-graph.scss @@ -0,0 +1,64 @@ +.msp-canvas { + width: 100%; + height: 100%; + background-color: #f3f2ee; + + text { + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently + supported by Chrome and Opera */ + } + + circle { + stroke: black; + stroke-width: 10; + stroke-opacity: .3; + + &:hover { + fill: #ae5d04; + stroke: black; + stroke-width: 10px; + } + } + + .info { + fill: white; + stroke: black; + stroke-width: 3; + } + + .show { + visibility: visible; + } + .hide { + visibility: hidden; + } + + .delete-button { + rect { + fill: #ED4337; + stroke: black; + } + + text { + stroke: white; + fill: white; + } + + &:hover { + stroke: black; + stroke-width: 3; + fill: #ff6961; + } + } + + .infoCircle { + &:hover { + fill: #4c66b2; + } + } +} \ No newline at end of file diff --git a/src/mol-plugin/skin/base/ui.scss b/src/mol-plugin/skin/base/ui.scss index c8402c6d308722b4b11fa42eceecad2ecfc1a1c4..7610eb8782137e39a2d45cdf5767724eddfcc5d7 100644 --- a/src/mol-plugin/skin/base/ui.scss +++ b/src/mol-plugin/skin/base/ui.scss @@ -40,4 +40,5 @@ @import 'components/transformer'; @import 'components/toast'; @import 'components/help'; -@import 'components/temp'; \ No newline at end of file +@import 'components/temp'; +@import 'components/line-graph.scss'; \ No newline at end of file diff --git a/src/mol-plugin/ui/controls/line-graph/line-graph-component.tsx b/src/mol-plugin/ui/controls/line-graph/line-graph-component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..83ece28437dfda8085797fbf584249178eec329f --- /dev/null +++ b/src/mol-plugin/ui/controls/line-graph/line-graph-component.tsx @@ -0,0 +1,354 @@ +/** + * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Paul Luna <paulluna0215@gmail.com> + */ +import PointComponent from './point-component'; + +import * as React from 'react'; +import { Vec2 } from 'mol-math/linear-algebra'; + +interface LineGraphComponentState { + points: Vec2[], + selected?: number, + copyPoint: any, + updatedX: number, + updatedY: number, +} + +export default class LineGraphComponent extends React.Component<any, LineGraphComponentState> { + private myRef:any; + private height: number; + private width: number; + private padding: number; + + constructor(props: any) { + super(props); + this.myRef = React.createRef(); + this.state = { + points:[ + Vec2.create(0, 0), + Vec2.create(1, 0) + ], + selected: undefined, + copyPoint: undefined, + updatedX: 0, + updatedY: 0, + }; + this.height = 400; + this.width = 600; + this.padding = 70; + + for (const point of this.props.data){ + this.state.points.push(point); + } + + this.state.points.sort((a, b) => { + if(a[0] === b[0]){ + if(a[0] === 0){ + return a[1]-b[1]; + } + if(a[1] === 1){ + return b[1]-a[1]; + } + return a[1]-b[1]; + } + return a[0] - b[0]; + }); + + this.handleDrag = this.handleDrag.bind(this); + this.handleDoubleClick = this.handleDoubleClick.bind(this); + this.refCallBack = this.refCallBack.bind(this); + this.handlePointUpdate = this.handlePointUpdate.bind(this); + this.change = this.change.bind(this); + } + + public render() { + const points = this.renderPoints(); + const ghostPoint = this.state.copyPoint; + return ([ + <div key="LineGraph"> + <svg + className="msp-canvas" + ref={this.refCallBack} + viewBox={`0 0 ${this.width+this.padding} ${this.height+this.padding}`} + onMouseMove={this.handleDrag} + onMouseUp={this.handlePointUpdate} + onDoubleClick={this.handleDoubleClick}> + + <g stroke="black" fill="black"> + <Poly + data={this.state.points} + k={0.5} + height={this.height} + width={this.width} + padding={this.padding}/> + {points} + {ghostPoint} + </g> + + <defs> + <linearGradient id="Gradient"> + <stop offset="0%" stopColor="#d30000"/> + <stop offset="30%" stopColor="#ffff05"/> + <stop offset="50%" stopColor="#05ff05"/> + <stop offset="70%" stopColor="#05ffff"/> + <stop offset="100%" stopColor="#041ae0"/> + </linearGradient> + </defs> + + </svg> + </div>, + <div key="modal" id="modal-root" /> + ]); + } + + private change(points: Vec2[]){ + let copyPoints = points.slice(); + copyPoints.shift(); + copyPoints.pop(); + this.props.onChange(copyPoints); + } + + private handleMouseDown = (id:number) => (event: any) => { + if(id === 0 || id === this.state.points.length-1){ + return; + } + const copyPoint: Vec2 = this.normalizePoint(Vec2.create(this.state.points[id][0], this.state.points[id][1])); + this.setState({ + selected: id, + copyPoint: "ready", + updatedX: copyPoint[0], + updatedY: copyPoint[1], + }); + + event.preventDefault(); + } + + private handleDrag(event: any) { + if(this.state.copyPoint === undefined){ + return + } + const pt = this.myRef.createSVGPoint(); + let updatedCopyPoint; + const padding = this.padding/2; + pt.x = event.clientX; + pt.y = event.clientY; + const svgP = pt.matrixTransform(this.myRef.getScreenCTM().inverse()); + + if( svgP.x < (padding) || + svgP.x > (this.width+(padding)) || + svgP.y > (this.height+(padding)) || + svgP.y < (padding)) { + return; + } + updatedCopyPoint = Vec2.create(svgP.x, svgP.y); + this.setState({ + updatedX: updatedCopyPoint[0], + updatedY: updatedCopyPoint[1], + }); + const unNormalizePoint = this.unNormalizePoint(updatedCopyPoint); + this.setState({ + copyPoint: <PointComponent + selected={false} + key="copy" + x={updatedCopyPoint[0]} + y={updatedCopyPoint[1]} + nX={unNormalizePoint[0]} + nY={unNormalizePoint[1]} + delete={this.deletePoint} + onmouseover={this.props.onHover}/> + }); + this.props.onDrag(unNormalizePoint); + event.preventDefault() + } + + private handlePointUpdate(event: any) { + const selected = this.state.selected; + if(selected === undefined || selected === 0 || selected === this.state.points.length-1) { + this.setState({ + selected: undefined, + copyPoint: undefined, + }); + return + } + const updatedPoint = this.unNormalizePoint(Vec2.create(this.state.updatedX, this.state.updatedY)); + const points = this.state.points.filter((_,i) => i !== this.state.selected); + points.push(updatedPoint);; + points.sort((a, b) => { + if(a[0] === b[0]){ + if(a[0] === 0){ + return a[1]-b[1]; + } + if(a[1] === 1){ + return b[1]-a[1]; + } + return a[1]-b[1]; + } + return a[0] - b[0]; + }); + this.setState({ + points, + selected: undefined, + copyPoint: undefined, + }); + this.change(points); + event.preventDefault(); + } + + private handleDoubleClick(event: any) { + let newPoint; + const pt = this.myRef.createSVGPoint(); + pt.x = event.clientX; + pt.y = event.clientY; + const svgP = pt.matrixTransform(this.myRef.getScreenCTM().inverse()); + const points = this.state.points; + const padding = this.padding/2; + + if( svgP.x < (padding) || + svgP.x > (this.width+(padding)) || + svgP.y > (this.height+(padding)) || + svgP.y < (this.padding/2)) { + return; + } + newPoint = this.unNormalizePoint(Vec2.create(svgP.x, svgP.y)); + points.push(newPoint); + points.sort((a, b) => { + if(a[0] === b[0]){ + if(a[0] === 0){ + return a[1]-b[1]; + } + if(a[1] === 1){ + return b[1]-a[1]; + } + return a[1]-b[1]; + } + return a[0] - b[0]; + }); + this.setState({points}) + this.change(points); + event.preventDefault(); + } + private deletePoint = (i:number) => (event: any) => { + if(i===0 || i===this.state.points.length-1){ return}; + const points = this.state.points.filter((_,j) => j !== i); + points.sort((a, b) => { + if(a[0] === b[0]){ + if(a[0] === 0){ + return a[1]-b[1]; + } + if(a[1] === 1){ + return b[1]-a[1]; + } + return a[1]-b[1]; + } + return a[0] - b[0]; + }); + this.setState({points}); + this.change(points); + event.stopPropagation(); + } + + private normalizePoint(point: Vec2) { + const min = this.padding/2; + const maxX = this.width+min; + const maxY = this.height+min; + const normalizedX = (point[0]*(maxX-min))+min; + const normalizedY = (point[1]*(maxY-min))+min; + const reverseY = (this.height+this.padding)-normalizedY; + const newPoint = Vec2.create(normalizedX, reverseY); + return newPoint; + } + + private unNormalizePoint(point: Vec2) { + const min = this.padding/2; + const maxX = this.width+min; + const maxY = this.height+min; + const unNormalizedX = (point[0]-min)/(maxX-min); + + // we have to take into account that we reversed y when we first normalized it. + const unNormalizedY = ((this.height+this.padding)-point[1]-min)/(maxY-min); + + return Vec2.create(unNormalizedX, unNormalizedY); + } + + private refCallBack(element: any) { + if(element){ + this.myRef = element; + } + } + + private renderPoints() { + const points: any[] = []; + let point: Vec2; + for (let i = 0; i < this.state.points.length; i++){ + if(i != 0 && i != this.state.points.length-1){ + point = this.normalizePoint(this.state.points[i]); + points.push(<PointComponent + key={i} + id={i} + x={point[0]} + y={point[1]} + nX={this.state.points[i][0]} + nY={this.state.points[i][1]} + selected={false} + delete={this.deletePoint} + onmouseover={this.props.onHover} + onMouseDown={this.handleMouseDown(i)} + />); + } + } + return points; + } +} + +function Poly(props: any) { + + const points: Vec2[] = []; + let min:number; + let maxX:number; + let maxY: number; + let normalizedX: number; + let normalizedY: number; + let reverseY: number; + + for(const point of props.data){ + min = parseInt(props.padding, 10)/2; + maxX = parseInt(props.width, 10)+min; + maxY = parseInt(props.height, 10)+min; + normalizedX = (point[0]*(maxX-min))+min; + normalizedY = (point[1]*(maxY-min))+min; + reverseY = (props.height+props.padding)-normalizedY; + points.push(Vec2.create(normalizedX, reverseY)); + } + + if (props.k == null) {props.k = 0.3}; + const data = points; + const size = data.length; + const last = size - 2; + let path = "M" + [data[0][0], data[0][1]]; + + for (let i=0; i<size-1;i++){ + const x0 = i ? data[i-1][0] : data[0][0]; + const y0 = i ? data[i-1][1] : data[0][1]; + + const x1 = data[i][0]; + const y1 = data[i][1]; + + const x2 = data[i+1][0]; + const y2 = data[i+1][1]; + + const x3 = i !== last ? data[i+2][0] : x2; + const y3 = i !== last ? data[i+2][1] : y2; + + const cp1x = x1 + (x2 - x0)/6 * props.k; + const cp1y = y1 + (y2 -y0)/6 * props.k; + + const cp2x = x2 - (x3 -x1)/6 * props.k; + const cp2y = y2 - (y3 - y1)/6 * props.k; + + path += "C" + [cp1x, cp1y, cp2x, cp2y, x2, y2]; + } + + return <path d={path} strokeWidth="5" stroke="#cec9ba" fill="none"/> +} \ No newline at end of file diff --git a/src/mol-plugin/ui/controls/line-graph/point-component.tsx b/src/mol-plugin/ui/controls/line-graph/point-component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e5a78c6e2b04acb58e397de1ac3d22ba65419832 --- /dev/null +++ b/src/mol-plugin/ui/controls/line-graph/point-component.tsx @@ -0,0 +1,47 @@ + +import * as React from 'react'; + +import { Vec2 } from 'mol-math/linear-algebra'; + +export default class PointComponent extends React.Component<any, {show: boolean}> { + constructor(props: any){ + super(props); + this.state = {show: false} + + this.handleHover = this.handleHover.bind(this); + this.handleHoverOff = this.handleHoverOff.bind(this); + this.deletePoint = this.deletePoint.bind(this); + } + + private handleHover() { + this.setState({show: true}); + const point = Vec2.create(this.props.nX, this.props.nY); + this.props.onmouseover(point); + } + + private handleHoverOff(){ + this.setState({show: false}); + this.props.onmouseover(undefined); + } + + private deletePoint() { + this.props.delete(this.props.id); + } + + public render() { + return([ + <circle + r="10" + key={`${this.props.id}circle`} + id={`${this.props.id}`} + cx={this.props.x} + cy={this.props.y} + onDoubleClick={this.props.delete(this.props.id)} + onMouseEnter={this.handleHover} + onMouseLeave={this.handleHoverOff} + onMouseDown={this.props.onMouseDown} + fill="black" + /> + ]); + } +} \ No newline at end of file diff --git a/src/mol-plugin/ui/controls/parameters.tsx b/src/mol-plugin/ui/controls/parameters.tsx index 11a06d873281951badac21054cdd659d6f8dc8a9..3d5fda213e07b3e636f84154152a713d3867ed86 100644 --- a/src/mol-plugin/ui/controls/parameters.tsx +++ b/src/mol-plugin/ui/controls/parameters.tsx @@ -6,12 +6,17 @@ */ import * as React from 'react' + import { ParamDefinition as PD } from 'mol-util/param-definition'; import { camelCaseToWords } from 'mol-util/string'; import { ColorNames, ColorNamesValueMap } from 'mol-util/color/tables'; import { Color } from 'mol-util/color'; +import { Vec2 } from 'mol-math/linear-algebra'; +import LineGraphComponent from './line-graph/line-graph-component'; + import { Slider, Slider2 } from './slider'; + export interface ParameterControlsProps<P extends PD.Params = PD.Params> { params: P, values: any, @@ -53,7 +58,7 @@ function controlFor(param: PD.Any): ParamControl | undefined { ? BoundedIntervalControl : IntervalControl; case 'group': return GroupControl; case 'mapped': return MappedControl; - case 'line-graph': return void 0; + case 'line-graph': return LineGraphControl; } console.warn(`${(param as any).type} has no associated UI component.`); return void 0; @@ -93,6 +98,57 @@ export class BoolControl extends SimpleParam<PD.Boolean> { } } +export class LineGraphControl extends React.PureComponent<ParamProps<PD.LineGraph>, { isExpanded: boolean, isOverPoint: boolean, message: string }> { + state = { + isExpanded: false, + isOverPoint: false, + message: `${this.props.param.defaultValue.length} points`, + } + + onHover = (point?: Vec2) => { + this.setState({isOverPoint: !this.state.isOverPoint}); + if(point){ + this.setState({message: `(${point[0].toFixed(2)}, ${point[1].toFixed(2)})`}); + return; + } + this.setState({message: `${this.props.value.length} points`}); + } + + onDrag = (point: Vec2) => { + this.setState({message: `(${point[0].toFixed(2)}, ${point[1].toFixed(2)})`}); + } + + onChange = (value: PD.LineGraph['defaultValue'] ) => { + this.props.onChange({ name: this.props.name, param: this.props.param, value: value}); + } + + toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => { + this.setState({ isExpanded: !this.state.isExpanded }); + e.currentTarget.blur(); + } + + render() { + const label = this.props.param.label || camelCaseToWords(this.props.name); + return <> + <div className='msp-control-row'> + <span>{label}</span> + <div> + <button onClick={this.toggleExpanded}> + {`${this.state.message}`} + </button> + </div> + </div> + <div className='msp-control-offset' style={{ display: this.state.isExpanded ? 'block' : 'none' }}> + <LineGraphComponent + data={this.props.param.defaultValue} + onChange={this.onChange} + onHover={this.onHover} + onDrag={this.onDrag}/> + </div> + </>; + } +} + export class NumberInputControl extends SimpleParam<PD.Numeric> { onChange = (e: React.ChangeEvent<HTMLInputElement>) => { this.update(+e.target.value); } renderControl() { diff --git a/src/mol-plugin/ui/state.tsx b/src/mol-plugin/ui/state.tsx index e6b11dcfc397c91fa2599ce269a5ef716f4cd70d..a30896fb1fc4c12d4b5cf9736ab68324724cb0ae 100644 --- a/src/mol-plugin/ui/state.tsx +++ b/src/mol-plugin/ui/state.tsx @@ -9,8 +9,8 @@ import * as React from 'react'; import { PluginComponent } from './base'; import { shallowEqual } from 'mol-util'; import { List } from 'immutable'; -import { ParamDefinition as PD } from 'mol-util/param-definition'; import { ParameterControls } from './controls/parameters'; +import { ParamDefinition as PD} from 'mol-util/param-definition'; import { Subject } from 'rxjs'; export class StateSnapshots extends PluginComponent<{ }, { serverUrl: string }> {