From 858d698decc2fe247b68ace682c55ce745b9ca9b Mon Sep 17 00:00:00 2001 From: Peter Pan Date: Fri, 9 Apr 2021 23:36:51 +0800 Subject: [PATCH] feat: color map of high-dimensional page (#950) * feat: show more menu in navbar * feat: able to drag and change run sidebar width * feat: color map of high-dimensional page * fix: disable color map when category is more than color size --- frontend/packages/core/public/icons/a.svg | 3 + .../core/public/icons/chevron-left.svg | 4 +- .../core/public/icons/chevron-right.svg | 4 +- .../packages/core/public/icons/chevron-up.svg | 4 +- frontend/packages/core/public/icons/reset.svg | 4 +- .../packages/core/public/icons/selection.svg | 4 +- .../packages/core/public/icons/three-d.svg | 6 +- .../public/locales/en/high-dimensional.json | 3 +- .../public/locales/zh/high-dimensional.json | 3 +- .../HighDimensionalPage/ChartOperations.tsx | 14 +-- .../HighDimensionalChart.tsx | 4 + .../HighDimensionalPage/LabelSearchInput.tsx | 28 ++--- .../src/components/ScatterChart/Component.tsx | 9 +- .../components/ScatterChart/Labels/Labels.tsx | 24 ++-- .../components/ScatterChart/Points/Points.tsx | 49 ++++---- .../components/ScatterChart/Points/index.ts | 4 +- .../components/ScatterChart/ScatterChart.ts | 85 +++++++++++++- .../core/src/components/ScatterChart/index.ts | 2 + .../core/src/components/ScatterChart/types.ts | 9 +- .../packages/core/src/components/Select.tsx | 62 +++++++--- .../core/src/pages/high-dimensional.tsx | 107 ++++++++++++------ .../src/resource/high-dimensional/index.ts | 3 + .../src/resource/high-dimensional/parser.ts | 78 ++++++++++++- .../src/resource/high-dimensional/types.ts | 15 ++- frontend/packages/core/src/types/index.ts | 23 ++++ frontend/packages/core/src/utils/chart.ts | 4 +- 26 files changed, 408 insertions(+), 147 deletions(-) create mode 100644 frontend/packages/core/public/icons/a.svg diff --git a/frontend/packages/core/public/icons/a.svg b/frontend/packages/core/public/icons/a.svg new file mode 100644 index 00000000..b8d8fe34 --- /dev/null +++ b/frontend/packages/core/public/icons/a.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/packages/core/public/icons/chevron-left.svg b/frontend/packages/core/public/icons/chevron-left.svg index eb699020..63ce89f5 100644 --- a/frontend/packages/core/public/icons/chevron-left.svg +++ b/frontend/packages/core/public/icons/chevron-left.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/packages/core/public/icons/chevron-right.svg b/frontend/packages/core/public/icons/chevron-right.svg index 387397f4..74a251b1 100644 --- a/frontend/packages/core/public/icons/chevron-right.svg +++ b/frontend/packages/core/public/icons/chevron-right.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/packages/core/public/icons/chevron-up.svg b/frontend/packages/core/public/icons/chevron-up.svg index 587c8ed8..1029ac89 100644 --- a/frontend/packages/core/public/icons/chevron-up.svg +++ b/frontend/packages/core/public/icons/chevron-up.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/packages/core/public/icons/reset.svg b/frontend/packages/core/public/icons/reset.svg index 3e4ddb21..410175d1 100644 --- a/frontend/packages/core/public/icons/reset.svg +++ b/frontend/packages/core/public/icons/reset.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/packages/core/public/icons/selection.svg b/frontend/packages/core/public/icons/selection.svg index 6d9205b8..a880739f 100644 --- a/frontend/packages/core/public/icons/selection.svg +++ b/frontend/packages/core/public/icons/selection.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/packages/core/public/icons/three-d.svg b/frontend/packages/core/public/icons/three-d.svg index 705febe1..e219ff14 100644 --- a/frontend/packages/core/public/icons/three-d.svg +++ b/frontend/packages/core/public/icons/three-d.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/frontend/packages/core/public/locales/en/high-dimensional.json b/frontend/packages/core/public/locales/en/high-dimensional.json index c73e05d7..0f8f42fd 100644 --- a/frontend/packages/core/public/locales/en/high-dimensional.json +++ b/frontend/packages/core/public/locales/en/high-dimensional.json @@ -1,5 +1,5 @@ { - "3d-label": "Enable/disable 3D labels mode", + "3d-label": "Show/Hide data detail", "component": "Component #{{index}}", "continue": "Resume", "data": "Data", @@ -22,6 +22,7 @@ }, "matched-result-count": "{{count}} matched.", "neighbors": "Neighbors", + "no-color-map": "No color map", "pause": "Pause", "perplexity": "Perplexity", "points": "Points", diff --git a/frontend/packages/core/public/locales/zh/high-dimensional.json b/frontend/packages/core/public/locales/zh/high-dimensional.json index a0bfff43..f614db1c 100644 --- a/frontend/packages/core/public/locales/zh/high-dimensional.json +++ b/frontend/packages/core/public/locales/zh/high-dimensional.json @@ -1,5 +1,5 @@ { - "3d-label": "开启/关闭3D数据标签", + "3d-label": "显示/隐藏数据详情", "component": "主成分{{index}}", "continue": "继续", "data": "数据", @@ -22,6 +22,7 @@ }, "matched-result-count": "匹配结果 {{count}}", "neighbors": "相邻数据点数量", + "no-color-map": "无颜色", "pause": "暂停", "perplexity": "困惑度", "points": "数据点", diff --git a/frontend/packages/core/src/components/HighDimensionalPage/ChartOperations.tsx b/frontend/packages/core/src/components/HighDimensionalPage/ChartOperations.tsx index a9e000ba..cab054b1 100644 --- a/frontend/packages/core/src/components/HighDimensionalPage/ChartOperations.tsx +++ b/frontend/packages/core/src/components/HighDimensionalPage/ChartOperations.tsx @@ -54,11 +54,8 @@ const Operations = styled.div` } } - &.three-d { - font-size: ${rem(20)}; - } - - &:hover { + &:hover, + &.active { color: var(--primary-focused-color); } @@ -69,11 +66,12 @@ const Operations = styled.div` `; type ChartOperationsProps = { + labelCloud: boolean; onToggleLabelCloud?: () => unknown; onReset?: () => unknown; }; -const ChartOperations: FunctionComponent = ({onToggleLabelCloud, onReset}) => { +const ChartOperations: FunctionComponent = ({labelCloud, onToggleLabelCloud, onReset}) => { const {t} = useTranslation('high-dimensional'); return ( @@ -86,9 +84,9 @@ const ChartOperations: FunctionComponent = ({onToggleLabel */} - onToggleLabelCloud?.()}> + onToggleLabelCloud?.()} className={labelCloud ? 'active' : ''}> - + diff --git a/frontend/packages/core/src/components/HighDimensionalPage/HighDimensionalChart.tsx b/frontend/packages/core/src/components/HighDimensionalPage/HighDimensionalChart.tsx index 632195d3..42f8a1f8 100644 --- a/frontend/packages/core/src/components/HighDimensionalPage/HighDimensionalChart.tsx +++ b/frontend/packages/core/src/components/HighDimensionalPage/HighDimensionalChart.tsx @@ -68,6 +68,7 @@ const Chart = styled.div` type HighDimensionalChartProps = { vectors: Float32Array; labels: string[]; + colorMap?: ScatterChartProps['colorMap']; shape: Shape; dim: number; is3D: boolean; @@ -94,6 +95,7 @@ const HighDimensionalChart = React.forwardRef setShowLabelCloud(s => !s)} onReset={() => chart.current?.reset()} /> @@ -276,6 +279,7 @@ const HighDimensionalChart = React.forwardRef>>(Select)` +const LabelSelect = styled>>(Select)` width: 45%; min-width: ${rem(80)}; max-width: ${rem(200)}; @@ -44,43 +45,34 @@ const LabelInput = styled(SearchInput)` `; type LabelSearchResult = { - labelBy: string | undefined; + labelIndex: number | undefined; value: string; }; export type LabelSearchInputProps = { - labels: string[]; + labels: SelectListItem[]; onChange?: (result: LabelSearchResult) => unknown; }; const LabelSearchInput: FunctionComponent = ({labels, onChange}) => { - const [labelBy, setLabelBy] = useState(labels[0] ?? undefined); + const [labelIndex, setLabelIndex] = useState(0); const [value, setValue] = useState(''); useEffect(() => { - if (labels.length) { - setLabelBy(label => { - if (label && labels.includes(label)) { - return label; - } - return labels[0]; - }); - } else { - setLabelBy(undefined); - } + setLabelIndex(0); }, [labels]); const debouncedValue = useSearchValue(value); useEffect(() => { onChange?.({ - labelBy, + labelIndex, value: debouncedValue }); - }, [labelBy, onChange, debouncedValue]); + }, [labelIndex, onChange, debouncedValue]); return ( - + ); diff --git a/frontend/packages/core/src/components/ScatterChart/Component.tsx b/frontend/packages/core/src/components/ScatterChart/Component.tsx index 93103057..fb94a0c5 100644 --- a/frontend/packages/core/src/components/ScatterChart/Component.tsx +++ b/frontend/packages/core/src/components/ScatterChart/Component.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ +import type {ColorMap, Point3D} from './types'; import React, {useEffect, useImperativeHandle, useRef} from 'react'; import type Chart from './ScatterChart'; import type {ScatterChartOptions as ChartOptions} from './ScatterChart'; import LabelChart from './Labels'; -import type {Point3D} from './types'; import PointChart from './Points'; import type {WithStyled} from '~/utils/style'; import styled from 'styled-components'; @@ -37,6 +37,7 @@ export type ScatterChartProps = { height: number; data: Point3D[]; labels: string[]; + colorMap?: ColorMap | null; is3D: boolean; rotate?: boolean; focusedIndices?: number[]; @@ -49,7 +50,7 @@ export type ScatterChartRef = { }; const ScatterChart = React.forwardRef( - ({width, height, data, labels, is3D, rotate, focusedIndices, highlightIndices, type, className}, ref) => { + ({width, height, data, labels, colorMap, is3D, rotate, focusedIndices, highlightIndices, type, className}, ref) => { const theme = useTheme(); const element = useRef(null); @@ -84,8 +85,8 @@ const ScatterChart = React.forwardRef { - chart.current?.setData(data, labels); - }, [data, labels, type]); + chart.current?.setData(data, labels, colorMap); + }, [data, labels, colorMap, type]); useEffect(() => { chart.current?.setFocusedPointIndices(focusedIndices ?? []); diff --git a/frontend/packages/core/src/components/ScatterChart/Labels/Labels.tsx b/frontend/packages/core/src/components/ScatterChart/Labels/Labels.tsx index cfaffde4..28efd15c 100644 --- a/frontend/packages/core/src/components/ScatterChart/Labels/Labels.tsx +++ b/frontend/packages/core/src/components/ScatterChart/Labels/Labels.tsx @@ -53,6 +53,19 @@ export default class LabelScatterChart extends ScatterChart { return this.mesh; } + get defaultColor() { + return LabelScatterChart.LABEL_BACKGROUND_COLOR_DEFAULT; + } + get hoveredColor() { + return LabelScatterChart.LABEL_BACKGROUND_COLOR_HOVER; + } + get focusedColor() { + return LabelScatterChart.LABEL_BACKGROUND_COLOR_FOCUS; + } + get highLightColor() { + return LabelScatterChart.LABEL_BACKGROUND_COLOR_HIGHLIGHT; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any protected createShaderUniforms(picking: boolean): Record> { return { @@ -154,16 +167,7 @@ export default class LabelScatterChart extends ScatterChart { const count = this.dataCount; const colors = new Float32Array(count * VERTEX_COUNT_PER_LABEL * 3); for (let i = 0; i < count; i++) { - let color: THREE.Color; - if (this.hoveredDataIndices.includes(i)) { - color = LabelScatterChart.LABEL_BACKGROUND_COLOR_HOVER; - } else if (this.focusedDataIndices.includes(i)) { - color = LabelScatterChart.LABEL_BACKGROUND_COLOR_FOCUS; - } else if (this.highLightDataIndices.includes(i)) { - color = LabelScatterChart.LABEL_BACKGROUND_COLOR_HIGHLIGHT; - } else { - color = LabelScatterChart.LABEL_BACKGROUND_COLOR_DEFAULT; - } + const color = this.getColorByIndex(i); for (let j = 0; j < VERTEX_COUNT_PER_LABEL; j++) { colors[i * VERTEX_COUNT_PER_LABEL * 3 + j * 3] = color.r; colors[i * VERTEX_COUNT_PER_LABEL * 3 + j * 3 + 1] = color.g; diff --git a/frontend/packages/core/src/components/ScatterChart/Points/Points.tsx b/frontend/packages/core/src/components/ScatterChart/Points/Points.tsx index ed005544..c2f51bb0 100644 --- a/frontend/packages/core/src/components/ScatterChart/Points/Points.tsx +++ b/frontend/packages/core/src/components/ScatterChart/Points/Points.tsx @@ -23,7 +23,7 @@ import type {ScatterChartOptions} from '../ScatterChart'; import fragmentShader from './fragment.glsl'; import vertexShader from './vertex.glsl'; -export default class PointScatter extends ScatterChart { +export default class PointScatterChart extends ScatterChart { static readonly NUM_POINTS_FOG_THRESHOLD = 5000; static readonly POINT_COLOR_DEFAULT = new THREE.Color(0x7e7e7e); @@ -50,6 +50,19 @@ export default class PointScatter extends ScatterChart { return this.points; } + get defaultColor() { + return PointScatterChart.POINT_COLOR_DEFAULT; + } + get hoveredColor() { + return PointScatterChart.POINT_COLOR_HOVER; + } + get focusedColor() { + return PointScatterChart.POINT_COLOR_FOCUS; + } + get highLightColor() { + return PointScatterChart.POINT_COLOR_HIGHLIGHT; + } + constructor(container: HTMLElement, options: ScatterChartOptions) { super(container, options); this.label = new ScatterChartLabel(this.container, { @@ -82,25 +95,11 @@ export default class PointScatter extends ScatterChart { private convertPointsColor() { const count = this.dataCount; const colors = new Float32Array(count * 3); - let dst = 0; for (let i = 0; i < count; i++) { - if (this.hoveredDataIndices.includes(i)) { - colors[dst++] = PointScatter.POINT_COLOR_HOVER.r; - colors[dst++] = PointScatter.POINT_COLOR_HOVER.g; - colors[dst++] = PointScatter.POINT_COLOR_HOVER.b; - } else if (this.focusedDataIndices.includes(i)) { - colors[dst++] = PointScatter.POINT_COLOR_FOCUS.r; - colors[dst++] = PointScatter.POINT_COLOR_FOCUS.g; - colors[dst++] = PointScatter.POINT_COLOR_FOCUS.b; - } else if (this.highLightDataIndices.includes(i)) { - colors[dst++] = PointScatter.POINT_COLOR_HIGHLIGHT.r; - colors[dst++] = PointScatter.POINT_COLOR_HIGHLIGHT.g; - colors[dst++] = PointScatter.POINT_COLOR_HIGHLIGHT.b; - } else { - colors[dst++] = PointScatter.POINT_COLOR_DEFAULT.r; - colors[dst++] = PointScatter.POINT_COLOR_DEFAULT.g; - colors[dst++] = PointScatter.POINT_COLOR_DEFAULT.b; - } + const color = this.getColorByIndex(i); + colors[i * 3] = color.r; + colors[i * 3 + 1] = color.g; + colors[i * 3 + 2] = color.b; } return colors; } @@ -110,13 +109,13 @@ export default class PointScatter extends ScatterChart { const scaleFactor = new Float32Array(count); for (let i = 0; i < count; i++) { if (this.hoveredDataIndices.includes(i)) { - scaleFactor[i] = PointScatter.POINT_SCALE_HOVER; + scaleFactor[i] = PointScatterChart.POINT_SCALE_HOVER; } else if (this.focusedDataIndices.includes(i)) { - scaleFactor[i] = PointScatter.POINT_SCALE_FOCUS; + scaleFactor[i] = PointScatterChart.POINT_SCALE_FOCUS; } else if (this.highLightDataIndices.includes(i)) { - scaleFactor[i] = PointScatter.POINT_SCALE_HIGHLIGHT; + scaleFactor[i] = PointScatterChart.POINT_SCALE_HIGHLIGHT; } else { - scaleFactor[i] = PointScatter.POINT_SCALE_DEFAULT; + scaleFactor[i] = PointScatterChart.POINT_SCALE_DEFAULT; } } return scaleFactor; @@ -190,7 +189,9 @@ export default class PointScatter extends ScatterChart { } const multiplier = - 2 - Math.min(n, PointScatter.NUM_POINTS_FOG_THRESHOLD) / PointScatter.NUM_POINTS_FOG_THRESHOLD; + 2 - + Math.min(n, PointScatterChart.NUM_POINTS_FOG_THRESHOLD) / + PointScatterChart.NUM_POINTS_FOG_THRESHOLD; fog.near = shortestDist; fog.far = furthestDist * multiplier; diff --git a/frontend/packages/core/src/components/ScatterChart/Points/index.ts b/frontend/packages/core/src/components/ScatterChart/Points/index.ts index d5de42a0..0d28dc31 100644 --- a/frontend/packages/core/src/components/ScatterChart/Points/index.ts +++ b/frontend/packages/core/src/components/ScatterChart/Points/index.ts @@ -14,6 +14,6 @@ * limitations under the License. */ -import PointScatter from './Points'; +import PointScatterChart from './Points'; -export default PointScatter; +export default PointScatterChart; diff --git a/frontend/packages/core/src/components/ScatterChart/ScatterChart.ts b/frontend/packages/core/src/components/ScatterChart/ScatterChart.ts index e102f218..decde907 100644 --- a/frontend/packages/core/src/components/ScatterChart/ScatterChart.ts +++ b/frontend/packages/core/src/components/ScatterChart/ScatterChart.ts @@ -19,8 +19,9 @@ import * as THREE from 'three'; import * as d3 from 'd3'; -import type {Point2D, Point3D} from './types'; +import type {ColorMap, Point2D, Point3D} from './types'; +import {ColorType} from './types'; import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls'; export type ScatterChartOptions = { @@ -44,6 +45,28 @@ export default abstract class ScatterChart { static readonly PERSP_CAMERA_INIT_POSITION: Point3D = [0.45, 0.9, 1.6]; static readonly ORTHO_CAMERA_INIT_POSITION: Point3D = [0, 0, 4]; + static readonly VALUE_COLOR_MAP_RANGE = ['#ffffdd', '#1f2d86'] as const; + static readonly CATEGORY_COLOR_MAP = [ + '#9BB9E8', + '#8BB8FF', + '#B4CCB7', + '#A8E9B8', + '#DB989A', + '#6DCDE4', + '#93C2CA', + '#DE7CCE', + '#DA96BC', + '#309E51', + '#D6C482', + '#6D7CE4', + '#CDCB74', + '#2576AD', + '#E46D6D', + '#CA5353', + '#E49D6D', + '#E4E06D' + ].map(color => new THREE.Color(color)); + width: number; height: number; background: string | number | THREE.Color = '#fff'; @@ -78,6 +101,9 @@ export default abstract class ScatterChart { protected blending: THREE.Blending = THREE.NormalBlending; protected depth = true; + protected colorMap: ColorMap = {type: ColorType.Null, labels: []}; + protected colorGenerator: ((value: string) => THREE.Color) | null = null; + private mouseCoordinates: Point2D | null = null; private onMouseMoveBindThis: (e: MouseEvent) => void; @@ -89,6 +115,10 @@ export default abstract class ScatterChart { private animationId: number | null = null; protected abstract get object(): (THREE.Object3D & {material: THREE.Material | THREE.Material[]}) | null; + protected abstract get defaultColor(): THREE.Color; + protected abstract get hoveredColor(): THREE.Color; + protected abstract get focusedColor(): THREE.Color; + protected abstract get highLightColor(): THREE.Color; // eslint-disable-next-line @typescript-eslint/no-explicit-any protected abstract createShaderUniforms(picking: boolean): Record>; protected abstract onRender(): void; @@ -251,6 +281,53 @@ export default abstract class ScatterChart { }); } + private convertColorMap() { + switch (this.colorMap.type) { + case ColorType.Value: { + const {minValue, maxValue} = this.colorMap; + return (label: string) => { + const value = Number.parseFloat(label); + if (!Number.isFinite(value)) { + return this.defaultColor; + } + const ranger = d3 + .scaleLinear() + .domain([minValue, maxValue]) + .range(ScatterChart.VALUE_COLOR_MAP_RANGE); + return new THREE.Color(ranger(value)); + }; + } + case ColorType.Category: { + const categories = this.colorMap.categories; + return (label: string) => { + const index = categories.indexOf(label); + if (index === -1) { + return this.defaultColor; + } + return ScatterChart.CATEGORY_COLOR_MAP[index % ScatterChart.CATEGORY_COLOR_MAP.length]; + }; + } + default: + return null; + } + } + + protected getColorByIndex(index: number): THREE.Color { + if (this.hoveredDataIndices.includes(index)) { + return this.hoveredColor; + } + if (this.focusedDataIndices.includes(index)) { + return this.focusedColor; + } + if (this.highLightDataIndices.includes(index)) { + return this.highLightColor; + } + if (this.colorGenerator) { + return this.colorGenerator(this.colorMap.labels[index]); + } + return this.defaultColor; + } + protected createMaterial() { this.material = this.createRenderMaterial(); this.pickingMaterial = this.createPickingMaterial(); @@ -476,15 +553,17 @@ export default abstract class ScatterChart { } else { this.removeAxes(); } - this.setData(this.data, this.labels); + this.setData(this.data, this.labels, this.colorMap); } - setData(data: Point3D[], labels: string[]) { + setData(data: Point3D[], labels: string[], colorMap?: ColorMap | null) { if (this.object) { this.scene.remove(this.object); } this.labels = labels; + this.colorMap = colorMap ?? {type: ColorType.Null, labels: []}; this.data = data; + this.colorGenerator = this.convertColorMap(); this.positions = this.convertDataToPosition(); this.pickingColors = this.convertDataPickingColor(); this.onDataSet(); diff --git a/frontend/packages/core/src/components/ScatterChart/index.ts b/frontend/packages/core/src/components/ScatterChart/index.ts index d81b84c9..77c72221 100644 --- a/frontend/packages/core/src/components/ScatterChart/index.ts +++ b/frontend/packages/core/src/components/ScatterChart/index.ts @@ -17,5 +17,7 @@ import ScatterChart from './Component'; export type {ScatterChartProps, ScatterChartRef} from './Component'; +export type {ColorMap} from './types'; +export {ColorType} from './types'; export default ScatterChart; diff --git a/frontend/packages/core/src/components/ScatterChart/types.ts b/frontend/packages/core/src/components/ScatterChart/types.ts index 08443910..0b34204a 100644 --- a/frontend/packages/core/src/components/ScatterChart/types.ts +++ b/frontend/packages/core/src/components/ScatterChart/types.ts @@ -14,5 +14,10 @@ * limitations under the License. */ -export type Point2D = [number, number]; -export type Point3D = [number, number, number]; +import type {HighDimensionalColorMap} from '~/types'; + +export type {Point2D, Point3D} from '~/types'; + +export {HighDimensionalColorType as ColorType} from '~/types'; + +export type ColorMap = HighDimensionalColorMap & {labels: string[]}; diff --git a/frontend/packages/core/src/components/Select.tsx b/frontend/packages/core/src/components/Select.tsx index 53c9daef..fff9e61d 100644 --- a/frontend/packages/core/src/components/Select.tsx +++ b/frontend/packages/core/src/components/Select.tsx @@ -115,32 +115,56 @@ const listItem = css` ${size(height, '100%')} line-height: ${height}; ${transitionProps(['color', 'background-color'])} +`; +const hoverListItem = css` &:hover { background-color: var(--background-focused-color); } `; -const ListItem = styled.div<{selected?: boolean}>` +const ListItem = styled.div<{selected?: boolean; disabled?: boolean}>` ${ellipsis()} ${listItem} - ${props => (props.selected ? `color: var(--select-selected-text-color);` : '')} + ${props => { + if (props.disabled) { + return css` + cursor: not-allowed; + `; + } else { + return hoverListItem; + } + }} + ${props => { + if (props.selected) { + return css` + color: var(--select-selected-text-color); + `; + } + if (props.disabled) { + return css` + color: var(--text-light-color); + `; + } + }} `; -const MultipleListItem = styled(Checkbox)<{selected?: boolean}>` +const MultipleListItem = styled(Checkbox)<{selected?: boolean; disabled?: boolean}>` ${listItem} display: flex; align-items: center; + ${props => (props.disabled ? '' : hoverListItem)} `; -type SelectListItem = { +type OnSingleChange = (value: T) => unknown; +type OnMultipleChange = (value: T[]) => unknown; + +export type SelectListItem = { value: T; label: string; + disabled?: boolean; }; -type OnSingleChange = (value: T) => unknown; -type OnMultipleChange = (value: T[]) => unknown; - export type SelectProps = { list?: (SelectListItem | T)[]; placeholder?: string; @@ -177,12 +201,15 @@ const Select = ({ propValue ]); - const isSelected = useMemo(() => !!(multiple ? (value as T[]) && (value as T[]).length !== 0 : (value as T)), [ - multiple, - value - ]); + const isSelected = useMemo( + () => !!(multiple ? (value as T[]) && (value as T[]).length !== 0 : value != (null as T)), + [multiple, value] + ); const changeValue = useCallback( - (mutateValue: T) => { + ({value: mutateValue, disabled}: SelectListItem) => { + if (disabled) { + return; + } setValue(mutateValue); (onChange as OnSingleChange)?.(mutateValue); closeDropdown(); @@ -190,7 +217,10 @@ const Select = ({ [closeDropdown, onChange] ); const changeMultipleValue = useCallback( - (mutateValue: T, checked: boolean) => { + ({value: mutateValue, disabled}: SelectListItem, checked: boolean) => { + if (disabled) { + return; + } let newValue = value as T[]; if (checked) { if (!newValue.includes(mutateValue)) { @@ -247,8 +277,9 @@ const Select = ({ value={(value as T[]).includes(item.value)} key={index} title={item.label} + disabled={item.disabled} size="small" - onChange={checked => changeMultipleValue(item.value, checked)} + onChange={checked => changeMultipleValue(item, checked)} > {item.label} @@ -259,7 +290,8 @@ const Select = ({ selected={item.value === value} key={index} title={item.label} - onClick={() => changeValue(item.value)} + disabled={item.disabled} + onClick={() => changeValue(item)} > {item.label} diff --git a/frontend/packages/core/src/pages/high-dimensional.tsx b/frontend/packages/core/src/pages/high-dimensional.tsx index c148a941..bdfec95f 100644 --- a/frontend/packages/core/src/pages/high-dimensional.tsx +++ b/frontend/packages/core/src/pages/high-dimensional.tsx @@ -17,6 +17,7 @@ import Aside, {AsideSection} from '~/components/Aside'; import type { Dimension, + LabelMetadata, PCAResult, ParseParams, ParseResult, @@ -28,7 +29,7 @@ import type { import HighDimensionalChart, {HighDimensionalChartRef} from '~/components/HighDimensionalPage/HighDimensionalChart'; import LabelSearchInput, {LabelSearchInputProps} from '~/components/HighDimensionalPage/LabelSearchInput'; import React, {FunctionComponent, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import Select, {SelectProps} from '~/components/Select'; +import type {SelectListItem, SelectProps} from '~/components/Select'; import type {BlobResponse} from '~/utils/fetch'; import BodyLoading from '~/components/BodyLoading'; @@ -38,8 +39,11 @@ import DimensionSwitch from '~/components/HighDimensionalPage/DimensionSwitch'; import Error from '~/components/Error'; import Field from '~/components/Field'; import LabelSearchResult from '~/components/HighDimensionalPage/LabelSearchResult'; +import {LabelType} from '~/resource/high-dimensional'; import PCADetail from '~/components/HighDimensionalPage/PCADetail'; import ReductionTab from '~/components/HighDimensionalPage/ReductionTab'; +import ScatterChart from '~/components/ScatterChart/ScatterChart'; +import Select from '~/components/Select'; import TSNEDetail from '~/components/HighDimensionalPage/TSNEDetail'; import Title from '~/components/Title'; import UMAPDetail from '~/components/HighDimensionalPage/UMAPDetail'; @@ -73,7 +77,10 @@ const AsideTitle = styled.div` margin-bottom: ${rem(20)}; `; -const FullWidthSelect = styled>>(Select)` +// styled-components cannot infer type of a component... +// And I don't want to write a type guard +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const FullWidthSelect = styled>>(Select)` width: 100%; `; @@ -176,25 +183,45 @@ const HighDimensional: FunctionComponent = () => { const [vectorContent, setVectorContent] = useState(''); const [metadataContent, setMetadataContent] = useState(''); const [vectors, setVectors] = useState(new Float32Array()); - const [labels, setLabels] = useState([]); - const [labelBy, setLabelBy] = useState(); + const [labelList, setLabelList] = useState([]); + const labelByList = useMemo[]>( + () => labelList.map(({label}, index) => ({label, value: index})), + [labelList] + ); + const [labelByIndex, setLabelByIndex] = useState(0); + const colorByList = useMemo[]>( + () => [ + { + label: t('high-dimensional:no-color-map'), + value: -1 + }, + ...labelList.map((item, index) => ({ + label: item.label, + value: index, + disabled: + item.type === LabelType.Null || + (item.type === LabelType.Category && + item.categories.length > ScatterChart.CATEGORY_COLOR_MAP.length) + })) + ], + [labelList, t] + ); + const [colorByIndex, setColorByIndex] = useState(-1); const [metadata, setMetadata] = useState([]); // dimension of data const [dim, setDim] = useState(0); const [rawShape, setRawShape] = useState([0, 0]); - const getLabelByLabels = useCallback( - (value: string | undefined) => { - if (value != null) { - const labelIndex = labels.indexOf(value); - if (labelIndex !== -1) { - return metadata.map(row => row[labelIndex]); - } - } - return []; - }, - [labels, metadata] + const selectedMetadata = useMemo(() => metadata[labelByIndex], [labelByIndex, metadata]); + const selectedColor = useMemo( + () => + colorByIndex === -1 + ? null + : { + ...labelList[colorByIndex], + labels: metadata[colorByIndex] + }, + [colorByIndex, labelList, metadata] ); - const labelByLabels = useMemo(() => getLabelByLabels(labelBy), [getLabelByLabels, labelBy]); // dimension of display const [dimension, setDimension] = useState('3d'); @@ -277,8 +304,9 @@ const HighDimensional: FunctionComponent = () => { setRawShape(data.rawShape); setDim(data.dimension); setVectors(data.vectors); - setLabels(data.labels); - setLabelBy(data.labels[0]); + setLabelList(data.labels); + setLabelByIndex(0); + setColorByIndex(-1); setMetadata(data.metadata); } else if (data !== null) { setLoadingPhase('parsing'); @@ -312,23 +340,23 @@ const HighDimensional: FunctionComponent = () => { }, []); const [searchResult, setSearchResult] = useState>['0']>({ - labelBy: undefined, + labelIndex: undefined, value: '' }); const searchedResult = useMemo(() => { - if (searchResult.labelBy == null || searchResult.value === '') { + if (searchResult.labelIndex == null || searchResult.value === '') { return { indices: [], metadata: [] }; } - const labelByLabels = getLabelByLabels(searchResult.labelBy); + const metadataList = metadata[searchResult.labelIndex]; const metadataResult: string[] = []; const vectorsIndices: number[] = []; - for (let i = 0; i < labelByLabels.length; i++) { - if (labelByLabels[i].includes(searchResult.value)) { - metadataResult.push(labelByLabels[i]); + for (let i = 0; i < metadataList.length; i++) { + if (metadataList[i].includes(searchResult.value)) { + metadataResult.push(metadataList[i]); vectorsIndices.push(i); } } @@ -340,7 +368,7 @@ const HighDimensional: FunctionComponent = () => { indices: vectorsIndices, metadata: metadataResult }; - }, [getLabelByLabels, searchResult.labelBy, searchResult.value]); + }, [metadata, searchResult.labelIndex, searchResult.value]); const [hoveredIndices, setHoveredIndices] = useState([]); const hoverSearchResult = useCallback( @@ -393,11 +421,11 @@ const HighDimensional: FunctionComponent = () => { /> - + + + + - {/* - - */} setUploadModal(true)}> {t('high-dimensional:upload-data')} @@ -424,7 +452,19 @@ const HighDimensional: FunctionComponent = () => { ), - [t, dataPath, reduction, dimension, labels, labelBy, embeddingList, selectedEmbeddingName, detail] + [ + t, + embeddingList, + selectedEmbeddingName, + labelByList, + labelByIndex, + colorByList, + colorByIndex, + dataPath, + reduction, + dimension, + detail + ] ); const leftAside = useMemo( @@ -432,7 +472,7 @@ const HighDimensional: FunctionComponent = () => { - + {searchResult.value !== '' && ( @@ -451,7 +491,7 @@ const HighDimensional: FunctionComponent = () => { ), - [hoverSearchResult, labels, searchResult.value, searchedResult.metadata, t] + [hoverSearchResult, labelByList, searchResult.value, searchedResult.metadata, t] ); return ( @@ -463,7 +503,8 @@ const HighDimensional: FunctionComponent = () => { (matrix: T[][], row: number): T[] { + return matrix.map(m => m[row]); +} + +function transposeMatrix(matrix: T[][]): T[][] { + if (!matrix.length) { + return []; + } + const result: T[][] = []; + const dimension = matrix[0].length; + for (let i = 0; i < dimension; i++) { + result.push(rowOfMatrix(matrix, i)); + } + return result; +} + +function getLabelColor(labels: string[]): LabelColor { + const values = labels.map(Number.parseFloat); + const type = values.every(Number.isFinite) ? LabelType.Value : LabelType.Category; + switch (type) { + case LabelType.Value: { + return { + type, + minValue: Math.min(...values), + maxValue: Math.max(...values) + }; + } + case LabelType.Category: { + return { + type, + categories: uniq(labels) + }; + } + default: + return null as never; + } +} + function parseVectors(str: string): VectorResult { if (!str) { throw new ParserError('Tenser file is empty', ParserError.CODES.TENSER_EMPTY); @@ -143,11 +185,22 @@ function parseMetadata(str: string): MetadataResult { let metadata = split(str); // dimension is larger then 0 const dimension = metadata[0].length; - let labels = [DEFAULT_METADATA_FIELD]; metadata = alignItems(metadata, dimension, ''); + let labels: LabelMetadata[] = []; if (dimension > 1) { // metadata is larger then 1 - labels = metadata.shift() as string[]; + const labelNames = metadata.shift() ?? []; + labels = labelNames.map((label, i) => ({ + label, + ...getLabelColor(rowOfMatrix(metadata, i)) + })); + } else { + labels = [ + { + label: DEFAULT_METADATA_FIELD, + ...getLabelColor(rowOfMatrix(metadata, 0)) + } + ]; } return { dimension, @@ -156,7 +209,7 @@ function parseMetadata(str: string): MetadataResult { }; } -function genMetadataAndLabels(metadata: string, count: number) { +function genMetadataAndLabels(metadata: string, count: number): Pick { if (metadata) { const data = parseMetadata(metadata); const metadataCount = data.metadata.length; @@ -176,7 +229,12 @@ function genMetadataAndLabels(metadata: string, count: number) { }; } return { - labels: [INDEX_METADATA_FIELD], + labels: [ + { + label: INDEX_METADATA_FIELD, + type: LabelType.Null + } + ], metadata: Array.from({length: count}, (_, i) => [`${i}`]) }; } @@ -194,7 +252,11 @@ export function parseFromString({vectors: v, metadata: m, maxCount, maxDimension Object.assign(result, parseVectors(v)); Object.assign(result, genMetadataAndLabels(m, result.count)); } - return shuffle(result, maxCount, maxDimension); + const {metadata, ...others} = shuffle(result, maxCount, maxDimension); + return { + ...others, + metadata: transposeMatrix(metadata) + }; } export async function parseFromBlob({ @@ -209,7 +271,7 @@ export async function parseFromBlob({ if (count * dimension !== vectors.length) { throw new ParserError('Size of tensor does not match.', ParserError.CODES.SHAPE_MISMATCH); } - return shuffle( + const {metadata, ...others} = shuffle( { rawShape: shape, count, @@ -220,4 +282,8 @@ export async function parseFromBlob({ maxCount, maxDimension ); + return { + ...others, + metadata: transposeMatrix(metadata) + }; } diff --git a/frontend/packages/core/src/resource/high-dimensional/types.ts b/frontend/packages/core/src/resource/high-dimensional/types.ts index e201d55c..f257ac92 100644 --- a/frontend/packages/core/src/resource/high-dimensional/types.ts +++ b/frontend/packages/core/src/resource/high-dimensional/types.ts @@ -14,6 +14,12 @@ * limitations under the License. */ +import type {HighDimensionalColorMap as LabelColor} from '~/types'; +import {HighDimensionalColorType as LabelType} from '~/types'; + +export {LabelType}; +export type {LabelColor}; + export type Dimension = '2d' | '3d'; export type Reduction = 'pca' | 'tsne' | 'umap'; @@ -21,6 +27,8 @@ export type Vectors = [number, number, number][]; export type Shape = [number, number]; +export type LabelMetadata = {label: string} & LabelColor; + export type VectorResult = { rawShape: Shape; dimension: number; @@ -30,7 +38,7 @@ export type VectorResult = { export type MetadataResult = { dimension: number; - labels: string[]; + labels: LabelMetadata[]; metadata: string[][]; }; @@ -60,13 +68,10 @@ export type ParseParams = } | null; -export type ParseResult = { +export type ParseResult = MetadataResult & { rawShape: Shape; count: number; - dimension: number; vectors: Float32Array; - labels: string[]; - metadata: string[][]; }; export type PCAParams = { diff --git a/frontend/packages/core/src/types/index.ts b/frontend/packages/core/src/types/index.ts index 5d4cef8d..2e4ca0ae 100644 --- a/frontend/packages/core/src/types/index.ts +++ b/frontend/packages/core/src/types/index.ts @@ -39,3 +39,26 @@ export enum TimeMode { Relative = 'relative', WallTime = 'wall' } + +export type Point2D = [number, number]; +export type Point3D = [number, number, number]; + +export enum HighDimensionalColorType { + Null, + Value, + Category +} + +export type HighDimensionalColorMap = + | { + type: HighDimensionalColorType.Null; + } + | { + type: HighDimensionalColorType.Value; + minValue: number; + maxValue: number; + } + | { + type: HighDimensionalColorType.Category; + categories: string[]; + }; diff --git a/frontend/packages/core/src/utils/chart.ts b/frontend/packages/core/src/utils/chart.ts index 33b9e8bb..1f8f3d18 100644 --- a/frontend/packages/core/src/utils/chart.ts +++ b/frontend/packages/core/src/utils/chart.ts @@ -36,7 +36,7 @@ export const color = [ '#FF6600', '#FFEA00', '#FE4A3B' -]; +] as const; export const colorAlt = [ '#9498F0', '#66E0B8', @@ -56,7 +56,7 @@ export const colorAlt = [ '#FFB27F', '#FFF266', '#FE9289' -]; +] as const; export const title = { textStyle: { -- GitLab