diff --git a/frontend/javascripts/viewer/view/components/setting_input_views.tsx b/frontend/javascripts/viewer/view/components/setting_input_views.tsx index 11e03dc11ae..1c18d92f127 100644 --- a/frontend/javascripts/viewer/view/components/setting_input_views.tsx +++ b/frontend/javascripts/viewer/view/components/setting_input_views.tsx @@ -25,6 +25,7 @@ import * as Utils from "libs/utils"; import _ from "lodash"; import messages from "messages"; import * as React from "react"; +import { useCallback, useEffect, useState } from "react"; import { connect } from "react-redux"; import type { APISegmentationLayer } from "types/api_types"; import type { Vector3, Vector6 } from "viewer/constants"; @@ -51,82 +52,86 @@ type NumberSliderSettingProps = { value: number; label: string | React.ReactNode; max: number; - min: number; - step: number; - disabled: boolean; - spans: Vector3; + min?: number; + step?: number; + disabled?: boolean; + spans?: Vector3; defaultValue?: number; wheelFactor?: number; }; -export class NumberSliderSetting extends React.PureComponent { - static defaultProps = { - min: 1, - step: 1, - disabled: false, - spans: [SETTING_LEFT_SPAN, SETTING_MIDDLE_SPAN, SETTING_VALUE_SPAN], - }; - - _onChange = (_value: number | null) => { - if (_value != null && this.isValueValid(_value)) { - this.props.onChange(_value); +export const NumberSliderSetting = React.memo(function NumberSliderSetting( + props: NumberSliderSettingProps, +) { + const { + min = 1, + step = 1, + disabled = false, + spans = [SETTING_LEFT_SPAN, SETTING_MIDDLE_SPAN, SETTING_VALUE_SPAN], + value: originalValue, + label, + max, + onChange, + defaultValue, + wheelFactor: stepSize, + } = props; + + const [value, setValue] = useState(originalValue); + // biome-ignore lint/correctness/useExhaustiveDependencies: Update the debaunced change handler when prop changes + const debouncedOnChange = useCallback(_.debounce(onChange, 250), [onChange]); + + const isValueValid = (_value: number | null) => + _.isNumber(_value) && _value >= min && _value <= max; + + const _onChange = (_value: number | null) => { + if (_value != null && isValueValid(_value)) { + setValue(_value); + debouncedOnChange(_value); } }; - isValueValid = (_value: number | null) => - _.isNumber(_value) && _value >= this.props.min && _value <= this.props.max; + // Validate the provided value. If it's not valid, fallback to the midpoint between min and max. + // This check guards against broken settings which could be introduced before this component + // checked more thoroughly against invalid values. + // biome-ignore lint/correctness/useExhaustiveDependencies: isValueValid changes on every render + useEffect(() => { + setValue(isValueValid(originalValue) ? originalValue : Math.floor((min + max) / 2)); + }, [originalValue, min, max]); - render() { - const { - value: originalValue, - label, - max, - min, - step, - onChange, - disabled, - defaultValue, - wheelFactor: stepSize, - } = this.props; - // Validate the provided value. If it's not valid, fallback to the midpoint between min and max. - // This check guards against broken settings which could be introduced before this component - // checked more thoroughly against invalid values. - const value = this.isValueValid(originalValue) ? originalValue : Math.floor((min + max) / 2); - return ( - - - - - - - - - - - - ); - } -} + return ( + + + + + + + + + + + + ); +}); type LogSliderSettingProps = { onChange: (value: number) => void; @@ -134,9 +139,9 @@ type LogSliderSettingProps = { label: string | React.ReactNode; max: number; min: number; - roundTo: number; + roundTo?: number; disabled?: boolean; - spans: Vector3; + spans?: Vector3; precision?: number; defaultValue?: number; }; @@ -144,101 +149,113 @@ type LogSliderSettingProps = { const LOG_SLIDER_MIN = -100; const LOG_SLIDER_MAX = 100; -export class LogSliderSetting extends React.PureComponent { - static defaultProps = { - disabled: false, - roundTo: 3, - spans: [SETTING_LEFT_SPAN, SETTING_MIDDLE_SPAN, SETTING_VALUE_SPAN], +export const LogSliderSetting = React.memo(function LogSliderSetting(props: LogSliderSettingProps) { + const { + disabled = false, + roundTo = 3, + spans = [SETTING_LEFT_SPAN, SETTING_MIDDLE_SPAN, SETTING_VALUE_SPAN], + label, + value: originalValue, + min, + max, + onChange, + precision, + defaultValue, + } = props; + + const [value, setValue] = useState(originalValue); + // biome-ignore lint/correctness/useExhaustiveDependencies: Update the debaunced change handler when prop changes + const debouncedOnChange = useCallback(_.debounce(onChange, 250), [onChange]); + + const calculateValue = (v: number) => { + const a = 200 / (Math.log(max) - Math.log(min)); + const b = (100 * (Math.log(min) + Math.log(max))) / (Math.log(min) - Math.log(max)); + return Math.exp((v - b) / a); + }; + + const getSliderValue = (v: number) => { + const a = 200 / (Math.log(max) - Math.log(min)); + const b = (100 * (Math.log(min) + Math.log(max))) / (Math.log(min) - Math.log(max)); + const scaleValue = a * Math.log(v) + b; + return Math.round(scaleValue); }; - onChangeInput = (value: number | null) => { - if (value == null) { + const onChangeInput = (v: number | null) => { + if (v == null) { return; } - if (this.props.min <= value && value <= this.props.max) { - this.props.onChange(value); + if (min <= v && v <= max) { + setValue(v); + debouncedOnChange(v); } else { // reset to slider value - this.props.onChange(this.props.value); + setValue(value); + debouncedOnChange(value); } }; - onChangeSlider = (value: number) => { - this.props.onChange(this.calculateValue(value)); + const onChangeSlider = (v: number) => { + const calculatedValue = calculateValue(v); + setValue(calculatedValue); + debouncedOnChange(calculatedValue); }; - calculateValue(value: number) { - const a = 200 / (Math.log(this.props.max) - Math.log(this.props.min)); - const b = - (100 * (Math.log(this.props.min) + Math.log(this.props.max))) / - (Math.log(this.props.min) - Math.log(this.props.max)); - return Math.exp((value - b) / a); - } - - formatTooltip = (value: number | undefined) => { - if (value == null) { + const formatTooltip = (v: number | undefined) => { + if (v == null) { return "invalid"; } - const calculatedValue = this.calculateValue(value); + const calculatedValue = calculateValue(v); return calculatedValue >= 10000 ? calculatedValue.toExponential() - : Utils.roundTo(calculatedValue, this.props.roundTo); + : Utils.roundTo(calculatedValue, roundTo); }; - getSliderValue = () => { - const a = 200 / (Math.log(this.props.max) - Math.log(this.props.min)); - const b = - (100 * (Math.log(this.props.min) + Math.log(this.props.max))) / - (Math.log(this.props.min) - Math.log(this.props.max)); - const scaleValue = a * Math.log(this.props.value) + b; - return Math.round(scaleValue); + const resetToDefaultValue = () => { + if (defaultValue == null) return; + onChangeInput(defaultValue); }; - resetToDefaultValue = () => { - if (this.props.defaultValue == null) return; - this.onChangeInput(this.props.defaultValue); - }; + useEffect(() => { + setValue(originalValue); + }, [originalValue]); - render() { - const { label, roundTo, value, min, max, disabled, defaultValue } = this.props; - return ( - - - - - - - - - - - - ); - } -} + return ( + + + + + + + + + + + + ); +}); type SwitchSettingProps = React.PropsWithChildren<{ onChange: (value: boolean) => void | Promise; diff --git a/frontend/javascripts/viewer/view/left-border-tabs/histogram_view.tsx b/frontend/javascripts/viewer/view/left-border-tabs/histogram_view.tsx index 2d60e390b8b..40465e1b676 100644 --- a/frontend/javascripts/viewer/view/left-border-tabs/histogram_view.tsx +++ b/frontend/javascripts/viewer/view/left-border-tabs/histogram_view.tsx @@ -3,16 +3,16 @@ import { Alert, Col, InputNumber, Row, Spin } from "antd"; import FastTooltip from "components/fast_tooltip"; import { Slider } from "components/slider"; import { roundTo } from "libs/utils"; -import * as _ from "lodash"; -import * as React from "react"; -import { connect } from "react-redux"; -import type { Dispatch } from "redux"; +import { debounce, range } from "lodash"; +import type React from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useDispatch } from "react-redux"; import type { APIHistogramData, ElementClass, HistogramDatum } from "types/api_types"; import { PRIMARY_COLOR, type Vector3 } from "viewer/constants"; import { updateLayerSettingAction } from "viewer/model/actions/settings_actions"; import type { DatasetLayerConfiguration } from "viewer/store"; -type OwnProps = { +type Props = { data: APIHistogramData | null | undefined; layerName: string; intensityRangeMin: number; @@ -24,17 +24,6 @@ type OwnProps = { supportFractions: boolean; reloadHistogram: () => void; }; -type HistogramProps = OwnProps & { - onChangeLayer: ( - layerName: string, - propertyName: keyof DatasetLayerConfiguration, - value: [number, number] | number | boolean, - ) => void; -}; -type HistogramState = { - currentMin: number; - currentMax: number; -}; const uint24Colors = [ [255, 65, 54], @@ -54,9 +43,7 @@ export function isHistogramSupported(elementClass: ElementClass): boolean { ); } -function getMinAndMax(props: HistogramProps) { - const { min, max, data, defaultMinMax } = props; - +function getMinAndMax({ min, max, data, defaultMinMax }: Props) { if (min != null && max != null) { return { min, @@ -90,71 +77,102 @@ const DUMMY_HISTOGRAM_DATA = [ // message will be rendered. { numberOfElements: 255, - elementCounts: _.range(255).map((idx) => Math.exp(-0.5 * ((idx - 128) / 30) ** 2)), + elementCounts: range(255).map((idx) => Math.exp(-0.5 * ((idx - 128) / 30) ** 2)), min: 0, max: 255, }, ]; -class Histogram extends React.PureComponent { - canvasRef: HTMLCanvasElement | null | undefined; - - constructor(props: HistogramProps) { - super(props); - const { min, max } = getMinAndMax(props); - this.state = { - currentMin: min, - currentMax: max, - }; - } - - componentDidUpdate(prevProps: HistogramProps) { - if ( - prevProps.min !== this.props.min || - prevProps.max !== this.props.max || - prevProps.data !== this.props.data - ) { - const { min, max } = getMinAndMax(this.props); - this.setState({ - currentMin: min, - currentMax: max, - }); - } - - this.updateCanvas(); - } - - onCanvasRefChange = (ref: HTMLCanvasElement | null | undefined) => { - this.canvasRef = ref; +const Histogram: React.FC = (props) => { + const { + data, + layerName, + intensityRangeMin, + intensityRangeMax, + isInEditMode, + defaultMinMax, + supportFractions, + reloadHistogram, + } = props; + + const dispatch = useDispatch(); + const canvasRef = useRef(null); + const { min, max } = getMinAndMax(props); + const [currentMin, setCurrentMin] = useState(min); + const [currentMax, setCurrentMax] = useState(max); + + const onChangeLayer = useCallback( + ( + layerName: string, + propertyName: keyof DatasetLayerConfiguration, + value: [number, number] | number | boolean, + ) => { + dispatch(updateLayerSettingAction(layerName, propertyName, value)); + }, + [dispatch], + ); - if (this.canvasRef == null) { - return; - } + const getPrecision = () => Math.max(getPrecisionOf(currentMin), getPrecisionOf(currentMax)) + 3; + + const drawHistogram = useCallback( + ( + ctx: CanvasRenderingContext2D, + histogram: HistogramDatum, + maxValue: number, + color: Vector3, + minRange: number, + maxRange: number, + ) => { + const { intensityRangeMin, intensityRangeMax } = props; + const { min: histogramMin, max: histogramMax, elementCounts } = histogram; + const histogramLength = histogramMax - histogramMin; + const fullLength = maxRange - minRange; + const xOffset = histogramMin - minRange; + ctx.fillStyle = `rgba(${color.join(",")}, 0.1)`; + ctx.strokeStyle = `rgba(${color.join(",")})`; + ctx.beginPath(); + // Scale data to the height of the histogram canvas. + const downscaledData = elementCounts.map((value) => (value / maxValue) * CANVAS_HEIGHT); + const activeRegion = new Path2D(); + ctx.moveTo(0, 0); + activeRegion.moveTo(((intensityRangeMin - minRange) / fullLength) * CANVAS_WIDTH, 0); + + for (let i = 0; i < downscaledData.length; i++) { + const xInHistogramScale = (i * histogramLength) / downscaledData.length; + const xInCanvasScale = ((xOffset + xInHistogramScale) * CANVAS_WIDTH) / fullLength; + const xValue = histogramMin + xInHistogramScale; + + if (xValue >= intensityRangeMin && xValue <= intensityRangeMax) { + activeRegion.lineTo(xInCanvasScale, downscaledData[i]); + } + + ctx.lineTo(xInCanvasScale, downscaledData[i]); + } - const ctx = this.canvasRef.getContext("2d"); - if (ctx == null) { - return; - } - ctx.translate(0, CANVAS_HEIGHT); - ctx.scale(1, -1); - ctx.lineWidth = 1; - ctx.lineJoin = "round"; - this.updateCanvas(); - }; + ctx.stroke(); + ctx.closePath(); + const activeRegionRightLimit = Math.min(histogramMax, intensityRangeMax); + const activeRegionLeftLimit = Math.max(histogramMin, intensityRangeMin); + activeRegion.lineTo(((activeRegionRightLimit - minRange) / fullLength) * CANVAS_WIDTH, 0); + activeRegion.lineTo(((activeRegionLeftLimit - minRange) / fullLength) * CANVAS_WIDTH, 0); + activeRegion.closePath(); + ctx.fill(activeRegion); + }, + [props], + ); - updateCanvas() { - if (this.canvasRef == null) { + const updateCanvas = useCallback(() => { + if (canvasRef.current == null) { return; } - const ctx = this.canvasRef.getContext("2d"); + const ctx = canvasRef.current.getContext("2d"); if (ctx == null) { return; } ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); - const { min, max } = - this.props.data != null ? getMinAndMax(this.props) : DUMMY_HISTOGRAM_DATA[0]; - const data = this.props.data ?? DUMMY_HISTOGRAM_DATA; + const { min, max } = props.data != null ? getMinAndMax(props) : DUMMY_HISTOGRAM_DATA[0]; + const data = props.data ?? DUMMY_HISTOGRAM_DATA; // Compute the overall maximum count, so the RGB curves are scaled correctly relative to each other. const maxValue = Math.max( @@ -173,236 +191,197 @@ class Histogram extends React.PureComponent { for (const [i, histogram] of data.entries()) { const color = data.length > 1 ? uint24Colors[i] : PRIMARY_COLOR; - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'number[]' is not assignable to p... Remove this comment to see the full error message - this.drawHistogram(ctx, histogram, maxValue, color, min, max); + drawHistogram(ctx, histogram, maxValue, color as Vector3, min, max); } - } + }, [props, drawHistogram]); - getPrecision = () => - Math.max(getPrecisionOf(this.state.currentMin), getPrecisionOf(this.state.currentMax)) + 3; - - drawHistogram = ( - ctx: CanvasRenderingContext2D, - histogram: HistogramDatum, - maxValue: number, - color: Vector3, - minRange: number, - maxRange: number, - ) => { - const { intensityRangeMin, intensityRangeMax } = this.props; - const { min: histogramMin, max: histogramMax, elementCounts } = histogram; - const histogramLength = histogramMax - histogramMin; - const fullLength = maxRange - minRange; - const xOffset = histogramMin - minRange; - ctx.fillStyle = `rgba(${color.join(",")}, 0.1)`; - ctx.strokeStyle = `rgba(${color.join(",")})`; - ctx.beginPath(); - // Scale data to the height of the histogram canvas. - const downscaledData = elementCounts.map((value) => (value / maxValue) * CANVAS_HEIGHT); - const activeRegion = new Path2D(); - ctx.moveTo(0, 0); - activeRegion.moveTo(((intensityRangeMin - minRange) / fullLength) * CANVAS_WIDTH, 0); - - for (let i = 0; i < downscaledData.length; i++) { - const xInHistogramScale = (i * histogramLength) / downscaledData.length; - const xInCanvasScale = ((xOffset + xInHistogramScale) * CANVAS_WIDTH) / fullLength; - const xValue = histogramMin + xInHistogramScale; - - if (xValue >= intensityRangeMin && xValue <= intensityRangeMax) { - activeRegion.lineTo(xInCanvasScale, downscaledData[i]); - } + // biome-ignore lint/correctness/useExhaustiveDependencies: Update min/max when the data changes. + useEffect(() => { + const { min, max } = getMinAndMax(props); + setCurrentMin(min); + setCurrentMax(max); + }, [props.min, props.max]); + + useEffect(() => { + updateCanvas(); + }); - ctx.lineTo(xInCanvasScale, downscaledData[i]); + useLayoutEffect(() => { + if (canvasRef.current == null) { + return; } - ctx.stroke(); - ctx.closePath(); - const activeRegionRightLimit = Math.min(histogramMax, intensityRangeMax); - const activeRegionLeftLimit = Math.max(histogramMin, intensityRangeMin); - activeRegion.lineTo(((activeRegionRightLimit - minRange) / fullLength) * CANVAS_WIDTH, 0); - activeRegion.lineTo(((activeRegionLeftLimit - minRange) / fullLength) * CANVAS_WIDTH, 0); - activeRegion.closePath(); - ctx.fill(activeRegion); - }; + const ctx = canvasRef.current.getContext("2d"); + if (ctx == null) { + return; + } + ctx.translate(0, CANVAS_HEIGHT); + ctx.scale(1, -1); + ctx.lineWidth = 1; + ctx.lineJoin = "round"; + updateCanvas(); + }, [updateCanvas]); - onThresholdChange = (values: number[]) => { - const { layerName } = this.props; + const onThresholdChange = (values: number[]) => { const [firstVal, secVal] = values; if (firstVal < secVal) { - this.props.onChangeLayer(layerName, "intensityRange", [firstVal, secVal]); + onChangeLayer(layerName, "intensityRange", [firstVal, secVal]); } else { - this.props.onChangeLayer(layerName, "intensityRange", [secVal, firstVal]); + onChangeLayer(layerName, "intensityRange", [secVal, firstVal]); } }; - tipFormatter = (value: number | undefined) => { + const tipFormatter = (value: number | undefined) => { if (value == null) { return "invalid"; } return value >= 100000 || (value < 0.001 && value > -0.001 && value !== 0) ? value.toExponential() - : roundTo(value, this.getPrecision()).toString(); + : roundTo(value, getPrecision()).toString(); }; - updateMinimumDebounced = _.debounce( - (value, layerName) => this.props.onChangeLayer(layerName, "min", value), - 500, + const updateMinimumDebounced = useCallback( + debounce((value, layerName) => onChangeLayer(layerName, "min", value), 500), + [], ); - updateMaximumDebounced = _.debounce( - (value, layerName) => this.props.onChangeLayer(layerName, "max", value), - 500, + const updateMaximumDebounced = useCallback( + debounce((value, layerName) => onChangeLayer(layerName, "max", value), 500), + [], ); - render() { - const { intensityRangeMin, intensityRangeMax, isInEditMode, defaultMinMax, layerName, data } = - this.props; - - const maybeWarning = - data === null ? ( - - Histogram couldn’t be fetched.{" "} - - Retry - - - } - showIcon + const maybeWarning = + data === null ? ( + + Histogram couldn’t be fetched.{" "} + + Retry + + + } + showIcon + /> + ) : null; + + const { min: minRange, max: maxRange } = getMinAndMax(props); + + const tooltipTitleFor = (minimumOrMaximum: string) => + `Enter the ${minimumOrMaximum} possible value for layer ${layerName}. Scientific (e.g. 9e+10) notation is supported.`; + + const minMaxInputStyle = { + width: "100%", + }; + const maybeCeilFn = supportFractions ? (val: number) => val : Math.ceil; + return ( + +
+ {maybeWarning &&
{maybeWarning}
} + - ) : null; - - const { currentMin, currentMax } = this.state; - const { min: minRange, max: maxRange } = getMinAndMax(this.props); - - const tooltipTitleFor = (minimumOrMaximum: string) => - `Enter the ${minimumOrMaximum} possible value for layer ${layerName}. Scientific (e.g. 9e+10) notation is supported.`; - - const minMaxInputStyle = { - width: "100%", - }; - const maybeCeilFn = this.props.supportFractions ? (val: number) => val : Math.ceil; - return ( - -
- {maybeWarning &&
{maybeWarning}
} - -
- + + {isInEditMode ? ( + - {isInEditMode ? ( - - - - - - - { - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'number' is not assignable to par... Remove this comment to see the full error message - value = Number.parseFloat(value); - - if (value <= maxRange) { - this.setState({ - currentMin: value, - }); - this.updateMinimumDebounced(value, layerName); - } - }} - style={minMaxInputStyle} - /> - - - - - - - - { - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'number' is not assignable to par... Remove this comment to see the full error message - value = Number.parseFloat(value); - - if (value >= minRange) { - this.setState({ - currentMax: value, - }); - this.updateMaximumDebounced(value, layerName); - } - }} - style={minMaxInputStyle} - /> - - - - this.props.onChangeLayer(layerName, "isInEditMode", !isInEditMode)} - > - - + style={minMaxInputStyle} + /> - - ) : null} -
- ); - } -} - -const mapDispatchToProps = (dispatch: Dispatch) => ({ - onChangeLayer(layerName: string, propertyName: keyof DatasetLayerConfiguration, value: any) { - dispatch(updateLayerSettingAction(layerName, propertyName, value)); - }, -}); + + + + + + + { + // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'number' is not assignable to par... Remove this comment to see the full error message + value = Number.parseFloat(value); + + if (value >= minRange) { + setCurrentMax(value); + updateMaximumDebounced(value, layerName); + } + }} + style={minMaxInputStyle} + /> + + + + onChangeLayer(layerName, "isInEditMode", !isInEditMode)} + > + + + + + ) : null} + + ); +}; -const connector = connect(null, mapDispatchToProps); -export default connector(Histogram); +export default Histogram; diff --git a/frontend/javascripts/viewer/view/left-border-tabs/layer_settings_tab.tsx b/frontend/javascripts/viewer/view/left-border-tabs/layer_settings_tab.tsx index 7cbcd1c222a..6d348684988 100644 --- a/frontend/javascripts/viewer/view/left-border-tabs/layer_settings_tab.tsx +++ b/frontend/javascripts/viewer/view/left-border-tabs/layer_settings_tab.tsx @@ -41,9 +41,9 @@ import { settings, settingsTooltips, } from "messages"; -import React, { useCallback } from "react"; -import { connect, useDispatch } from "react-redux"; -import type { Dispatch } from "redux"; +import type React from "react"; +import { Fragment, useCallback, useState } from "react"; +import { useDispatch } from "react-redux"; import { APIAnnotationTypeEnum, type APIDataLayer, @@ -121,7 +121,6 @@ import type { DatasetLayerConfiguration, UserConfiguration, VolumeTracing, - WebknossosState, } from "viewer/store"; import Store from "viewer/store"; import { MaterializeVolumeAnnotationModal } from "viewer/view/action-bar/starting_job_modals"; @@ -141,16 +140,6 @@ import Histogram, { isHistogramSupported } from "./histogram_view"; import MappingSettingsView from "./mapping_settings_view"; import AddVolumeLayerModal, { validateReadableLayerName } from "./modals/add_volume_layer_modal"; -type DatasetSettingsProps = ReturnType & - ReturnType; - -type State = { - isAddVolumeLayerModalVisible: boolean; - preselectedSegmentationLayerName: string | undefined; - segmentationLayerWasPreselected: boolean | undefined; - layerToMergeWithFallback: APIDataLayer | null | undefined; -}; - function DragHandleIcon({ isDisabled = false }: { isDisabled?: boolean }) { return (
{ - onChangeUser: Record) => any>; - state: State = { - isAddVolumeLayerModalVisible: false, - preselectedSegmentationLayerName: undefined, - segmentationLayerWasPreselected: false, - layerToMergeWithFallback: null, - }; +const DatasetSettings: React.FC = () => { + const dispatch = useDispatch(); - constructor(props: DatasetSettingsProps) { - super(props); - this.onChangeUser = _.mapValues(this.props.userConfiguration, (__, propertyName) => - _.partial(this.props.onChangeUser, propertyName as keyof UserConfiguration), + const userConfiguration = useWkSelector((state) => state.userConfiguration); + const datasetConfiguration = useWkSelector((state) => state.datasetConfiguration); + const histogramData = useWkSelector((state) => state.temporaryConfiguration.histogramData); + const dataset = useWkSelector((state) => state.dataset); + const annotation = useWkSelector((state) => state.annotation); + const task = useWkSelector((state) => state.task); + const controlMode = useWkSelector((state) => state.temporaryConfiguration.controlMode); + const isArbitraryMode = useWkSelector((state) => + Constants.MODES_ARBITRARY.includes(state.temporaryConfiguration.viewMode), + ); + const isAdminOrDatasetManager = useWkSelector((state) => + state.activeUser != null ? Utils.isUserAdminOrDatasetManager(state.activeUser) : false, + ); + const isAdminOrManager = useWkSelector((state) => + state.activeUser != null ? Utils.isUserAdminOrManager(state.activeUser) : false, + ); + const isSuperUser = useWkSelector((state) => + state.activeUser != null ? state.activeUser.isSuperUser : false, + ); + + const [isAddVolumeLayerModalVisible, setIsAddVolumeLayerModalVisible] = useState(false); + const [preselectedSegmentationLayerName, setPreselectedSegmentationLayerName] = useState< + string | undefined + >(undefined); + const [segmentationLayerWasPreselected, setSegmentationLayerWasPreselected] = useState(false); + const [layerToMergeWithFallback, setLayerToMergeWithFallback] = useState< + APIDataLayer | null | undefined + >(null); + + const onChange = useCallback( + (propertyName: keyof DatasetConfiguration, value: ValueOf) => { + dispatch(updateDatasetSettingAction(propertyName, value)); + }, + [dispatch], + ); + + const onChangeUser = useCallback( + (propertyName: keyof UserConfiguration, value: ValueOf) => { + dispatch(updateUserSettingAction(propertyName, value)); + }, + [dispatch], + ); + + const onChangeLayer = useCallback( + ( + layerName: string, + propertyName: keyof DatasetLayerConfiguration, + value: ValueOf, + ) => { + dispatch(updateLayerSettingAction(layerName, propertyName, value)); + }, + [dispatch], + ); + + const onClipHistogram = useCallback( + (layerName: string, shouldAdjustClipRange: boolean) => { + return dispatchClipHistogramAsync(layerName, shouldAdjustClipRange, dispatch); + }, + [dispatch], + ); + + const onChangeRadius = useCallback( + (radius: number) => { + dispatch(setNodeRadiusAction(radius)); + }, + [dispatch], + ); + + const onSetPosition = useCallback( + (position: Vector3) => { + dispatch(setPositionAction(position)); + }, + [dispatch], + ); + + const onChangeShowSkeletons = useCallback( + (showSkeletons: boolean) => { + dispatch(setShowSkeletonsAction(showSkeletons)); + }, + [dispatch], + ); + + const onZoomToMag = useCallback( + (layerName: string, mag: Vector3) => { + const targetZoomValue = getMaxZoomValueForMag(Store.getState(), layerName, mag); + dispatch(setZoomStepAction(targetZoomValue)); + return targetZoomValue; + }, + [dispatch], + ); + + const onEditAnnotationLayer = useCallback( + (tracingId: string, layerProperties: EditableLayerProperties) => { + dispatch(editAnnotationLayerAction(tracingId, layerProperties)); + }, + [dispatch], + ); + + const reloadHistogram = useCallback( + (layerName: string) => { + dispatch(reloadHistogramAction(layerName)); + }, + [dispatch], + ); + + const addSkeletonLayerToAnnotation = useCallback(() => { + dispatch( + pushSaveQueueTransactionIsolated( + addLayerToAnnotation({ + typ: "Skeleton", + name: "skeleton", + fallbackLayerName: undefined, + }), + ), ); - } + }, [dispatch]); - getFindDataButton = ( + const deleteAnnotationLayerFromTracing = useCallback( + (tracingId: string, type: AnnotationLayerType, layerName: string) => { + dispatch(pushSaveQueueTransaction([deleteAnnotationLayer(tracingId, layerName, type)])); + }, + [dispatch], + ); + + const getFindDataButton = ( layerName: string, isDisabled: boolean, isColorLayer: boolean, @@ -384,7 +484,7 @@ class DatasetSettings extends React.PureComponent {
this.handleFindData(layerName, isColorLayer, maybeVolumeTracing) + ? () => handleFindData(layerName, isColorLayer, maybeVolumeTracing) : () => Promise.resolve() } style={{ @@ -398,7 +498,7 @@ class DatasetSettings extends React.PureComponent { ); }; - getReloadDataButton = ( + const getReloadDataButton = ( layerName: string, isHistogramAvailable: boolean, maybeFallbackLayerName: string | null, @@ -407,9 +507,7 @@ class DatasetSettings extends React.PureComponent { return (
- this.reloadLayerData(layerName, isHistogramAvailable, maybeFallbackLayerName) - } + onClick={() => reloadLayerData(layerName, isHistogramAvailable, maybeFallbackLayerName)} > Reload data from server @@ -418,13 +516,13 @@ class DatasetSettings extends React.PureComponent { ); }; - getEditMinMaxButton = (layerName: string, isInEditMode: boolean) => { + const getEditMinMaxButton = (layerName: string, isInEditMode: boolean) => { const tooltipText = isInEditMode ? "Stop editing the possible range of the histogram." : "Manually set the possible range of the histogram."; return ( -
this.props.onChangeLayer(layerName, "isInEditMode", !isInEditMode)}> +
onChangeLayer(layerName, "isInEditMode", !isInEditMode)}> { ); }; - getMergeWithFallbackLayerButton = (layer: APIDataLayer) => ( -
this.setState({ layerToMergeWithFallback: layer })}> + const getMergeWithFallbackLayerButton = (layer: APIDataLayer) => ( +
setLayerToMergeWithFallback(layer)}> Merge this volume annotation with its fallback layer
); - getDeleteAnnotationLayerButton = ( + const getDeleteAnnotationLayerButton = ( readableName: string, type: AnnotationLayerType, tracingId: string, @@ -453,28 +551,26 @@ class DatasetSettings extends React.PureComponent {
this.deleteAnnotationLayerIfConfirmed(readableName, type, tracingId)} + onClick={() => deleteAnnotationLayerIfConfirmed(readableName, type, tracingId)} className="fas fa-trash icon-margin-right" />
); - getDeleteAnnotationLayerDropdownOption = ( + const getDeleteAnnotationLayerDropdownOption = ( readableName: string, type: AnnotationLayerType, tracingId: string, layer?: APIDataLayer, ) => ( -
this.deleteAnnotationLayerIfConfirmed(readableName, type, tracingId, layer)} - > +
deleteAnnotationLayerIfConfirmed(readableName, type, tracingId, layer)}> Delete this annotation layer
); - deleteAnnotationLayerIfConfirmed = async ( + const deleteAnnotationLayerIfConfirmed = async ( readableAnnotationLayerName: string, type: AnnotationLayerType, tracingId: string, @@ -500,19 +596,19 @@ class DatasetSettings extends React.PureComponent { }, }); if (!shouldDelete) return; - this.props.deleteAnnotationLayer(tracingId, type, readableAnnotationLayerName); + deleteAnnotationLayerFromTracing(tracingId, type, readableAnnotationLayerName); await Model.ensureSavedState(); location.reload(); }; - getClipButton = (layerName: string, isInEditMode: boolean) => { + const getClipButton = (layerName: string, isInEditMode: boolean) => { const editModeAddendum = isInEditMode ? "In Edit Mode, the histogram's range will be adjusted, too." : ""; const tooltipText = `Automatically clip the histogram to enhance contrast. ${editModeAddendum}`; return ( -
this.props.onClipHistogram(layerName, isInEditMode)}> +
onClipHistogram(layerName, isInEditMode)}> { ); }; - getComputeSegmentIndexFileButton = (layerName: string, isSegmentation: boolean) => { - if (!(this.props.isSuperUser && isSegmentation)) return <>; + const getComputeSegmentIndexFileButton = (layerName: string, isSegmentation: boolean) => { + if (!(isSuperUser && isSegmentation)) return <>; const triggerComputeSegmentIndexFileJob = async () => { - await startComputeSegmentIndexFileJob(this.props.dataset.id, layerName); + await startComputeSegmentIndexFileJob(dataset.id, layerName); Toast.info( - + Started a job for computating a segment index file.
See{" "} @@ -540,7 +636,7 @@ class DatasetSettings extends React.PureComponent { Processing Jobs {" "} for an overview of running jobs. -
, + , ); }; @@ -552,15 +648,15 @@ class DatasetSettings extends React.PureComponent { ); }; - setVisibilityForAllLayers = (isVisible: boolean) => { - const { layers } = this.props.datasetConfiguration; + const setVisibilityForAllLayers = (isVisible: boolean) => { + const { layers } = datasetConfiguration; Object.keys(layers).forEach((otherLayerName) => - this.props.onChangeLayer(otherLayerName, "isDisabled", !isVisible), + onChangeLayer(otherLayerName, "isDisabled", !isVisible), ); }; - isLayerExclusivelyVisible = (layerName: string): boolean => { - const { layers } = this.props.datasetConfiguration; + const isLayerExclusivelyVisible = (layerName: string): boolean => { + const { layers } = datasetConfiguration; const isOnlyGivenLayerVisible = Object.keys(layers).every((otherLayerName) => { const { isDisabled } = layers[otherLayerName]; return layerName === otherLayerName ? !isDisabled : isDisabled; @@ -568,7 +664,7 @@ class DatasetSettings extends React.PureComponent { return isOnlyGivenLayerVisible; }; - getEnableDisableLayerSwitch = (isDisabled: boolean, onChange: SwitchChangeEventHandler) => ( + const getEnableDisableLayerSwitch = (isDisabled: boolean, onChange: SwitchChangeEventHandler) => ( {/* This div is necessary for the tooltip to be displayed */}
{ ); - getHistogram = (layerName: string, layer: DatasetLayerConfiguration) => { + const getHistogram = (layerName: string, layer: DatasetLayerConfiguration) => { const { intensityRange, min, max, isInEditMode } = layer; if (!intensityRange) { return null; } - const defaultIntensityRange = getDefaultValueRangeOfLayer(this.props.dataset, layerName); - const histograms = this.props.histogramData?.[layerName]; - const elementClass = getElementClass(this.props.dataset, layerName); + const defaultIntensityRange = getDefaultValueRangeOfLayer(dataset, layerName); + const histograms = histogramData?.[layerName]; + const elementClass = getElementClass(dataset, layerName); return ( { isInEditMode={isInEditMode} layerName={layerName} defaultMinMax={defaultIntensityRange} - reloadHistogram={() => this.reloadHistogram(layerName)} + reloadHistogram={() => reloadHistogram(layerName)} /> ); }; - getLayerSettingsHeader = ( + const getLayerSettingsHeader = ( isDisabled: boolean, isColorLayer: boolean, isInEditMode: boolean, @@ -616,14 +712,12 @@ class DatasetSettings extends React.PureComponent { isHistogramAvailable: boolean, hasLessThanTwoColorLayers: boolean = true, ) => { - const { annotation, dataset, isAdminOrManager } = this.props; const { intensityRange } = layerSettings; const layer = getLayerByName(dataset, layerName); const isSegmentation = layer.category === "segmentation"; const layerType = layer.category === "segmentation" ? AnnotationLayerEnum.Volume : AnnotationLayerEnum.Skeleton; - const canBeMadeEditable = - isSegmentation && layer.tracingId == null && this.props.controlMode === "TRACE"; + const canBeMadeEditable = isSegmentation && layer.tracingId == null && controlMode === "TRACE"; const isVolumeTracing = isSegmentation ? layer.tracingId != null : false; const isAnnotationLayer = isSegmentation && layer.tracingId != null; const isOnlyAnnotationLayer = @@ -635,7 +729,7 @@ class DatasetSettings extends React.PureComponent { maybeVolumeTracing?.fallbackLayer != null ? maybeVolumeTracing.fallbackLayer : null; const setSingleLayerVisibility = (isVisible: boolean) => { - this.props.onChangeLayer(layerName, "isDisabled", !isVisible); + onChangeLayer(layerName, "isDisabled", !isVisible); }; const onChange = ( @@ -649,14 +743,14 @@ class DatasetSettings extends React.PureComponent { // If a modifier is pressed, toggle between "all layers visible" and // "only selected layer visible". - if (this.isLayerExclusivelyVisible(layerName)) { - this.setVisibilityForAllLayers(true); + if (isLayerExclusivelyVisible(layerName)) { + setVisibilityForAllLayers(true); } else { - this.setVisibilityForAllLayers(false); + setVisibilityForAllLayers(false); setSingleLayerVisibility(true); } }; - const hasHistogram = this.props.histogramData[layerName] != null; + const hasHistogram = histogramData[layerName] != null; const volumeDescriptor = "tracingId" in layer && layer.tracingId != null ? getVolumeDescriptorById(annotation, layer.tracingId) @@ -674,25 +768,25 @@ class DatasetSettings extends React.PureComponent { const possibleItems: MenuProps["items"] = [ isVolumeTracing && !isDisabled && maybeFallbackLayer != null && isAdminOrManager ? { - label: this.getMergeWithFallbackLayerButton(layer), + label: getMergeWithFallbackLayerButton(layer), key: "mergeWithFallbackLayerButton", } : null, - this.props.dataset.isEditable + dataset.isEditable ? { - label: this.getReloadDataButton(layerName, isHistogramAvailable, maybeFallbackLayer), + label: getReloadDataButton(layerName, isHistogramAvailable, maybeFallbackLayer), key: "reloadDataButton", } : null, { - label: this.getFindDataButton(layerName, isDisabled, isColorLayer, maybeVolumeTracing), + label: getFindDataButton(layerName, isDisabled, isColorLayer, maybeVolumeTracing), key: "findDataButton", }, isAnnotationLayer && !isOnlyAnnotationLayer ? { label: (
- {this.getDeleteAnnotationLayerDropdownOption( + {getDeleteAnnotationLayerDropdownOption( readableName, layerType, layer.tracingId, @@ -704,17 +798,17 @@ class DatasetSettings extends React.PureComponent { } : null, !isDisabled - ? { label: this.getEditMinMaxButton(layerName, isInEditMode), key: "editMinMax" } + ? { label: getEditMinMaxButton(layerName, isInEditMode), key: "editMinMax" } : null, hasHistogram && !isDisabled - ? { label: this.getClipButton(layerName, isInEditMode), key: "clipButton" } + ? { label: getClipButton(layerName, isInEditMode), key: "clipButton" } : null, - this.props.dataset.dataStore.jobsEnabled && - this.props.dataset.dataStore.jobsSupportedByAvailableWorkers.includes( + dataset.dataStore.jobsEnabled && + dataset.dataStore.jobsSupportedByAvailableWorkers.includes( APIJobType.COMPUTE_SEGMENT_INDEX_FILE, ) ? { - label: this.getComputeSegmentIndexFileButton(layerName, isSegmentation), + label: getComputeSegmentIndexFileButton(layerName, isSegmentation), key: "computeSegmentIndexFileButton", } : null, @@ -733,7 +827,7 @@ class DatasetSettings extends React.PureComponent { return (
{dragHandle} - {this.getEnableDisableLayerSwitch(isDisabled, onChange)} + {getEnableDisableLayerSwitch(isDisabled, onChange)}
{ isInvalid={!readableLayerNameValidationResult.isValid} trimValue onChange={(newName) => { - this.props.onEditAnnotationLayer(volumeDescriptor.tracingId, { + onEditAnnotationLayer(volumeDescriptor.tracingId, { name: newName, }); }} @@ -786,7 +880,7 @@ class DatasetSettings extends React.PureComponent { }} >
- + {canBeMadeEditable ? ( { icon={} hoveredIcon={} onClick={() => { - this.setState({ - isAddVolumeLayerModalVisible: true, - segmentationLayerWasPreselected: true, - preselectedSegmentationLayerName: layer.name, - }); + setIsAddVolumeLayerModalVisible(true); + setSegmentationLayerWasPreselected(true); + setPreselectedSegmentationLayerName(layer.name); }} /> @@ -839,7 +931,7 @@ class DatasetSettings extends React.PureComponent { /> ) : null} - {isColorLayer ? null : this.getOptionalDownsampleVolumeIcon(maybeVolumeTracing)} + {isColorLayer ? null : getOptionalDownsampleVolumeIcon(maybeVolumeTracing)}
@@ -853,11 +945,14 @@ class DatasetSettings extends React.PureComponent { ); }; - getColorLayerSpecificSettings = ( + const getColorLayerSpecificSettings = ( layerConfiguration: DatasetLayerConfiguration, - layerName: string, + onGammaCorrectionValueChange: (value: number) => void, + onColorChange: (value: Vector3) => void, + onIsInvertedChange: () => void, ) => { const defaultSettings = getDefaultLayerViewConfiguration(); + return (
{ max={10} roundTo={3} value={layerConfiguration.gammaCorrectionValue} - onChange={_.partial(this.props.onChangeLayer, layerName, "gammaCorrectionValue")} + onChange={onGammaCorrectionValueChange} defaultValue={defaultSettings.gammaCorrectionValue} /> { {
- this.props.onChangeLayer( - layerName, - "isInverted", - layerConfiguration ? !layerConfiguration.isInverted : false, - ) - } + onClick={onIsInvertedChange} style={{ top: 4, right: 0, @@ -929,15 +1018,18 @@ class DatasetSettings extends React.PureComponent { ); }; - getSegmentationSpecificSettings = (layerName: string) => { + const getSegmentationSpecificSettings = ( + layerName: string, + onSegmentationPatternOpacityChange: (value: number) => void, + ) => { const segmentationOpacitySetting = ( ); @@ -951,7 +1043,7 @@ class DatasetSettings extends React.PureComponent { ); }; - LayerSettings = ({ + const LayerSettings = ({ layerName, layerConfiguration, isColorLayer, @@ -965,12 +1057,32 @@ class DatasetSettings extends React.PureComponent { hasLessThanTwoColorLayers?: boolean; }) => { const { setNodeRef, transform, transition, isDragging } = useSortable({ id: layerName }); + const onAlphaChange = useCallback( + (value: number) => onChangeLayer(layerName, "alpha", value), + [layerName], + ); + const onGammaCorrectionValueChange = useCallback( + (value: number) => onChangeLayer(layerName, "gammaCorrectionValue", value), + [layerName], + ); + const onColorChange = useCallback( + (value: Vector3) => onChangeLayer(layerName, "color", value), + [layerName], + ); + const onIsInvertedChange = useCallback( + () => onChangeLayer(layerName, "isInverted", !layerConfiguration?.isInverted), + [layerName, layerConfiguration?.isInverted], + ); + const onSegmentationPatternOpacityChange = useCallback( + (value: number) => onChange("segmentationPatternOpacity", value), + [], + ); // Ensure that every layer needs a layer configuration and that color layers have a color layer. if (!layerConfiguration || (isColorLayer && !layerConfiguration.color)) { return null; } - const elementClass = getElementClass(this.props.dataset, layerName); + const elementClass = getElementClass(dataset, layerName); const { isDisabled, isInEditMode } = layerConfiguration; const betweenLayersMarginBottom = isLastLayer ? {} : { marginBottom: 30 }; @@ -992,15 +1104,11 @@ class DatasetSettings extends React.PureComponent { ); const isHistogramAvailable = isHistogramSupported(elementClass) && isColorLayer; - const layerSpecificDefaults = getSpecificDefaultsForLayer( - this.props.dataset, - layerName, - isColorLayer, - ); + const layerSpecificDefaults = getSpecificDefaultsForLayer(dataset, layerName, isColorLayer); return (
- {this.getLayerSettingsHeader( + {getLayerSettingsHeader( isDisabled, isColorLayer, isInEditMode, @@ -1016,31 +1124,35 @@ class DatasetSettings extends React.PureComponent { marginLeft: 10, }} > - {isHistogramAvailable && this.getHistogram(layerName, layerConfiguration)} + {isHistogramAvailable && getHistogram(layerName, layerConfiguration)} {isColorLayer - ? this.getColorLayerSpecificSettings(layerConfiguration, layerName) - : this.getSegmentationSpecificSettings(layerName)} + ? getColorLayerSpecificSettings( + layerConfiguration, + onGammaCorrectionValueChange, + onColorChange, + onIsInvertedChange, + ) + : getSegmentationSpecificSettings(layerName, onSegmentationPatternOpacityChange)}
)}
); }; - handleFindData = async ( + const handleFindData = async ( layerName: string, isDataLayer: boolean, volume: VolumeTracing | null | undefined, ) => { const { tracingStore } = Store.getState().annotation; - const { dataset } = this.props; let foundPosition; let foundMag; @@ -1051,7 +1163,7 @@ class DatasetSettings extends React.PureComponent { ); if ((!position || !mag) && volume.fallbackLayer) { - await this.handleFindData(volume.fallbackLayer, true, volume); + await handleFindData(volume.fallbackLayer, true, volume); return; } @@ -1084,12 +1196,12 @@ class DatasetSettings extends React.PureComponent { Toast.warning( `Couldn't find data within layer "${layerName}." Jumping to the center of the layer's bounding box.`, ); - this.props.onSetPosition(centerPosition); + onSetPosition(centerPosition); return; } - this.props.onSetPosition(foundPosition); - const zoomValue = this.props.onZoomToMag(layerName, foundMag); + onSetPosition(foundPosition); + const zoomValue = onZoomToMag(layerName, foundMag); Toast.success( `Jumping to position ${foundPosition .map((el) => Math.floor(el)) @@ -1097,24 +1209,21 @@ class DatasetSettings extends React.PureComponent { ); }; - reloadLayerData = async ( + const reloadLayerData = async ( layerName: string, isHistogramAvailable: boolean, maybeFallbackLayerName: string | null, ): Promise => { - await clearCache(this.props.dataset, maybeFallbackLayerName ?? layerName); - if (isHistogramAvailable) this.props.reloadHistogram(layerName); + await clearCache(dataset, maybeFallbackLayerName ?? layerName); + if (isHistogramAvailable) reloadHistogram(layerName); await api.data.reloadBuckets(layerName); Toast.success(`Successfully reloaded data of layer ${layerName}.`); }; - reloadHistogram = async (layerName: string): Promise => { - await clearCache(this.props.dataset, layerName); - this.props.reloadHistogram(layerName); - }; - - getVolumeMagsToDownsample = (volumeTracing: VolumeTracing | null | undefined): Array => { - if (this.props.task != null) { + const getVolumeMagsToDownsample = ( + volumeTracing: VolumeTracing | null | undefined, + ): Array => { + if (task != null) { return []; } @@ -1129,7 +1238,7 @@ class DatasetSettings extends React.PureComponent { ? fallbackLayerInfo.resolutions : // This is only a heuristic. At some point, user configuration // might make sense here. - getWidestMags(this.props.dataset); + getWidestMags(dataset); const getMaxDim = (mag: Vector3) => Math.max(...mag); @@ -1147,12 +1256,12 @@ class DatasetSettings extends React.PureComponent { return magsToDownsample; }; - getOptionalDownsampleVolumeIcon = (volumeTracing: VolumeTracing | null | undefined) => { + const getOptionalDownsampleVolumeIcon = (volumeTracing: VolumeTracing | null | undefined) => { if (!volumeTracing) { return null; } - const magsToDownsample = this.getVolumeMagsToDownsample(volumeTracing); + const magsToDownsample = getVolumeMagsToDownsample(volumeTracing); const hasExtensiveMags = magsToDownsample.length === 0; if (hasExtensiveMags) { @@ -1170,9 +1279,7 @@ class DatasetSettings extends React.PureComponent { ); }; - getSkeletonLayer = () => { - const { controlMode, annotation, onChangeRadius, userConfiguration, onChangeShowSkeletons } = - this.props; + const getSkeletonLayer = () => { const isPublicViewMode = controlMode === ControlModeEnum.VIEW; if (isPublicViewMode || annotation.skeleton == null) { @@ -1185,7 +1292,7 @@ class DatasetSettings extends React.PureComponent { const { showSkeletons, tracingId } = skeletonTracing; const activeNodeRadius = getActiveNode(skeletonTracing)?.radius ?? 0; return ( - +
{ > {!isOnlyAnnotationLayer - ? this.getDeleteAnnotationLayerButton( + ? getDeleteAnnotationLayerButton( readableName, AnnotationLayerEnum.Skeleton, annotation.skeleton.tracingId, @@ -1268,16 +1375,16 @@ class DatasetSettings extends React.PureComponent { max={userSettings.particleSize.maximum} step={0.1} value={userConfiguration.particleSize} - onChange={this.onChangeUser.particleSize} + onChange={onChangeUser.bind(null, "particleSize")} defaultValue={defaultState.userConfiguration.particleSize} /> - {this.props.isArbitraryMode ? ( + {isArbitraryMode ? ( ) : ( @@ -1287,54 +1394,49 @@ class DatasetSettings extends React.PureComponent { min={userSettings.clippingDistance.minimum} max={userSettings.clippingDistance.maximum} value={userConfiguration.clippingDistance} - onChange={this.onChangeUser.clippingDistance} + onChange={onChangeUser.bind(null, "clippingDistance")} defaultValue={defaultState.userConfiguration.clippingDistance} /> )} {" "}
) : null} -
+ ); }; - showAddVolumeLayerModal = () => { - this.setState({ - isAddVolumeLayerModalVisible: true, - }); + const showAddVolumeLayerModal = () => { + setIsAddVolumeLayerModalVisible(true); }; - hideAddVolumeLayerModal = () => { - this.setState({ - isAddVolumeLayerModalVisible: false, - segmentationLayerWasPreselected: false, - preselectedSegmentationLayerName: undefined, - }); + const hideAddVolumeLayerModal = () => { + setIsAddVolumeLayerModalVisible(false); + setSegmentationLayerWasPreselected(false); + setPreselectedSegmentationLayerName(undefined); }; - addSkeletonAnnotationLayer = async () => { - this.props.addSkeletonLayerToAnnotation(); + const addSkeletonAnnotationLayer = async () => { + addSkeletonLayerToAnnotation(); await Model.ensureSavedState(); location.reload(); }; - saveViewConfigurationAsDefault = () => { - const { dataset, datasetConfiguration } = this.props; + const saveViewConfigurationAsDefault = () => { const dataSource: Array<{ name: string; description?: string; @@ -1434,12 +1536,12 @@ class DatasetSettings extends React.PureComponent { }); }; - onSortLayerSettingsEnd = (event: DragEndEvent) => { + const onSortLayerSettingsEnd = (event: DragEndEvent) => { const { active, over } = event; // Fix for having a grabbing cursor during dragging from https://github.com/clauderic/react-sortable-hoc/issues/328#issuecomment-1005835670. document.body.classList.remove("is-dragging"); - const { colorLayerOrder } = this.props.datasetConfiguration; + const { colorLayerOrder } = datasetConfiguration; if (over) { const oldIndex = colorLayerOrder.indexOf(active.id as string); @@ -1453,204 +1555,117 @@ class DatasetSettings extends React.PureComponent { [newIndexClipped, 0, movedElement], ], }); - this.props.onChange("colorLayerOrder", newLayerOrder); + onChange("colorLayerOrder", newLayerOrder); } }; - render() { - const { layers, colorLayerOrder } = this.props.datasetConfiguration; - const LayerSettings = this.LayerSettings; + const { layers, colorLayerOrder } = datasetConfiguration; - const segmentationLayerNames = Object.keys(layers).filter( - (layerName) => !getIsColorLayer(this.props.dataset, layerName), + const segmentationLayerNames = Object.keys(layers).filter( + (layerName) => !getIsColorLayer(dataset, layerName), + ); + const hasLessThanTwoColorLayers = colorLayerOrder.length < 2; + const colorLayerSettings = colorLayerOrder.map((layerName, index) => { + return ( + ); - const hasLessThanTwoColorLayers = colorLayerOrder.length < 2; - const colorLayerSettings = colorLayerOrder.map((layerName, index) => { - return ( - - ); - }); - const segmentationLayerSettings = segmentationLayerNames.map((layerName, index) => { - return ( - - ); - }); + }); + const segmentationLayerSettings = segmentationLayerNames.map((layerName, index) => { + return ( + + ); + }); - const state = Store.getState(); - const canBeMadeHybrid = - this.props.annotation.skeleton === null && - this.props.annotation.annotationType === APIAnnotationTypeEnum.Explorational && - state.task === null; + const canBeMadeHybrid = + annotation.skeleton === null && + annotation.annotationType === APIAnnotationTypeEnum.Explorational && + task === null; - return ( -
- - colorLayerOrder.length > 1 && document.body.classList.add("is-dragging") - } + return ( +
+ colorLayerOrder.length > 1 && document.body.classList.add("is-dragging")} + > + layerName)} + strategy={verticalListSortingStrategy} > - layerName)} - strategy={verticalListSortingStrategy} - > - {colorLayerSettings} - - - - {segmentationLayerSettings} - {this.getSkeletonLayer()} - - {this.props.annotation.restrictions.allowUpdate && - this.props.controlMode === ControlModeEnum.TRACE ? ( - <> - - - - - - ) : null} + {colorLayerSettings} + + - {this.props.annotation.restrictions.allowUpdate && canBeMadeHybrid ? ( + {segmentationLayerSettings} + {getSkeletonLayer()} + + {annotation.restrictions.allowUpdate && controlMode === ControlModeEnum.TRACE ? ( + <> + - - ) : null} - - {this.props.controlMode === ControlModeEnum.VIEW && this.props.isAdminOrDatasetManager ? ( - - - - - - ) : null} - - {this.state.layerToMergeWithFallback != null ? ( - this.setState({ layerToMergeWithFallback: null })} - /> - ) : null} - - {this.state.isAddVolumeLayerModalVisible ? ( - - ) : null} -
- ); - } -} - -const mapStateToProps = (state: WebknossosState) => ({ - userConfiguration: state.userConfiguration, - datasetConfiguration: state.datasetConfiguration, - histogramData: state.temporaryConfiguration.histogramData, - dataset: state.dataset, - annotation: state.annotation, - task: state.task, - controlMode: state.temporaryConfiguration.controlMode, - isArbitraryMode: Constants.MODES_ARBITRARY.includes(state.temporaryConfiguration.viewMode), - isAdminOrDatasetManager: - state.activeUser != null ? Utils.isUserAdminOrDatasetManager(state.activeUser) : false, - isAdminOrManager: state.activeUser != null ? Utils.isUserAdminOrManager(state.activeUser) : false, - isSuperUser: state.activeUser?.isSuperUser || false, -}); - -const mapDispatchToProps = (dispatch: Dispatch) => ({ - onChange(propertyName: keyof DatasetConfiguration, value: ValueOf) { - dispatch(updateDatasetSettingAction(propertyName, value)); - }, - - onChangeUser(propertyName: keyof UserConfiguration, value: ValueOf) { - dispatch(updateUserSettingAction(propertyName, value)); - }, + + ) : null} - onChangeLayer( - layerName: string, - propertyName: keyof DatasetLayerConfiguration, - value: ValueOf, - ) { - dispatch(updateLayerSettingAction(layerName, propertyName, value)); - }, - - onClipHistogram(layerName: string, shouldAdjustClipRange: boolean) { - return dispatchClipHistogramAsync(layerName, shouldAdjustClipRange, dispatch); - }, - - onChangeRadius(radius: number) { - dispatch(setNodeRadiusAction(radius)); - }, - - onSetPosition(position: Vector3) { - dispatch(setPositionAction(position)); - }, - - onChangeShowSkeletons(showSkeletons: boolean) { - dispatch(setShowSkeletonsAction(showSkeletons)); - }, - - onZoomToMag(layerName: string, mag: Vector3) { - const targetZoomValue = getMaxZoomValueForMag(Store.getState(), layerName, mag); - dispatch(setZoomStepAction(targetZoomValue)); - return targetZoomValue; - }, - - onEditAnnotationLayer(tracingId: string, layerProperties: EditableLayerProperties) { - dispatch(editAnnotationLayerAction(tracingId, layerProperties)); - }, - - reloadHistogram(layerName: string) { - dispatch(reloadHistogramAction(layerName)); - }, - - addSkeletonLayerToAnnotation() { - dispatch( - pushSaveQueueTransactionIsolated( - addLayerToAnnotation({ - typ: "Skeleton", - name: "skeleton", - fallbackLayerName: undefined, - }), - ), - ); - }, + {annotation.restrictions.allowUpdate && canBeMadeHybrid ? ( + + + + ) : null} + + {controlMode === ControlModeEnum.VIEW && isAdminOrDatasetManager ? ( + + + + + + ) : null} - deleteAnnotationLayer(tracingId: string, type: AnnotationLayerType, layerName: string) { - dispatch(pushSaveQueueTransaction([deleteAnnotationLayer(tracingId, layerName, type)])); - }, -}); + {layerToMergeWithFallback != null ? ( + setLayerToMergeWithFallback(null)} + /> + ) : null} + + {isAddVolumeLayerModalVisible ? ( + + ) : null} +
+ ); +}; -const connector = connect(mapStateToProps, mapDispatchToProps); -export default connector(DatasetSettings); +export default DatasetSettings; diff --git a/frontend/javascripts/viewer/view/left-border-tabs/mapping_settings_view.tsx b/frontend/javascripts/viewer/view/left-border-tabs/mapping_settings_view.tsx index 77c2fed2c9b..81bb2f03ff0 100644 --- a/frontend/javascripts/viewer/view/left-border-tabs/mapping_settings_view.tsx +++ b/frontend/javascripts/viewer/view/left-border-tabs/mapping_settings_view.tsx @@ -1,10 +1,11 @@ import { Select } from "antd"; import FastTooltip from "components/fast_tooltip"; +import { useWkSelector } from "libs/react_hooks"; import * as Utils from "libs/utils"; import messages from "messages"; -import React from "react"; -import { connect } from "react-redux"; -import type { APISegmentationLayer } from "types/api_types"; +import type React from "react"; +import { Fragment, useCallback, useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; import { MappingStatusEnum } from "viewer/constants"; import { isAnnotationOwner } from "viewer/model/accessors/annotation_accessor"; import { @@ -14,50 +15,22 @@ import { import { getEditableMappingForVolumeTracingId, hasEditableMapping, - isMappingLocked, + isMappingLocked as isMappingLockedAccessor, } from "viewer/model/accessors/volumetracing_accessor"; -import { - ensureLayerMappingsAreLoadedAction, - setLayerMappingsAction, -} from "viewer/model/actions/dataset_actions"; +import { ensureLayerMappingsAreLoadedAction } from "viewer/model/actions/dataset_actions"; import { setHideUnmappedIdsAction, setMappingAction, setMappingEnabledAction, } from "viewer/model/actions/settings_actions"; -import type { EditableMapping, Mapping, MappingType, WebknossosState } from "viewer/store"; +import type { MappingType } from "viewer/store"; import { SwitchSetting } from "viewer/view/components/setting_input_views"; const { Option, OptGroup } = Select; -type OwnProps = { +type Props = { layerName: string; }; -type StateProps = { - segmentationLayer: APISegmentationLayer | null | undefined; - isMappingEnabled: boolean; - mapping: Mapping | null | undefined; - mappingName: string | null | undefined; - hideUnmappedIds: boolean | null | undefined; - mappingType: MappingType; - editableMapping: EditableMapping | null | undefined; - isMappingLocked: boolean; - isMergerModeEnabled: boolean; - allowUpdate: boolean; - isEditableMappingActive: boolean; - isAnnotationLockedByOwner: boolean; - isOwner: boolean; -} & typeof mapDispatchToProps; -type Props = OwnProps & StateProps; -type State = { - // shouldMappingBeEnabled is the UI state which is directly connected to the - // toggle button. The actual mapping in the store is only activated when - // the user selects a mapping from the dropdown (which is only possible after - // using the toggle). This is why, there is this.state.shouldMappingBeEnabled and - // this.props.isMappingEnabled - shouldMappingBeEnabled: boolean; - isRefreshingMappingList: boolean; -}; const needle = "##"; @@ -71,223 +44,184 @@ const unpackMappingNameAndCategory = (packedString: string) => { return [mappingName, categoryName]; }; -class MappingSettingsView extends React.Component { - state = { - shouldMappingBeEnabled: false, - isRefreshingMappingList: false, - }; +const MappingSettingsView: React.FC = (props) => { + const { layerName } = props; + const dispatch = useDispatch(); + const [shouldMappingBeEnabled, setShouldMappingBeEnabled] = useState(false); - componentDidMount() { - if (this.props.isMappingEnabled) { - this.ensureMappingsAreLoaded(); + const activeMappingInfo = useWkSelector((state) => + getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, layerName), + ); + const segmentationLayer = useWkSelector((state) => + getSegmentationLayerByName(state.dataset, layerName), + ); + const editableMapping = useWkSelector((state) => + getEditableMappingForVolumeTracingId(state, segmentationLayer.tracingId), + ); + + const { hideUnmappedIds, mapping, mappingName, mappingType } = activeMappingInfo; + + const isMappingEnabled = activeMappingInfo.mappingStatus === MappingStatusEnum.ENABLED; + const isMergerModeEnabled = useWkSelector( + (state) => state.temporaryConfiguration.isMergerModeEnabled, + ); + const allowUpdate = useWkSelector((state) => state.annotation.restrictions.allowUpdate); + const isEditableMappingActive = useWkSelector((state) => hasEditableMapping(state, layerName)); + const isMappingLocked = useWkSelector((state) => isMappingLockedAccessor(state, layerName)); + const isAnnotationLockedByOwner = useWkSelector((state) => state.annotation.isLockedByOwner); + const isOwner = useWkSelector((state) => isAnnotationOwner(state)); + + const ensureMappingsAreLoaded = useCallback(async () => { + if (!segmentationLayer) { + return; } - } - componentDidUpdate(prevProps: Props) { - if (this.props.isMappingEnabled !== prevProps.isMappingEnabled) { - this.ensureMappingsAreLoaded(); + dispatch(ensureLayerMappingsAreLoadedAction(segmentationLayer.name)); + }, [dispatch, segmentationLayer]); + + useEffect(() => { + if (isMappingEnabled) { + ensureMappingsAreLoaded(); } - } + }, [isMappingEnabled, ensureMappingsAreLoaded]); - handleChangeHideUnmappedSegments = (hideUnmappedIds: boolean) => { - this.props.setHideUnmappedIds(this.props.layerName, hideUnmappedIds); + const handleChangeHideUnmappedSegments = (hideUnmappedIds: boolean) => { + dispatch(setHideUnmappedIdsAction(layerName, hideUnmappedIds)); }; - handleChangeMapping = (packedMappingNameWithCategory: string): void => { + const handleChangeMapping = (packedMappingNameWithCategory: string): void => { const [mappingName, mappingType] = unpackMappingNameAndCategory(packedMappingNameWithCategory); if (mappingType !== "JSON" && mappingType !== "HDF5") { throw new Error("Invalid mapping type"); } - this.props.setMapping(this.props.layerName, mappingName, mappingType, { - showLoadingIndicator: true, - }); + dispatch( + setMappingAction(layerName, mappingName, mappingType, { + showLoadingIndicator: true, + }), + ); // @ts-ignore if (document.activeElement) document.activeElement.blur(); }; - async ensureMappingsAreLoaded() { - const { segmentationLayer } = this.props; - - if (!segmentationLayer) { - return; - } - - this.props.ensureLayerMappingsAreLoaded(segmentationLayer.name); - } - - handleSetMappingEnabled = (shouldMappingBeEnabled: boolean): void => { + const handleSetMappingEnabled = (shouldMappingBeEnabled: boolean): void => { if (shouldMappingBeEnabled) { - this.ensureMappingsAreLoaded(); + ensureMappingsAreLoaded(); } - this.setState({ - shouldMappingBeEnabled, - }); + setShouldMappingBeEnabled(shouldMappingBeEnabled); - if (this.props.mappingName != null) { - this.props.setMappingEnabled(this.props.layerName, shouldMappingBeEnabled); + if (mappingName != null) { + dispatch(setMappingEnabledAction(layerName, shouldMappingBeEnabled)); } }; - render() { - const { - segmentationLayer, - mappingName, - editableMapping, - isMergerModeEnabled, - mapping, - hideUnmappedIds, - isMappingEnabled, - isMappingLocked, - allowUpdate, - isEditableMappingActive, - isAnnotationLockedByOwner, - isOwner, - } = this.props; - - const availableMappings = segmentationLayer?.mappings != null ? segmentationLayer.mappings : []; - const availableAgglomerates = segmentationLayer?.agglomerates || []; - // Antd does not render the placeholder when a value is defined (even when it's null). - // That's why, we only pass the value when it's actually defined. - const selectValueProp = - mappingName != null - ? { - value: - editableMapping != null - ? `${editableMapping.baseMappingName} (${mappingName})` - : mappingName, - } - : {}; - - const renderCategoryOptions = (optionStrings: string[], category: MappingType) => { - const useGroups = availableMappings.length > 0 && availableAgglomerates.length > 0; - const elements = optionStrings - .slice() - .sort(Utils.localeCompareBy((optionString) => optionString)) - .map((optionString) => ( - - )); - return useGroups ? {elements} : elements; - }; + const availableMappings = segmentationLayer?.mappings != null ? segmentationLayer.mappings : []; + const availableAgglomerates = segmentationLayer?.agglomerates || []; + // Antd does not render the placeholder when a value is defined (even when it's null). + // That's why, we only pass the value when it's actually defined. + const selectValueProp = + mappingName != null + ? { + value: + editableMapping != null + ? `${editableMapping.baseMappingName} (${mappingName})` + : mappingName, + } + : {}; + + const renderCategoryOptions = (optionStrings: string[], category: MappingType) => { + const useGroups = availableMappings.length > 0 && availableAgglomerates.length > 0; + const elements = optionStrings + .slice() + .sort(Utils.localeCompareBy((optionString) => optionString)) + .map((optionString) => ( + + )); + return useGroups ? {elements} : elements; + }; - // The mapping toggle should be active if either the user clicked on it (this.state.shouldMappingBeEnabled) - // or a mapping was activated, e.g. from the API or by selecting one from the dropdown (this.props.isMappingEnabled). - const shouldMappingBeEnabled = this.state.shouldMappingBeEnabled || isMappingEnabled; - const renderHideUnmappedSegmentsSwitch = - (shouldMappingBeEnabled || isMergerModeEnabled) && - mapping && - this.props.mappingType === "JSON" && - hideUnmappedIds != null; - const isDisabled = isEditableMappingActive || isMappingLocked || isAnnotationLockedByOwner; - const disabledMessage = !allowUpdate - ? messages["tracing.read_only_mode_notification"](isAnnotationLockedByOwner, isOwner) - : isEditableMappingActive - ? "The mapping has been edited through proofreading actions and can no longer be disabled or changed." - : isMappingEnabled - ? "This mapping has been locked to this annotation, because the segmentation was modified while it was active. It can no longer be disabled or changed." - : "The segmentation was modified while no mapping was active. To ensure a consistent state, mappings can no longer be enabled."; - return ( - - { - /* Only display the mapping selection when merger mode is not active - to avoid conflicts in the logic of the UI. */ - !this.props.isMergerModeEnabled ? ( - - -
- -
-
+ // The mapping toggle should be active if either the user clicked on it (shouldMappingBeEnabled) + // or a mapping was activated, e.g. from the API or by selecting one from the dropdown (isMappingEnabled). + const isMappingToggleActive = shouldMappingBeEnabled || isMappingEnabled; + const renderHideUnmappedSegmentsSwitch = + (isMappingToggleActive || isMergerModeEnabled) && + mapping && + mappingType === "JSON" && + hideUnmappedIds != null; + const isDisabled = isEditableMappingActive || isMappingLocked || isAnnotationLockedByOwner; + const disabledMessage = !allowUpdate + ? messages["tracing.read_only_mode_notification"](isAnnotationLockedByOwner, isOwner) + : isEditableMappingActive + ? "The mapping has been edited through proofreading actions and can no longer be disabled or changed." + : isMappingEnabled + ? "This mapping has been locked to this annotation, because the segmentation was modified while it was active. It can no longer be disabled or changed." + : "The segmentation was modified while no mapping was active. To ensure a consistent state, mappings can no longer be enabled."; + return ( + + { + /* Only display the mapping selection when merger mode is not active + to avoid conflicts in the logic of the UI. */ + !isMergerModeEnabled ? ( + + +
+ +
+
- {/* + {/* Show mapping-select even when the mapping is disabled but the UI was used before (i.e., mappingName != null) */} - {shouldMappingBeEnabled ? ( - - ) : null} -
- ) : null - } - {renderHideUnmappedSegmentsSwitch ? ( - - ) : null} -
- ); - } -} - -const mapDispatchToProps = { - setMappingEnabled: setMappingEnabledAction, - setAvailableMappingsForLayer: setLayerMappingsAction, - setHideUnmappedIds: setHideUnmappedIdsAction, - setMapping: setMappingAction, - ensureLayerMappingsAreLoaded: ensureLayerMappingsAreLoadedAction, -}; - -function mapStateToProps(state: WebknossosState, ownProps: OwnProps) { - const activeMappingInfo = getMappingInfo( - state.temporaryConfiguration.activeMappingByLayer, - ownProps.layerName, + {isMappingToggleActive ? ( + + ) : null} + + ) : null + } + {renderHideUnmappedSegmentsSwitch ? ( + + ) : null} + ); - const segmentationLayer = getSegmentationLayerByName(state.dataset, ownProps.layerName); - const editableMapping = getEditableMappingForVolumeTracingId(state, segmentationLayer.tracingId); - - return { - hideUnmappedIds: activeMappingInfo.hideUnmappedIds, - isMappingEnabled: activeMappingInfo.mappingStatus === MappingStatusEnum.ENABLED, - mapping: activeMappingInfo.mapping, - mappingName: activeMappingInfo.mappingName, - mappingType: activeMappingInfo.mappingType, - segmentationLayer, - isMergerModeEnabled: state.temporaryConfiguration.isMergerModeEnabled, - allowUpdate: state.annotation.restrictions.allowUpdate, - editableMapping, - isEditableMappingActive: hasEditableMapping(state, ownProps.layerName), - isMappingLocked: isMappingLocked(state, ownProps.layerName), - isAnnotationLockedByOwner: state.annotation.isLockedByOwner, - isOwner: isAnnotationOwner(state), - }; -} +}; -const connector = connect(mapStateToProps, mapDispatchToProps); -export default connector(MappingSettingsView); +export default MappingSettingsView;