Browse Source

color list value offset support

David Sehnal 4 years ago
parent
commit
346eb59da9

+ 11 - 1
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 });
     }

+ 3 - 2
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 },

+ 1 - 1
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>

+ 95 - 9
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 }
 

+ 4 - 2
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 };
 }
 

+ 2 - 2
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);

+ 38 - 8
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,

+ 3 - 2
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 };
 }

+ 5 - 3
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 {