diff --git a/src/mol-geo/geometry/direct-volume/direct-volume.ts b/src/mol-geo/geometry/direct-volume/direct-volume.ts index aa18b974b36a80e7962e514b2db55feb8fe7c534..ec945d68b86c5d789a1e2d9e53f7a3bbb15b2fa9 100644 --- a/src/mol-geo/geometry/direct-volume/direct-volume.ts +++ b/src/mol-geo/geometry/direct-volume/direct-volume.ts @@ -27,6 +27,7 @@ import { createEmptyTransparency } from '../transparency-data'; import { createTransferFunctionTexture, getControlPointsFromVec2Array } from './transfer-function'; import { createEmptyClipping } from '../clipping-data'; import { Grid, Volume } from '../../../mol-model/volume'; +import { ColorNames } from '../../../mol-util/color/names'; const VolumeBox = Box(); @@ -139,7 +140,16 @@ export namespace DirectVolume { Vec2.create(0.19, 0.0), Vec2.create(0.2, 0.05), Vec2.create(0.25, 0.05), Vec2.create(0.26, 0.0), Vec2.create(0.79, 0.0), Vec2.create(0.8, 0.05), Vec2.create(0.85, 0.05), Vec2.create(0.86, 0.0), ]), - list: PD.ColorList('red-yellow-blue'), + list: PD.ColorList({ + kind: 'interpolate', + colors: [ + [ColorNames.white, 0], + [ColorNames.red, 0.25], + [ColorNames.white, 0.5], + [ColorNames.blue, 0.75], + [ColorNames.white, 1] + ] + }, { offsets: true }), }, { isFlat: true }) }, { isEssential: true }); } diff --git a/src/mol-geo/geometry/direct-volume/transfer-function.ts b/src/mol-geo/geometry/direct-volume/transfer-function.ts index c65e1067be63c80344ac5b6864b4c526b33f34d3..c89fc5cef1e1a36ef426de82be7d56066d6d8028 100644 --- a/src/mol-geo/geometry/direct-volume/transfer-function.ts +++ b/src/mol-geo/geometry/direct-volume/transfer-function.ts @@ -6,10 +6,11 @@ import { TextureImage } from '../../../mol-gl/renderable/util'; import { spline } from '../../../mol-math/interpolate'; -import { ColorScale, Color } from '../../../mol-util/color'; +import { ColorScale } from '../../../mol-util/color'; import { ValueCell } from '../../../mol-util'; import { Vec2 } from '../../../mol-math/linear-algebra'; import { ColorListName } from '../../../mol-util/color/lists'; +import { ColorListEntry } from '../../../mol-util/color/color'; export interface ControlPoint { x: number, alpha: number } @@ -24,7 +25,7 @@ export function getControlPointsFromVec2Array(array: Vec2[]): ControlPoint[] { return array.map(v => ({ x: v[0], alpha: v[1] })); } -export function createTransferFunctionTexture(controlPoints: ControlPoint[], listOrName: Color[] | ColorListName, texture?: ValueCell<TextureImage<Uint8Array>>): ValueCell<TextureImage<Uint8Array>> { +export function createTransferFunctionTexture(controlPoints: ControlPoint[], listOrName: ColorListEntry[] | ColorListName, texture?: ValueCell<TextureImage<Uint8Array>>): ValueCell<TextureImage<Uint8Array>> { const cp = [ { x: 0, alpha: 0 }, { x: 0, alpha: 0 }, diff --git a/src/mol-plugin-ui/controls/legend.tsx b/src/mol-plugin-ui/controls/legend.tsx index a758f167b8083f8d76e255a9c8165f2bf3fbb146..a61cb8f21e4d3b801b4bfa4f3748a33037b877a6 100644 --- a/src/mol-plugin-ui/controls/legend.tsx +++ b/src/mol-plugin-ui/controls/legend.tsx @@ -26,7 +26,7 @@ export function legendFor(legend: LegendData): Legend | undefined { export class ScaleLegend extends React.PureComponent<LegendProps<ScaleLegendData>> { render() { const { legend } = this.props; - const colors = legend.colors.map(c => Color.toStyle(c)).join(', '); + const colors = legend.colors.map(c => Array.isArray(c) ? `${Color.toStyle(c[0])} ${100 * c[1]}%` : Color.toStyle(c)).join(', '); return <div className='msp-scale-legend'> <div style={{ background: `linear-gradient(to right, ${colors})` }}> <span style={{float: 'left'}}>{legend.minLabel}</span> diff --git a/src/mol-plugin-ui/controls/parameters.tsx b/src/mol-plugin-ui/controls/parameters.tsx index 1fd2e31cda2b861e78280fddd08ddfc36023f0d1..3afcb0ae0cd69bed3196f9dd0e8a5e213efb1224 100644 --- a/src/mol-plugin-ui/controls/parameters.tsx +++ b/src/mol-plugin-ui/controls/parameters.tsx @@ -25,6 +25,7 @@ import { legendFor } from './legend'; import LineGraphComponent from './line-graph/line-graph-component'; import { Slider, Slider2 } from './slider'; import { Asset } from '../../mol-util/assets'; +import { ColorListEntry } from '../../mol-util/color/color'; export type ParameterControlsCategoryFilter = string | null | (string | null)[] @@ -177,7 +178,7 @@ function controlFor(param: PD.Any): ParamControl | undefined { case 'conditioned': return ConditionedControl; case 'multi-select': return MultiSelectControl; case 'color': return CombinedColorControl; - case 'color-list': return ColorListControl; + case 'color-list': return param.offsets ? OffsetColorListControl : ColorListControl; case 'vec3': return Vec3Control; case 'mat4': return Mat4Control; case 'url': return UrlControl; @@ -557,21 +558,30 @@ export class ColorControl extends SimpleParam<PD.Color> { } } -const colorGradientInterpolated = memoize1((colors: Color[]) => { - const styles = colors.map(c => Color.toStyle(c)); +function colorEntryToStyle(e: ColorListEntry, includeOffset = false) { + if (Array.isArray(e)) { + if (includeOffset) return `${Color.toStyle(e[0])} ${(100 * e[1]).toFixed(2)}%`; + return Color.toStyle(e[0]); + } + return Color.toStyle(e); +} + +const colorGradientInterpolated = memoize1((colors: ColorListEntry[]) => { + const styles = colors.map(c => colorEntryToStyle(c, true)); return `linear-gradient(to right, ${styles.join(', ')})`; }); -const colorGradientBanded = memoize1((colors: Color[]) => { +const colorGradientBanded = memoize1((colors: ColorListEntry[]) => { const n = colors.length; - const styles: string[] = [`${Color.toStyle(colors[0])} ${100 * (1 / n)}%`]; + const styles: string[] = [`${colorEntryToStyle(colors[0])} ${100 * (1 / n)}%`]; + // TODO: does this need to support offsets? for (let i = 1, il = n - 1; i < il; ++i) { styles.push( - `${Color.toStyle(colors[i])} ${100 * (i / n)}%`, - `${Color.toStyle(colors[i])} ${100 * ((i + 1) / n)}%` + `${colorEntryToStyle(colors[i])} ${100 * (i / n)}%`, + `${colorEntryToStyle(colors[i])} ${100 * ((i + 1) / n)}%` ); } - styles.push(`${Color.toStyle(colors[n - 1])} ${100 * ((n - 1) / n)}%`); + styles.push(`${colorEntryToStyle(colors[n - 1])} ${100 * ((n - 1) / n)}%`); return `linear-gradient(to right, ${styles.join(', ')})`; }); @@ -586,7 +596,7 @@ function colorStripStyle(list: PD.ColorList['defaultValue'], right = '0'): React }; } -function colorGradient(colors: Color[], banded: boolean) { +function colorGradient(colors: ColorListEntry[], banded: boolean) { return banded ? colorGradientBanded(colors) : colorGradientInterpolated(colors); } @@ -604,6 +614,9 @@ function createColorListHelpers() { set: ActionMenu.createItemsFromSelectOptions(ColorListOptionsSet, { addOn }) }, ColorsParam: PD.ObjectList({ color: PD.Color(0x0 as Color) }, ({ color }) => Color.toHexString(color).toUpperCase()), + OffsetColorsParam: PD.ObjectList( + { color: PD.Color(0x0 as Color), offset: PD.Numeric(0, { min: 0, max: 1, step: 0.01 }) }, + ({ color, offset }) => `${Color.toHexString(color).toUpperCase()} [${offset.toFixed(2)}]`), IsInterpolatedParam: PD.Boolean(false, { label: 'Interpolated' }) }; } @@ -684,6 +697,79 @@ export class ColorListControl extends React.PureComponent<ParamProps<PD.ColorLis } } +export class OffsetColorListControl extends React.PureComponent<ParamProps<PD.ColorList>, { showHelp: boolean, show?: 'edit' | 'presets' }> { + state = { showHelp: false, show: void 0 as 'edit' | 'presets' | undefined }; + + protected update(value: PD.ColorList['defaultValue']) { + this.props.onChange({ param: this.props.param, name: this.props.name, value }); + } + + toggleEdit = () => this.setState({ show: this.state.show === 'edit' ? void 0 : 'edit' }); + togglePresets = () => this.setState({ show: this.state.show === 'presets' ? void 0 : 'presets' }); + + renderControl() { + const { value } = this.props; + // TODO: fix the button right offset + return <> + <button onClick={this.toggleEdit} style={{ position: 'relative', paddingRight: '33px' }}> + {value.colors.length === 1 ? '1 color' : `${value.colors.length} colors`} + <div style={colorStripStyle(value, '33px')} /> + </button> + <IconButton svg={BookmarksOutlinedSvg} onClick={this.togglePresets} toggleState={this.state.show === 'presets'} title='Color Presets' + style={{ padding: 0, position: 'absolute', right: 0, top: 0, width: '32px' }} /> + </>; + } + + selectPreset: ActionMenu.OnSelect = item => { + if (!item) return; + this.setState({ show: void 0 }); + + const preset = getColorListFromName(item.value as ColorListName); + this.update({ kind: preset.type !== 'qualitative' ? 'interpolate' : 'set', colors: preset.list }); + } + + colorsChanged: ParamOnChange = ({ value }) => { + const colors = (value as (typeof _colorListHelpers)['OffsetColorsParam']['defaultValue']).map(c => [c.color, c.offset] as [Color, number]); + colors.sort((a, b) => a[1] - b[1]); + this.update({ kind: this.props.value.kind, colors }); + } + + isInterpolatedChanged: ParamOnChange = ({ value }) => { + this.update({ kind: value ? 'interpolate' : 'set', colors: this.props.value.colors }); + } + + renderColors() { + if (!this.state.show) return null; + const { ColorPresets, OffsetColorsParam, IsInterpolatedParam } = ColorListHelpers(); + + const preset = ColorPresets[this.props.param.presetKind]; + if (this.state.show === 'presets') return <ActionMenu items={preset} onSelect={this.selectPreset} />; + + const colors = this.props.value.colors; + const values = colors.map((color, i) => { + if (Array.isArray(color)) return { color: color[0], offset: color[1] }; + return { color, offset: i / colors.length }; + }); + values.sort((a, b) => a.offset - b.offset); + return <div className='msp-control-offset'> + <ObjectListControl name='colors' param={OffsetColorsParam} value={values} onChange={this.colorsChanged} isDisabled={this.props.isDisabled} onEnter={this.props.onEnter} /> + <BoolControl name='isInterpolated' param={IsInterpolatedParam} value={this.props.value.kind === 'interpolate'} onChange={this.isInterpolatedChanged} isDisabled={this.props.isDisabled} onEnter={this.props.onEnter} /> + </div>; + } + + toggleHelp = () => this.setState({ showHelp: !this.state.showHelp }); + + render() { + return renderSimple({ + props: this.props, + state: this.state, + control: this.renderControl(), + toggleHelp: this.toggleHelp, + addOn: this.renderColors() + }); + } +} + export class Vec3Control extends React.PureComponent<ParamProps<PD.Vec3>, { isExpanded: boolean }> { state = { isExpanded: false } diff --git a/src/mol-util/color/color.ts b/src/mol-util/color/color.ts index ee117a919b938ae30d27253568fe16327a2b94df..6bb9a811a451b2055f9cc6414b5a7e095f383f0e 100644 --- a/src/mol-util/color/color.ts +++ b/src/mol-util/color/color.ts @@ -146,13 +146,15 @@ export namespace Color { } } +export type ColorListEntry = Color | [color: Color, offset: number /** normalized value from 0 to 1 */] + export interface ColorList { label: string description: string - list: Color[] + list: ColorListEntry[] type: 'sequential' | 'diverging' | 'qualitative' } -export function ColorList(label: string, type: 'sequential' | 'diverging' | 'qualitative', description: string, list: number[]): ColorList { +export function ColorList(label: string, type: 'sequential' | 'diverging' | 'qualitative', description: string, list: (number | [number, number])[]): ColorList { return { label, description, list: list as Color[], type }; } diff --git a/src/mol-util/color/palette.ts b/src/mol-util/color/palette.ts index 5eab403a7fbda4216db122cfc0d7357563f31a13..c254af22cf94a4aefda76f7a8b4aa6f88d7229b6 100644 --- a/src/mol-util/color/palette.ts +++ b/src/mol-util/color/palette.ts @@ -73,8 +73,8 @@ export function getPalette(count: number, props: PaletteProps) { } else { let colors: Color[]; if (props.palette.name === 'colors') { - colors = props.palette.params.list.colors; - if (colors.length === 0) colors = getColorListFromName('dark-2').list; + colors = props.palette.params.list.colors.map(c => Array.isArray(c) ? c[0] : c); + if (colors.length === 0) colors = getColorListFromName('dark-2').list.map(c => Array.isArray(c) ? c[0] : c); } else { count = Math.min(count, props.palette.params.maxCount); colors = distinctColors(count, props.palette.params); diff --git a/src/mol-util/color/scale.ts b/src/mol-util/color/scale.ts index 29bc97dd76c44be9b357574df36a3aa5e0245586..695bc2ef7fc7e956a1202766402aaee6d4aa7460 100644 --- a/src/mol-util/color/scale.ts +++ b/src/mol-util/color/scale.ts @@ -4,11 +4,13 @@ * @author Alexander Rose <alexander.rose@weirdbyte.de> */ -import { Color } from './color'; +import { Color, ColorListEntry } from './color'; import { getColorListFromName, ColorListName } from './lists'; import { defaults } from '../../mol-util'; import { NumberArray } from '../../mol-util/type-helpers'; import { ScaleLegend } from '../legend'; +import { SortedArray } from '../../mol-data/int'; +import { clamp } from '../../mol-math/interpolate'; export interface ColorScale { /** Returns hex color for given value */ @@ -26,7 +28,7 @@ export interface ColorScale { export const DefaultColorScaleProps = { domain: [0, 1] as [number, number], reverse: false, - listOrName: 'red-yellow-blue' as Color[] | ColorListName, + listOrName: 'red-yellow-blue' as ColorListEntry[] | ColorListName, minLabel: '' as string | undefined, maxLabel: '' as string | undefined, }; @@ -51,12 +53,40 @@ export namespace ColorScale { const minLabel = defaults(props.minLabel, min.toString()); const maxLabel = defaults(props.maxLabel, max.toString()); - function color(value: number) { - const t = Math.min(colors.length - 1, Math.max(0, ((value - min) / diff) * count1)); - const tf = Math.floor(t); - const c1 = colors[tf]; - const c2 = colors[Math.ceil(t)]; - return Color.interpolate(c1, c2, t - tf); + let color: (v: number) => Color; + + const hasOffsets = colors.every(c => Array.isArray(c)); + if (hasOffsets) { + const sorted = [...colors] as [Color, number][]; + sorted.sort((a, b) => a[1] - b[1]); + + const src = sorted.map(c => c[0]); + const off = SortedArray.ofSortedArray(sorted.map(c => c[1])); + const max = src.length - 1; + + color = (v: number) => { + let t = clamp((v - min) / diff, 0, 1); + const i = SortedArray.findPredecessorIndex(off, t); + + if (i === 0) { + return src[min]; + } else if (i > max) { + return src[max]; + } + + const o1 = off[i - 1], o2 = off[i]; + const t1 = clamp((t - o1) / (o2 - o1), 0, 1); // TODO: cache the deltas? + + return Color.interpolate(src[i - 1], src[i], t1); + }; + } else { + color = (value: number) => { + const t = Math.min(colors.length - 1, Math.max(0, ((value - min) / diff) * count1)); + const tf = Math.floor(t); + const c1 = colors[tf] as Color; + const c2 = colors[Math.ceil(t)] as Color; + return Color.interpolate(c1, c2, t - tf); + }; } return { color, diff --git a/src/mol-util/legend.ts b/src/mol-util/legend.ts index 7538f85153ba2c259405b6036fef9d882e19dcd9..f7a65dc2ad25fdb4eb486ba96954fd81be4e8d29 100644 --- a/src/mol-util/legend.ts +++ b/src/mol-util/legend.ts @@ -5,6 +5,7 @@ */ import { Color } from './color'; +import { ColorListEntry } from './color/color'; export type Legend = TableLegend | ScaleLegend @@ -20,8 +21,8 @@ export interface ScaleLegend { kind: 'scale-legend' minLabel: string, maxLabel: string, - colors: Color[] + colors: ColorListEntry[] } -export function ScaleLegend(minLabel: string, maxLabel: string, colors: Color[]): ScaleLegend { +export function ScaleLegend(minLabel: string, maxLabel: string, colors: ColorListEntry[]): ScaleLegend { return { kind: 'scale-legend', minLabel, maxLabel, colors }; } \ No newline at end of file diff --git a/src/mol-util/param-definition.ts b/src/mol-util/param-definition.ts index d57a8272f4e2477234f5f3ba405c6982ff9e4eb4..57edb26d5add656ed3da51bb1cea988852f03ffa 100644 --- a/src/mol-util/param-definition.ts +++ b/src/mol-util/param-definition.ts @@ -14,6 +14,7 @@ import { Legend } from './legend'; import { stringToWords } from './string'; import { getColorListFromName, ColorListName } from './color/lists'; import { Asset } from './assets'; +import { ColorListEntry } from './color/color'; export namespace ParamDefinition { export interface Info { @@ -119,11 +120,12 @@ export namespace ParamDefinition { return ret; } - export interface ColorList extends Base<{ kind: 'interpolate' | 'set', colors: ColorData[] }> { + export interface ColorList extends Base<{ kind: 'interpolate' | 'set', colors: ColorListEntry[] }> { type: 'color-list' + offsets: boolean presetKind: 'all' | 'scale' | 'set' } - export function ColorList(defaultValue: { kind: 'interpolate' | 'set', colors: ColorData[] } | ColorListName, info?: Info & { presetKind?: ColorList['presetKind'] }): ColorList { + export function ColorList(defaultValue: { kind: 'interpolate' | 'set', colors: ColorListEntry[] } | ColorListName, info?: Info & { presetKind?: ColorList['presetKind'], offsets?: boolean }): ColorList { let def: ColorList['defaultValue']; if (typeof defaultValue === 'string') { const colors = getColorListFromName(defaultValue); @@ -131,7 +133,7 @@ export namespace ParamDefinition { } else { def = defaultValue; } - return setInfo<ColorList>({ type: 'color-list', presetKind: info?.presetKind || 'all', defaultValue: def }, info); + return setInfo<ColorList>({ type: 'color-list', presetKind: info?.presetKind || 'all', defaultValue: def, offsets: !!info?.offsets }, info); } export interface Vec3 extends Base<Vec3Data>, Range {