feat: Add Deck.gl Contour Layer (#24154)

This commit is contained in:
Matthew Chiang
2023-10-10 04:20:37 -05:00
committed by GitHub
parent 42d0474cc2
commit 512fb9a0bd
18 changed files with 1092 additions and 3 deletions

View File

@@ -0,0 +1,107 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with work for additional information
* regarding copyright ownership. The ASF licenses file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { styled, t } from '@superset-ui/core';
import { ContourOptionProps } from './types';
import ContourPopoverTrigger from './ContourPopoverTrigger';
import OptionWrapper from '../DndColumnSelectControl/OptionWrapper';
const StyledOptionWrapper = styled(OptionWrapper)`
max-width: 100%;
min-width: 100%;
`;
const StyledListItem = styled.li`
display: flex;
align-items: center;
`;
const ColorPatch = styled.div<{ formattedColor: string }>`
background-color: ${({ formattedColor }) => formattedColor};
height: ${({ theme }) => theme.gridUnit}px;
width: ${({ theme }) => theme.gridUnit}px;
margin: 0 ${({ theme }) => theme.gridUnit}px;
`;
const ContourOption = ({
contour,
index,
saveContour,
onClose,
onShift,
}: ContourOptionProps) => {
const { lowerThreshold, upperThreshold, color, strokeWidth } = contour;
const isIsoband = upperThreshold;
const formattedColor = color
? `rgba(${color.r}, ${color.g}, ${color.b}, 1)`
: 'undefined';
const formatIsoline = (threshold: number, width: number) =>
`${t('Threshold')}: ${threshold}, ${t('color')}: ${formattedColor}, ${t(
'stroke width',
)}: ${width}`;
const formatIsoband = (threshold: number[]) =>
`${t('Threshold')}: [${threshold[0]}, ${
threshold[1]
}], color: ${formattedColor}`;
const displayString = isIsoband
? formatIsoband([lowerThreshold || -1, upperThreshold])
: formatIsoline(lowerThreshold || -1, strokeWidth);
const overlay = (
<div className="contour-tooltip-overlay">
<StyledListItem>
{t('Threshold: ')}
{isIsoband
? `[${lowerThreshold}, ${upperThreshold}]`
: `${lowerThreshold}`}
</StyledListItem>
<StyledListItem>
{t('Color: ')}
<ColorPatch formattedColor={formattedColor} /> {formattedColor}
</StyledListItem>
{!isIsoband && (
<StyledListItem>{`${t(
'Stroke Width:',
)} ${strokeWidth}`}</StyledListItem>
)}
</div>
);
return (
<ContourPopoverTrigger saveContour={saveContour} value={contour}>
<StyledOptionWrapper
index={index}
label={displayString}
type="ContourOption"
withCaret
clickClose={onClose}
onShiftOptions={onShift}
tooltipOverlay={overlay}
/>
</ContourPopoverTrigger>
);
};
export default ContourOption;

View File

@@ -0,0 +1,351 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState, useEffect } from 'react';
import { Row, Col } from 'src/components';
import Button from 'src/components/Button';
import Tabs from 'src/components/Tabs';
import { legacyValidateInteger, styled, t } from '@superset-ui/core';
import ControlHeader from '../../ControlHeader';
import TextControl from '../TextControl';
import ColorPickerControl from '../ColorPickerControl';
import {
ContourPopoverControlProps,
ColorType,
ContourType,
ErrorMapType,
} from './types';
enum CONTOUR_TYPES {
Isoline = 'ISOLINE',
Isoband = 'ISOBAND',
}
const ContourActionsContainer = styled.div`
margin-top: ${({ theme }) => theme.gridUnit * 2}px;
`;
const StyledRow = styled(Row)`
width: 100%;
gap: ${({ theme }) => theme.gridUnit * 2}px;
`;
const isIsoband = (contour: ContourType) => {
if (Object.keys(contour).length < 4) {
return false;
}
return contour.upperThreshold && contour.lowerThreshold;
};
const getTabKey = (contour: ContourType | undefined) =>
contour && isIsoband(contour) ? CONTOUR_TYPES.Isoband : CONTOUR_TYPES.Isoline;
const determineErrorMap = (tab: string, contour: ContourType) => {
const errorMap: ErrorMapType = {
lowerThreshold: [],
upperThreshold: [],
strokeWidth: [],
color: [],
};
// Isoline and Isoband validation
const lowerThresholdError = legacyValidateInteger(contour.lowerThreshold);
if (lowerThresholdError) errorMap.lowerThreshold.push(lowerThresholdError);
// Isoline only validation
if (tab === CONTOUR_TYPES.Isoline) {
const strokeWidthError = legacyValidateInteger(contour.strokeWidth);
if (strokeWidthError) errorMap.strokeWidth.push(strokeWidthError);
}
// Isoband only validation
if (tab === CONTOUR_TYPES.Isoband) {
const upperThresholdError = legacyValidateInteger(contour.upperThreshold);
if (upperThresholdError) errorMap.upperThreshold.push(upperThresholdError);
if (
!upperThresholdError &&
!lowerThresholdError &&
contour.upperThreshold &&
contour.lowerThreshold
) {
const lower = parseFloat(contour.lowerThreshold);
const upper = parseFloat(contour.upperThreshold);
if (lower >= upper) {
errorMap.lowerThreshold.push(
t('Lower threshold must be lower than upper threshold'),
);
errorMap.upperThreshold.push(
t('Upper threshold must be greater than lower threshold'),
);
}
}
}
return errorMap;
};
const convertContourToNumeric = (contour: ContourType) => {
const formattedContour = { ...contour };
const numericKeys = ['lowerThreshold', 'upperThreshold', 'strokeWidth'];
numericKeys.forEach(key => {
formattedContour[key] = Number(formattedContour[key]);
});
return formattedContour;
};
const formatIsoline = (contour: ContourType) => ({
color: contour.color,
lowerThreshold: contour.lowerThreshold,
upperThreshold: undefined,
strokeWidth: contour.strokeWidth,
});
const formatIsoband = (contour: ContourType) => ({
color: contour.color,
lowerThreshold: contour.lowerThreshold,
upperThreshold: contour.upperThreshold,
strokeWidth: undefined,
});
const DEFAULT_CONTOUR = {
lowerThreshold: undefined,
upperThreshold: undefined,
color: undefined,
strokeWidth: undefined,
};
const ContourPopoverControl = ({
value: initialValue,
onSave,
onClose,
}: ContourPopoverControlProps) => {
const [currentTab, setCurrentTab] = useState(getTabKey(initialValue));
const [contour, setContour] = useState(initialValue || DEFAULT_CONTOUR);
const [validationErrors, setValidationErrors] = useState(
determineErrorMap(getTabKey(initialValue), initialValue || DEFAULT_CONTOUR),
);
const [isComplete, setIsComplete] = useState(false);
useEffect(() => {
const isIsoband = currentTab === CONTOUR_TYPES.Isoband;
const validLower =
Boolean(contour.lowerThreshold) || contour.lowerThreshold === 0;
const validUpper =
Boolean(contour.upperThreshold) || contour.upperThreshold === 0;
const validStrokeWidth =
Boolean(contour.strokeWidth) || contour.strokeWidth === 0;
const validColor =
typeof contour.color === 'object' &&
'r' in contour.color &&
typeof contour.color.r === 'number' &&
'g' in contour.color &&
typeof contour.color.g === 'number' &&
'b' in contour.color &&
typeof contour.color.b === 'number' &&
'a' in contour.color &&
typeof contour.color.a === 'number';
const errors = determineErrorMap(currentTab, contour);
if (errors !== validationErrors) setValidationErrors(errors);
const sectionIsComplete = isIsoband
? validLower && validUpper && validColor
: validLower && validColor && validStrokeWidth;
if (sectionIsComplete !== isComplete) setIsComplete(sectionIsComplete);
}, [contour, currentTab]);
const onTabChange = (activeKey: any) => {
setCurrentTab(activeKey);
};
const updateStrokeWidth = (value: number | string) => {
const newContour = { ...contour };
newContour.strokeWidth = value;
setContour(newContour);
};
const updateColor = (rgb: ColorType) => {
const newContour = { ...contour };
newContour.color = { ...rgb, a: 100 };
setContour(newContour);
};
const updateLowerThreshold = (value: number | string) => {
const newContour = { ...contour };
newContour.lowerThreshold = value;
setContour(newContour);
};
const updateUpperThreshold = (value: number | string) => {
const newContour = { ...contour };
newContour.upperThreshold = value;
setContour(newContour);
};
const containsErrors = () => {
const keys = Object.keys(validationErrors);
return keys.some(key => validationErrors[key].length > 0);
};
const handleSave = () => {
if (isComplete && onSave) {
const newContour =
currentTab === CONTOUR_TYPES.Isoline
? formatIsoline(contour)
: formatIsoband(contour);
onSave(convertContourToNumeric(newContour));
if (onClose) onClose();
}
};
return (
<>
<Tabs
id="contour-edit-tabs"
onChange={onTabChange}
defaultActiveKey={getTabKey(initialValue)}
>
<Tabs.TabPane
className="adhoc-filter-edit-tab"
key={CONTOUR_TYPES.Isoline}
tab={t('Isoline')}
>
<div key={CONTOUR_TYPES.Isoline} className="isoline-popover-section">
<StyledRow>
<Col flex="1">
<ControlHeader
name="isoline-threshold"
label={t('Threshold')}
description={t(
'Defines the value that determines the boundary between different regions or levels in the data ',
)}
validationErrors={validationErrors.lowerThreshold}
hovered
/>
<TextControl
value={contour.lowerThreshold}
onChange={updateLowerThreshold}
/>
</Col>
</StyledRow>
<StyledRow>
<Col flex="1">
<ControlHeader
name="isoline-stroke-width"
label={t('Stroke Width')}
description={t('The width of the Isoline in pixels')}
validationErrors={validationErrors.strokeWidth}
hovered
/>
<TextControl
value={contour.strokeWidth || ''}
onChange={updateStrokeWidth}
/>
</Col>
<Col flex="1">
<ControlHeader
name="isoline-color"
label={t('Color')}
description={t('The color of the isoline')}
validationErrors={validationErrors.color}
hovered
/>
<ColorPickerControl
value={typeof contour === 'object' && contour?.color}
onChange={updateColor}
/>
</Col>
</StyledRow>
</div>
</Tabs.TabPane>
<Tabs.TabPane
className="adhoc-filter-edit-tab"
key={CONTOUR_TYPES.Isoband}
tab={t('Isoband')}
>
<div key={CONTOUR_TYPES.Isoband} className="isoline-popover-section">
<StyledRow>
<Col flex="1">
<ControlHeader
name="isoband-threshold-lower"
label={t('Lower Threshold')}
description={t(
'The lower limit of the threshold range of the Isoband',
)}
validationErrors={validationErrors.lowerThreshold}
hovered
/>
<TextControl
value={contour.lowerThreshold || ''}
onChange={updateLowerThreshold}
/>
</Col>
<Col flex="1">
<ControlHeader
name="isoband-threshold-upper"
label={t('Upper Threshold')}
description={t(
'The upper limit of the threshold range of the Isoband',
)}
validationErrors={validationErrors.upperThreshold}
hovered
/>
<TextControl
value={contour.upperThreshold || ''}
onChange={updateUpperThreshold}
/>
</Col>
</StyledRow>
<StyledRow>
<Col flex="1">
<ControlHeader
name="isoband-color"
label={t('Color')}
description={t('The color of the isoband')}
validationErrors={validationErrors.color}
hovered
/>
<ColorPickerControl
value={contour?.color}
onChange={updateColor}
/>
</Col>
</StyledRow>
</div>
</Tabs.TabPane>
</Tabs>
<ContourActionsContainer>
<Button buttonSize="small" onClick={onClose} cta>
{t('Close')}
</Button>
<Button
data-test="adhoc-filter-edit-popover-save-button"
disabled={!isComplete || containsErrors()}
buttonStyle="primary"
buttonSize="small"
className="m-r-5"
onClick={handleSave}
cta
>
{t('Save')}
</Button>
</ContourActionsContainer>
</>
);
};
export default ContourPopoverControl;

