Skip to content

Commit

Permalink
[feat] Support custom breaks in color scale (#2739)
Browse files Browse the repository at this point in the history
-Add SCALE_TYPE.custom option
-Save color breaks in colorRange.colorMap
-add customBreaks: true to layer.colorUI.colorRange.colorRangeConfig to render color breaks panel
-Make color scale selector a seperate component
-Create factory for color-selector, dimension-scale-selector, color-range-selector, custom-palette

Signed-off-by: Shan He <heshan0131@gmail.com>
Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>
  • Loading branch information
igorDykhta authored Nov 12, 2024
1 parent 3f64500 commit d0c9a3b
Show file tree
Hide file tree
Showing 58 changed files with 3,807 additions and 1,496 deletions.
4 changes: 3 additions & 1 deletion examples/demo-app/src/data/sample-trip-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -3988,6 +3988,7 @@ export const sampleTripDataConfig = {
isVisible: true,
visConfig: {
colorRange: {
colors: ['#FF000', '#00FF00', '#0000FF', '#555555', '#111111', '#222222'],
colorMap: [
['apple tree', '#FF000'],
['banana peel', '#00FF00'],
Expand All @@ -4003,7 +4004,8 @@ export const sampleTripDataConfig = {
colorField: {
name: 'fare_type',
type: 'string'
}
},
colorScale: 'custom'
}
},
{
Expand Down
7 changes: 5 additions & 2 deletions src/actions/src/vis-state-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export type LayerVisualChannelConfigChangeUpdaterAction = {
oldLayer: Layer;
newConfig: Partial<LayerBaseConfig>;
channel: string;
newVisConfig?: Partial<LayerVisConfig>;
};
/**
* Update layer visual channel
Expand All @@ -168,7 +169,8 @@ export type LayerVisualChannelConfigChangeUpdaterAction = {
export function layerVisualChannelConfigChange(
oldLayer: Layer,
newConfig: Partial<LayerBaseConfig>,
channel: string
channel: string,
newVisConfig?: Partial<LayerVisConfig>
): Merge<
LayerVisualChannelConfigChangeUpdaterAction,
{type: typeof ActionTypes.LAYER_VISUAL_CHANNEL_CHANGE}
Expand All @@ -177,7 +179,8 @@ export function layerVisualChannelConfigChange(
type: ActionTypes.LAYER_VISUAL_CHANNEL_CHANGE,
oldLayer,
newConfig,
channel
channel,
newVisConfig
};
}
export type LayerVisConfigChangeUpdaterAction = {
Expand Down
179 changes: 90 additions & 89 deletions src/components/src/common/color-legend.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
// SPDX-License-Identifier: MIT
// Copyright contributors to the kepler.gl project

import {ColorRange} from '@kepler.gl/constants';
import {Layer} from '@kepler.gl/layers';
import {HexColor, MapState} from '@kepler.gl/types';
import {
getLayerColorScale,
getLegendOfScale,
getVisualChannelScaleByZoom,
isObject
} from '@kepler.gl/utils';
import React, {useCallback, useMemo} from 'react';
import styled, {css} from 'styled-components';
import moment from 'moment';
import {SCALE_TYPES, SCALE_FUNC, ALL_FIELD_TYPES, ColorRange} from '@kepler.gl/constants';
import {getTimeWidgetHintFormatter, formatNumber} from '@kepler.gl/utils';
import {isObject} from '@kepler.gl/utils';
import {Reset} from './icons';
import {InlineInput} from './styled-components';
import {HexColor} from '@kepler.gl/types';

const ROW_H = 15;
const GAP = 4;
Expand All @@ -34,50 +38,6 @@ const StyledLegend = styled.div<{disableEdit: boolean}>`
${props => (props.disableEdit ? inputCss : '')}
`;

const defaultFormat = d => d;

const getTimeLabelFormat = domain => {
const formatter = getTimeWidgetHintFormatter(domain);
return val => moment.utc(val).format(formatter);
};

const getQuantLabelFormat = (domain, fieldType) => {
// quant scale can only be assigned to linear Fields: real, timestamp, integer
return fieldType === ALL_FIELD_TYPES.timestamp
? getTimeLabelFormat(domain)
: !fieldType
? defaultFormat
: n => formatNumber(n, fieldType);
};

const getOrdinalLegends = scale => {
const domain = scale.domain();
const labels = scale.domain();
const data = domain.map(scale);
return data.map((datum, index) => ({
data: datum,
label: labels[index]
}));
};

const getQuantLegends = (scale, labelFormat) => {
if (typeof scale.invertExtent !== 'function') {
return [];
}

const labels = scale.range().map(d => {
const invert = scale.invertExtent(d);
return `${labelFormat(invert[0])} to ${labelFormat(invert[1])}`;
});

const data = scale.range();

return labels.map((label, index) => ({
data: data[index],
label
}));
};

const StyledLegendRow = styled.div`
display: flex;
align-items: center;
Expand Down Expand Up @@ -235,71 +195,112 @@ const overrideColorLegends = (colorLegends, overrides) => {
return newColorLegends;
};

type OverrideByCustomLegendOptions = {
/**
* Legend parameters to override
*/
colorLegends?: Record<string, any>;
/**
* Original Legends
*/
currentLegends: ReturnType<typeof getLegendOfScale>;
};

/**
* Overrides legend labels with color legends.
* @param param0 Legend info and override parameters.
* @returns Original or overriden lenends.
*/
function overrideByCustomLegend({colorLegends, currentLegends}: OverrideByCustomLegendOptions) {
if (colorLegends && isObject(colorLegends)) {
// override labels with color legends
const data = Object.keys(colorLegends);
const labels = Object.values(colorLegends);

return overrideColorLegends(currentLegends, {data, labels});
}

return currentLegends;
}

export function useLayerColorLegends(
layer,
scaleType,
domain,
range,
isFixed,
fieldType,
labelFormat,
mapState
) {
const scale = useMemo(
() => getLayerColorScale({range, domain, scaleType, isFixed, layer}),
[range, domain, scaleType, isFixed, layer]
);

const scaleByZoom = useMemo(
() => getVisualChannelScaleByZoom({scale, layer, mapState}),
[scale, layer, mapState]
);

const currentLegends = useMemo(
() => getLegendOfScale({scale: scaleByZoom, scaleType, labelFormat, fieldType}),
[scaleByZoom, scaleType, labelFormat, fieldType]
);

const LegendsWithCustomLegends = useMemo(
() =>
overrideByCustomLegend({
colorLegends: range?.colorLegends,
currentLegends
}),
[range?.colorLegends, currentLegends]
);

return LegendsWithCustomLegends;
}

type ColorLegendProps = {
layer: Layer;
scaleType: string;
domain: number[] | string[];
fieldType?: string | null;
range?: ColorRange | null;
labelFormat?: (n: any) => string;
displayLabel?: boolean;
disableEdit?: boolean;
mapState?: MapState;
isFixed?: boolean;
onUpdateColorLegend?: (colorLegends: {[key: HexColor]: string}) => void;
};

ColorLegendFactory.deps = [LegendRowFactory];
function ColorLegendFactory(LegendRow: ReturnType<typeof LegendRowFactory>) {
const ColorLegend: React.FC<ColorLegendProps> = ({
layer,
isFixed,
domain,
range,
labelFormat,
scaleType,
fieldType,
mapState,
onUpdateColorLegend,
displayLabel = true,
disableEdit = false
}) => {
const {colorLegends} = range || {};

const legends = useMemo(() => {
let currentLegends: any[] = [];
if (!range) {
return currentLegends;
}
if (Array.isArray(range.colors)) {
if (!domain || !scaleType) {
return currentLegends;
}

const scaleFunction = SCALE_FUNC[scaleType];
// color scale can only be quantize, quantile or ordinal
const scale = scaleFunction().domain(domain).range(range.colors);

if (scaleType === SCALE_TYPES.ordinal) {
return getOrdinalLegends(scale);
}

const formatLabel = labelFormat || getQuantLabelFormat(scale.domain(), fieldType);

currentLegends = getQuantLegends(scale, formatLabel);
}

if (range.colorLegends && isObject(range.colorLegends)) {
// override labels with color legends
const data = Object.keys(range.colorLegends);
const labels = Object.values(range.colorLegends);

currentLegends = overrideColorLegends(currentLegends, {data, labels});
}

if (Array.isArray(range.colorMap)) {
const data = range.colorMap.map(cm => cm[1]);
const labels = range.colorMap.map(cm => cm[0]);

currentLegends = overrideColorLegends(currentLegends, {data, labels});
}

return currentLegends;
}, [domain, range, labelFormat, scaleType, fieldType]);
const legends = useLayerColorLegends(
layer,
scaleType,
domain,
range,
isFixed,
fieldType,
labelFormat,
mapState
);

const onUpdateLabel = useCallback(
(color, newValue) => {
Expand Down
41 changes: 16 additions & 25 deletions src/components/src/common/field-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import React, {Component, ComponentType} from 'react';
import styled from 'styled-components';
import {createSelector} from 'reselect';

import {Field} from '@kepler.gl/types';
import {Field, TooltipField} from '@kepler.gl/types';
import {notNullorUndefined, toArray} from '@kepler.gl/utils';

import ItemSelector from './item-selector/item-selector';
Expand Down Expand Up @@ -62,24 +62,11 @@ export function FieldListItemFactoryFactory(FieldToken) {
const SuggestedFieldHeader = () => <div>Suggested Field</div>;

export type MinimalField = {name: string; displayName: string; format: string; type?: string};
type FieldType =
| string
| string[]
| {
name: string;
format: string | null;
}[]
| {
format?: string;
id?: string;
name?: string;
fieldIdx?: number;
type?: number;
}
| Field;
export type FieldType = string | TooltipField | Field;
export type FieldValue = string | {name: string} | string[] | {name: string}[];

interface FieldSelectorFactoryProps {
fields?: FieldType[];
export type FieldSelectorProps<Option extends MinimalField> = {
fields: Option[];
onSelect: (
items:
| ReadonlyArray<string | number | boolean | object>
Expand All @@ -89,30 +76,31 @@ interface FieldSelectorFactoryProps {
| object
| null
) => void;
placement?: string;
value?: FieldType | null;
filterFieldTypes?: FieldType | FieldType[];
value?: FieldValue | null;
filterFieldTypes?: string | string[];
inputTheme?: string;
placement?: string;
placeholder?: string;
erasable?: boolean;
disabled?: boolean;
error?: boolean;
multiSelect?: boolean;
closeOnSelect?: boolean;
showToken?: boolean;
suggested?: ReadonlyArray<string | number | boolean | object> | null;
suggested?: Option[] | null;
CustomChickletComponent?: ComponentType<any>;
size?: string;
reorderItems?: (newOrder: any) => void;
className?: string;
}
};

function noop() {
return;
}
function FieldSelectorFactory(
FieldListItemFactory: ReturnType<typeof FieldListItemFactoryFactory>
): ComponentType<FieldSelectorFactoryProps> {
class FieldSelector extends Component<FieldSelectorFactoryProps> {
): ComponentType<FieldSelectorProps<MinimalField>> {
class FieldSelector extends Component<FieldSelectorProps<MinimalField>> {
static defaultProps = {
erasable: true,
disabled: false,
Expand Down Expand Up @@ -170,6 +158,7 @@ function FieldSelectorFactory(
}
);

// @ts-ignore Fix later
fieldListItemSelector = createSelector(this.showTokenSelector, FieldListItemFactory);

render() {
Expand Down Expand Up @@ -202,6 +191,8 @@ function FieldSelectorFactory(
);
}
}

// @ts-ignore: Fix me
return FieldSelector;
}

Expand Down
Loading

0 comments on commit d0c9a3b

Please sign in to comment.