未验证 提交 858d698d 编写于 作者: P Peter Pan 提交者: GitHub

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
上级 6da5a64d
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path d="M30.957 32h-4.769l-3.725-9.723h-13.411l-3.539 9.723h-4.471l12.294-32h4.545l13.076 32zM21.196 18.887l-5.625-14.826-5.364 14.826h10.989z"></path>
</svg>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<path d="M179.2 256l230.4-219.429-38.4-36.572-268.8 256 268.8 256 38.4-36.572z"></path>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path d="M11.2 16l14.4-13.714-2.4-2.286-16.8 16 16.8 16 2.4-2.286z"></path>
</svg>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<path d="M332.8 256l-230.4-219.429 38.4-36.572 268.8 256-268.8 256-38.4-36.572z"></path>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path d="M20.8 16l-14.4 13.714 2.4 2.286 16.8-16-16.8-16-2.4 2.286z"></path>
</svg>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<path d="M256 179.2l-219.429 230.4-36.572-38.4 256-268.8 256 268.8-36.572 38.4z"></path>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path d="M16 11.2l-13.714 14.4-2.286-2.4 16-16.8 16 16.8-2.286 2.4z"></path>
</svg>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<path d="M0 201.147v-127.991l41.808 41.744c47.411-71.728 127.725-114.888 213.797-114.9 141.593 0 256.395 114.607 256.395 256s-114.803 256-256.396 256c-104.379 0.024-198.352-63.132-237.62-159.695-4.568-11.225 0.844-24.022 12.087-28.579s24.055 0.842 28.62 12.067c32.555 80.007 110.426 132.336 196.913 132.324 117.329 0 212.445-94.968 212.445-212.118s-95.116-212.118-212.446-212.118c-75.793 0-144.173 40.041-181.988 102.758l54.571 54.505h-128.188z"></path>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path d="M0 12.572v-7.999l2.613 2.609c2.963-4.483 7.983-7.181 13.362-7.181 8.85 0 16.025 7.163 16.025 16s-7.175 16-16.025 16c-6.524 0.001-12.397-3.946-14.851-9.981-0.285-0.702 0.053-1.501 0.755-1.786s1.503 0.053 1.789 0.754c2.035 5 6.902 8.271 12.307 8.27 7.333 0 13.278-5.936 13.278-13.257s-5.945-13.257-13.278-13.257c-4.737 0-9.011 2.503-11.374 6.422l3.411 3.407h-8.012z"></path>
</svg>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<path d="M472.615 39.385h-78.769v-39.385h98.462c5.223 0 10.231 2.075 13.925 5.768s5.768 8.701 5.768 13.924v98.462h-39.385v-78.769zM472.615 393.846h39.385v98.462c0 10.875-8.817 19.692-19.692 19.692h-98.462v-39.385h78.769v-78.769zM118.154 472.615v39.385h-98.462c-5.223 0-10.232-2.075-13.925-5.768s-5.768-8.702-5.768-13.925v-98.462h39.385v78.769h78.769zM39.385 39.385v78.769h-39.385v-98.462c0-5.223 2.075-10.232 5.768-13.925s8.701-5.768 13.924-5.768h100.943v39.385h-81.251zM196.923 0.001h118.154v39.385h-118.154v-39.385zM196.923 472.615h118.154v39.385h-118.154v-39.385zM0.001 196.923h39.385v118.154h-39.385v-118.154zM472.615 196.923h39.385v118.154h-39.385v-118.154z"></path>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path d="M29.538 2.462h-4.923v-2.462h6.154c0.326 0 0.639 0.13 0.87 0.36s0.36 0.544 0.36 0.87v6.154h-2.462v-4.923zM29.538 24.615h2.462v6.154c0 0.68-0.551 1.231-1.231 1.231h-6.154v-2.462h4.923v-4.923zM7.385 29.538v2.462h-6.154c-0.326 0-0.639-0.13-0.87-0.36s-0.36-0.544-0.36-0.87v-6.154h2.462v4.923h4.923zM2.462 2.462v4.923h-2.462v-6.154c0-0.326 0.13-0.639 0.36-0.87s0.544-0.36 0.87-0.36h6.309v2.462h-5.078zM12.308 0h7.385v2.462h-7.385v-2.462zM12.308 29.538h7.385v2.462h-7.385v-2.462zM0 12.308h2.462v7.385h-2.462v-7.385zM29.538 12.308h2.462v7.385h-2.462v-7.385z"></path>
</svg>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<path d="M367.188 421.125c47.148 0 83.352-13.892 108.61-41.255 23.995-26.1 36.203-87.351 36.203-133.657 0-47.148-11.366-94.717-33.678-119.976-24.416-27.784-60.198-41.255-107.767-41.255h-107.767v336.142h104.399zM361.295 386.55h-63.2v-268.809h66.988c39.572 0 68.237 8.149 86.76 28.776 17.26 19.365 26.48 59.282 26.48 99.695 0 39.15-10.103 91.263-28.627 111.469-19.786 20.627-49.253 28.868-88.403 28.868z"></path>
<path d="M105.662 427.019c31.151 0 56.409-9.68 75.775-29.041 18.944-18.901 28.626-43.33 28.626-73.293 0-19.36-5.052-35.495-15.155-48.401-9.262-12.907-22.732-22.126-40.413-28.119 32.835-11.985 49.253-36.877 49.253-73.754 0-27.658-8.84-49.323-26.1-65.457-17.681-16.133-41.256-23.97-69.88-23.97s-51.779 8.758-69.46 26.736c-18.523 17.978-29.468 42.87-31.994 74.676h34.099c2.105-22.126 8.84-39.182 20.627-51.167 11.366-11.985 26.942-17.517 47.148-17.517 19.365 0 34.94 5.071 45.885 16.133 10.103 10.141 15.577 24.431 15.577 42.87s-5.472 32.728-15.996 42.87c-10.524 9.68-25.678 14.751-45.885 14.751h-23.154v29.501h24.416c21.049 0 37.044 5.071 48.832 16.134s17.681 26.276 17.681 46.097c0 19.36-6.315 35.495-18.102 48.401-13.049 13.368-30.31 20.283-51.779 20.283-18.944 0-34.519-5.993-47.148-17.057-14.733-13.368-22.733-33.189-23.574-59.004h-34.94c2.526 36.877 14.313 64.996 35.783 84.357 18.102 15.673 41.255 23.97 69.88 23.97z"></path>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path d="M22.949 26.32c2.947 0 5.209-0.868 6.788-2.578 1.5-1.631 2.263-5.459 2.263-8.354 0-2.947-0.71-5.92-2.105-7.498-1.526-1.736-3.762-2.578-6.735-2.578h-6.735v21.009h6.525zM22.581 24.159h-3.95v-16.801h4.187c2.473 0 4.265 0.509 5.422 1.799 1.079 1.21 1.655 3.705 1.655 6.231 0 2.447-0.631 5.704-1.789 6.967-1.237 1.289-3.078 1.804-5.525 1.804z"></path>
<path d="M6.604 26.689c1.947 0 3.526-0.605 4.736-1.815 1.184-1.181 1.789-2.708 1.789-4.581 0-1.21-0.316-2.218-0.947-3.025-0.579-0.807-1.421-1.383-2.526-1.757 2.052-0.749 3.078-2.305 3.078-4.61 0-1.729-0.553-3.083-1.631-4.091-1.105-1.008-2.578-1.498-4.367-1.498s-3.236 0.547-4.341 1.671c-1.158 1.124-1.842 2.679-2 4.667h2.131c0.132-1.383 0.553-2.449 1.289-3.198 0.71-0.749 1.684-1.095 2.947-1.095 1.21 0 2.184 0.317 2.868 1.008 0.631 0.634 0.974 1.527 0.974 2.679s-0.342 2.046-1 2.679c-0.658 0.605-1.605 0.922-2.868 0.922h-1.447v1.844h1.526c1.316 0 2.315 0.317 3.052 1.008s1.105 1.642 1.105 2.881c0 1.21-0.395 2.218-1.131 3.025-0.816 0.835-1.894 1.268-3.236 1.268-1.184 0-2.157-0.375-2.947-1.066-0.921-0.835-1.421-2.074-1.473-3.688h-2.184c0.158 2.305 0.895 4.062 2.236 5.272 1.131 0.98 2.578 1.498 4.367 1.498z"></path>
</svg>
{
"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",
......
{
"3d-label": "开启/关闭3D数据标签",
"3d-label": "显示/隐藏数据详情",
"component": "主成分{{index}}",
"continue": "继续",
"data": "数据",
......@@ -22,6 +22,7 @@
},
"matched-result-count": "匹配结果 {{count}}",
"neighbors": "相邻数据点数量",
"no-color-map": "无颜色",
"pause": "暂停",
"perplexity": "困惑度",
"points": "数据点",
......
......@@ -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<ChartOperationsProps> = ({onToggleLabelCloud, onReset}) => {
const ChartOperations: FunctionComponent<ChartOperationsProps> = ({labelCloud, onToggleLabelCloud, onReset}) => {
const {t} = useTranslation('high-dimensional');
return (
......@@ -86,9 +84,9 @@ const ChartOperations: FunctionComponent<ChartOperationsProps> = ({onToggleLabel
</a>
</Tippy> */}
<Tippy content={t('high-dimensional:3d-label')} placement="bottom" theme="tooltip">
<a className="three-d" onClick={() => onToggleLabelCloud?.()}>
<a onClick={() => onToggleLabelCloud?.()} className={labelCloud ? 'active' : ''}>
<span>
<Icon type="three-d" />
<Icon type="a" />
</span>
</a>
</Tippy>
......
......@@ -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<HighDimensionalChartRef, HighDimen
{
vectors,
labels,
colorMap,
shape,
dim,
is3D,
......@@ -265,6 +267,7 @@ const HighDimensionalChart = React.forwardRef<HighDimensionalChartRef, HighDimen
{shape[1]}
</div>
<ChartOperations
labelCloud={showLabelCloud}
onToggleLabelCloud={() => setShowLabelCloud(s => !s)}
onReset={() => chart.current?.reset()}
/>
......@@ -276,6 +279,7 @@ const HighDimensionalChart = React.forwardRef<HighDimensionalChartRef, HighDimen
height={height}
data={data?.vectors ?? []}
labels={labels}
colorMap={colorMap}
is3D={is3D}
rotate={reduction !== 'tsne'}
focusedIndices={focusedIndices}
......
......@@ -15,9 +15,10 @@
*/
import React, {FunctionComponent, useEffect, useState} from 'react';
import Select, {SelectProps} from '~/components/Select';
import type {SelectListItem, SelectProps} from '~/components/Select';
import SearchInput from '~/components/SearchInput';
import Select from '~/components/Select';
import {rem} from '~/utils/style';
import styled from 'styled-components';
import useSearchValue from '~/hooks/useSearchValue';
......@@ -27,7 +28,7 @@ const Wrapper = styled.div`
display: flex;
`;
const LabelSelect = styled<React.FunctionComponent<SelectProps<string>>>(Select)`
const LabelSelect = styled<React.FunctionComponent<SelectProps<number>>>(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<number>[];
onChange?: (result: LabelSearchResult) => unknown;
};
const LabelSearchInput: FunctionComponent<LabelSearchInputProps> = ({labels, onChange}) => {
const [labelBy, setLabelBy] = useState<string | undefined>(labels[0] ?? undefined);
const [labelIndex, setLabelIndex] = useState<number>(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 (
<Wrapper>
<LabelSelect list={labels} value={labelBy} onChange={setLabelBy} />
<LabelSelect list={labels} value={labelIndex} onChange={setLabelIndex} />
<LabelInput value={value} onChange={setValue} />
</Wrapper>
);
......
......@@ -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<ScatterChartRef, ScatterChartProps & WithStyled>(
({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<HTMLDivElement>(null);
......@@ -84,8 +85,8 @@ const ScatterChart = React.forwardRef<ScatterChartRef, ScatterChartProps & WithS
}, [is3D, rotate]);
useEffect(() => {
chart.current?.setData(data, labels);
}, [data, labels, type]);
chart.current?.setData(data, labels, colorMap);
}, [data, labels, colorMap, type]);
useEffect(() => {
chart.current?.setFocusedPointIndices(focusedIndices ?? []);
......
......@@ -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<string, THREE.IUniform<any>> {
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;
......
......@@ -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;
......
......@@ -14,6 +14,6 @@
* limitations under the License.
*/
import PointScatter from './Points';
import PointScatterChart from './Points';
export default PointScatter;
export default PointScatterChart;
......@@ -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<string, THREE.IUniform<any>>;
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<string, string>()
.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();
......
......@@ -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;
......@@ -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[]};
......@@ -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<T> = {
type OnSingleChange<T> = (value: T) => unknown;
type OnMultipleChange<T> = (value: T[]) => unknown;
export type SelectListItem<T> = {
value: T;
label: string;
disabled?: boolean;
};
type OnSingleChange<T> = (value: T) => unknown;
type OnMultipleChange<T> = (value: T[]) => unknown;
export type SelectProps<T> = {
list?: (SelectListItem<T> | T)[];
placeholder?: string;
......@@ -177,12 +201,15 @@ const Select = <T extends unknown>({
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<T>) => {
if (disabled) {
return;
}
setValue(mutateValue);
(onChange as OnSingleChange<T>)?.(mutateValue);
closeDropdown();
......@@ -190,7 +217,10 @@ const Select = <T extends unknown>({
[closeDropdown, onChange]
);
const changeMultipleValue = useCallback(
(mutateValue: T, checked: boolean) => {
({value: mutateValue, disabled}: SelectListItem<T>, checked: boolean) => {
if (disabled) {
return;
}
let newValue = value as T[];
if (checked) {
if (!newValue.includes(mutateValue)) {
......@@ -247,8 +277,9 @@ const Select = <T extends unknown>({
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}
</MultipleListItem>
......@@ -259,7 +290,8 @@ const Select = <T extends unknown>({
selected={item.value === value}
key={index}
title={item.label}
onClick={() => changeValue(item.value)}
disabled={item.disabled}
onClick={() => changeValue(item)}
>
{item.label}
</ListItem>
......
......@@ -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<React.FunctionComponent<SelectProps<string>>>(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<React.FunctionComponent<SelectProps<any>>>(Select)`
width: 100%;
`;
......@@ -176,25 +183,45 @@ const HighDimensional: FunctionComponent = () => {
const [vectorContent, setVectorContent] = useState('');
const [metadataContent, setMetadataContent] = useState('');
const [vectors, setVectors] = useState<Float32Array>(new Float32Array());
const [labels, setLabels] = useState<string[]>([]);
const [labelBy, setLabelBy] = useState<string>();
const [labelList, setLabelList] = useState<LabelMetadata[]>([]);
const labelByList = useMemo<SelectListItem<number>[]>(
() => labelList.map(({label}, index) => ({label, value: index})),
[labelList]
);
const [labelByIndex, setLabelByIndex] = useState<number>(0);
const colorByList = useMemo<SelectListItem<number>[]>(
() => [
{
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<number>(-1);
const [metadata, setMetadata] = useState<string[][]>([]);
// dimension of data
const [dim, setDim] = useState<number>(0);
const [rawShape, setRawShape] = useState<Shape>([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 [];
const selectedMetadata = useMemo(() => metadata[labelByIndex], [labelByIndex, metadata]);
const selectedColor = useMemo(
() =>
colorByIndex === -1
? null
: {
...labelList[colorByIndex],
labels: metadata[colorByIndex]
},
[labels, metadata]
[colorByIndex, labelList, metadata]
);
const labelByLabels = useMemo(() => getLabelByLabels(labelBy), [getLabelByLabels, labelBy]);
// dimension of display
const [dimension, setDimension] = useState<Dimension>('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<Parameters<NonNullable<LabelSearchInputProps['onChange']>>['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<number[]>([]);
const hoverSearchResult = useCallback(
......@@ -393,11 +421,11 @@ const HighDimensional: FunctionComponent = () => {
/>
</Field>
<Field label={t('high-dimensional:select-label')}>
<FullWidthSelect list={labels} value={labelBy} onChange={setLabelBy} />
<FullWidthSelect list={labelByList} value={labelByIndex} onChange={setLabelByIndex} />
</Field>
<Field label={t('high-dimensional:select-color')}>
<FullWidthSelect list={colorByList} value={colorByIndex} onChange={setColorByIndex} />
</Field>
{/* <Field label={t('high-dimensional:select-color')}>
<FullWidthSelect />
</Field> */}
<Field>
<FullWidthButton rounded outline type="primary" onClick={() => setUploadModal(true)}>
{t('high-dimensional:upload-data')}
......@@ -424,7 +452,19 @@ const HighDimensional: FunctionComponent = () => {
</AsideSection>
</RightAside>
),
[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 = () => {
<LeftAside>
<AsideSection>
<Field>
<LabelSearchInput labels={labels} onChange={setSearchResult} />
<LabelSearchInput labels={labelByList} onChange={setSearchResult} />
</Field>
{searchResult.value !== '' && (
<Field className="secondary">
......@@ -451,7 +491,7 @@ const HighDimensional: FunctionComponent = () => {
</AsideSection>
</LeftAside>
),
[hoverSearchResult, labels, searchResult.value, searchedResult.metadata, t]
[hoverSearchResult, labelByList, searchResult.value, searchedResult.metadata, t]
);
return (
......@@ -463,7 +503,8 @@ const HighDimensional: FunctionComponent = () => {
<HighDimensionalChart
ref={chart}
vectors={vectors}
labels={labelByLabels}
labels={selectedMetadata}
colorMap={selectedColor}
shape={rawShape}
dim={dim}
is3D={is3D}
......
......@@ -18,6 +18,7 @@ export type {
CalculateParams,
CalculateResult,
Dimension,
LabelMetadata,
ParseParams,
ParseResult,
PCAParams,
......@@ -31,6 +32,8 @@ export type {
Vectors
} from './types';
export {LabelType} from './types';
export {parseFromBlob, parseFromString, ParserError} from './parser';
export {default as PCA} from './pca';
......
......@@ -15,6 +15,8 @@
*/
import type {
LabelColor,
LabelMetadata,
MetadataResult,
ParseFromBlobParams,
ParseFromStringParams,
......@@ -23,7 +25,9 @@ import type {
VectorResult
} from './types';
import {LabelType} from './types';
import {safeSplit} from '~/utils';
import uniq from 'lodash/uniq';
const INDEX_METADATA_FIELD = '__index__';
const DEFAULT_METADATA_FIELD = '__metadata__';
......@@ -118,6 +122,44 @@ function shuffle(data: ParseResult, maxCount?: number, maxDimension?: number): P
};
}
function rowOfMatrix<T>(matrix: T[][], row: number): T[] {
return matrix.map(m => m[row]);
}
function transposeMatrix<T>(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<MetadataResult, 'labels' | 'metadata'> {
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)
};
}
......@@ -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 = {
......
......@@ -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[];
};
......@@ -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: {
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册