View File

@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState } from 'react';
import ContourPopoverControl from './ContourPopoverControl';
import ControlPopover from '../ControlPopover/ControlPopover';
import { ContourPopoverTriggerProps } from './types';
const ContourPopoverTrigger = ({
value: initialValue,
saveContour,
isControlled,
visible: controlledVisibility,
toggleVisibility,
...props
}: ContourPopoverTriggerProps) => {
const [isVisible, setIsVisible] = useState(false);
const visible = isControlled ? controlledVisibility : isVisible;
const setVisibility =
isControlled && toggleVisibility ? toggleVisibility : setIsVisible;
const popoverContent = (
<ContourPopoverControl
value={initialValue}
onSave={saveContour}
onClose={() => setVisibility(false)}
/>
);
return (
<ControlPopover
trigger="click"
content={popoverContent}
defaultVisible={visible}
visible={visible}
onVisibleChange={setVisibility}
destroyTooltipOnHide
>
{props.children}
</ControlPopover>
);
};
export default ContourPopoverTrigger;

View File

@@ -0,0 +1,143 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with work for additional information
* regarding copyright ownership. The ASF licenses file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState, useEffect } from 'react';
import { styled, t } from '@superset-ui/core';
import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel';
import ContourPopoverTrigger from './ContourPopoverTrigger';
import ContourOption from './ContourOption';
import { ContourType, ContourControlProps } from './types';
const DEFAULT_CONTOURS: ContourType[] = [
{
lowerThreshold: 4,
color: { r: 255, g: 0, b: 255, a: 100 },
strokeWidth: 1,
zIndex: 0,
},
{
lowerThreshold: 5,
color: { r: 0, g: 255, b: 0, a: 100 },
strokeWidth: 2,
zIndex: 1,
},
{
lowerThreshold: 6,
upperThreshold: 10,
color: { r: 0, g: 0, b: 255, a: 100 },
zIndex: 2,
},
];
const NewContourFormatPlaceholder = styled('div')`
position: relative;
width: calc(100% - ${({ theme }) => theme.gridUnit}px);
bottom: ${({ theme }) => theme.gridUnit * 4}px;
left: 0;
`;
const ContourControl = ({ onChange, ...props }: ContourControlProps) => {
const [popoverVisible, setPopoverVisible] = useState(false);
const [contours, setContours] = useState<ContourType[]>(
props?.value ? props?.value : DEFAULT_CONTOURS,
);
useEffect(() => {
// add z-index to contours
const newContours = contours.map((contour, index) => ({
...contour,
zIndex: (index + 1) * 10,
}));
onChange?.(newContours);
}, [onChange, contours]);
const togglePopover = (visible: boolean) => {
setPopoverVisible(visible);
};
const handleClickGhostButton = () => {
togglePopover(true);
};
const saveContour = (contour: ContourType) => {
setContours([...contours, contour]);
togglePopover(false);
};
const removeContour = (index: number) => {
const newContours = [...contours];
newContours.splice(index, 1);
setContours(newContours);
};
const onShiftContour = (hoverIndex: number, dragIndex: number) => {
const newContours = [...contours];
[newContours[hoverIndex], newContours[dragIndex]] = [
newContours[dragIndex],
newContours[hoverIndex],
];
setContours(newContours);
};
const editContour = (contour: ContourType, index: number) => {
const newContours = [...contours];
newContours[index] = contour;
setContours(newContours);
};
const valuesRenderer = () =>
contours.map((contour, index) => (
<ContourOption
key={index}
saveContour={(newContour: ContourType) =>
editContour(newContour, index)
}
contour={contour}
index={index}
onClose={removeContour}
onShift={onShiftContour}
/>
));
const ghostButtonText = t('Click to add a contour');
return (
<>
<DndSelectLabel
onDrop={() => {}}
canDrop={() => true}
valuesRenderer={valuesRenderer}
accept={[]}
ghostButtonText={ghostButtonText}
onClickGhostButton={handleClickGhostButton}
{...props}
/>
<ContourPopoverTrigger
saveContour={saveContour}
isControlled
visible={popoverVisible}
toggleVisibility={setPopoverVisible}
>
<NewContourFormatPlaceholder />
</ContourPopoverTrigger>
</>
);
};
export default ContourControl;

View File

@@ -0,0 +1,55 @@
import { OptionValueType } from 'src/explore/components/controls/DndColumnSelectControl/types';
import { ControlComponentProps } from 'src/explore/components/Control';
export interface ColorType {
r: number;
g: number;
b: number;
a: number;
}
export interface ContourType extends OptionValueType {
color?: ColorType | undefined;
lowerThreshold?: any | undefined;
upperThreshold?: any | undefined;
strokeWidth?: any | undefined;
}
export interface ErrorMapType {
lowerThreshold: string[];
upperThreshold: string[];
strokeWidth: string[];
color: string[];
}
export interface ContourControlProps
extends ControlComponentProps<OptionValueType[]> {
contours?: {};
}
export interface ContourPopoverTriggerProps {
description?: string;
hovered?: boolean;
value?: ContourType;
children?: React.ReactNode;
saveContour: (contour: ContourType) => void;
isControlled?: boolean;
visible?: boolean;
toggleVisibility?: (visibility: boolean) => void;
}
export interface ContourPopoverControlProps {
description?: string;
hovered?: boolean;
value?: ContourType;
onSave?: (contour: ContourType) => void;
onClose?: () => void;
}
export interface ContourOptionProps {
contour: ContourType;
index: number;
saveContour: (contour: ContourType) => void;
onClose: (index: number) => void;
onShift: (hoverIndex: number, dragIndex: number) => void;
}