Skip to content

Commit

Permalink
[Feat] Support radius legend (#2677)
Browse files Browse the repository at this point in the history
* Show radius legend in point layer

Co-authored-by: Ilya Boyandin <iboyandin@foursquare.com>
  • Loading branch information
heshan0131 and ilyabo authored Oct 8, 2024
1 parent 1e7415a commit a24ba5e
Show file tree
Hide file tree
Showing 5 changed files with 251 additions and 26 deletions.
151 changes: 151 additions & 0 deletions src/components/src/common/radius-legend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import React, {useMemo, FC} from 'react';
import styled from 'styled-components';
import {scaleSqrt} from 'd3-scale';
import {SCALE_TYPES} from '@kepler.gl/constants';
import {max} from 'd3-array';
import {format} from 'd3-format';
import {console as Console} from 'global/window';

const StyledLegend = styled.div<{width: number; height: number}>`
width: ${props => props.width}px;
height: ${props => props.height}px;
position: relative;
svg {
circle {
stroke: ${props => props.theme.borderColorLT};
fill: none;
}
line {
stroke: ${props => props.theme.borderColor};
stroke-dasharray: 2, 1;
}
}
`;

const LabelsOuter = styled.div<{width: number; height: number}>`
text-align: right;
position: absolute;
left: 0;
top: 0;
width: ${props => props.width}px;
height: ${props => props.height}px;
`;

const tickHeight = 9;

const ValueLabel = styled.div`
position: absolute;
font-size: ${tickHeight}px;
height: ${tickHeight}px;
line-height: ${tickHeight}px;
margin-top: -${tickHeight / 2}px;
color: ${props => props.theme.textColor};
background-color: ${props => props.theme.mapPanelBackgroundColor};
padding: 0 2px;
border-radius: 2px;
`;

const formatValue = format('.2~f');

const margin = {left: 1, top: 5, right: 2, bottom: 5};

type Props = {
width: number;
scaleType: string;
domain: [number, number];
fieldType: string;
range: [number, number];
};

const RadiusLegend: FC<Props> = ({scaleType, width, domain, range}) => {
const radiusScale = useMemo(() => {
if (scaleType !== SCALE_TYPES.sqrt) {
Console.warn(`Unsupported radius scale type: ${scaleType}`);
return undefined;
}
if (!Array.isArray(domain) || !domain.every(Number.isFinite)) {
return undefined;
}
return scaleSqrt()
.domain(domain)
.range(range);
}, [domain, range, scaleType]);

const radiusTicks = useMemo(() => {
if (radiusScale === undefined) return [];
const numTicksToFit = Math.min(10, ((range[1] - range[0]) * 2) / tickHeight);
const ticks = radiusScale.ticks(numTicksToFit);
// Add min and max values
if (ticks[0] > domain[0]) {
ticks.unshift(domain[0]);
}
if (ticks[ticks.length - 1] < domain[1]) {
ticks.push(domain[1]);
}
// Make sure there is no overlap
return ticks.reduceRight((acc, v) => {
if (acc.length === 0 || Math.abs(radiusScale(acc[0]) - radiusScale(v)) * 2 > tickHeight) {
// @ts-ignore
acc.unshift(v);
}
return acc;
}, new Array<number>());
}, [radiusScale, domain, range]);

if (!radiusScale || !radiusTicks.length) {
return null;
}
const maxR = Math.ceil(radiusScale(max(radiusTicks) || 0));
const w = width - margin.left - margin.right;
const h = maxR * 2;
const height = h + margin.top + margin.bottom;

return (
<StyledLegend width={width} height={height}>
<svg width={width} height={height}>
<g transform={`translate(${margin.left},${margin.top})`}>
<g>
{radiusTicks.map((v, i) => (
<g key={i}>
<g transform={`translate(${w},${h - radiusScale(v) * 2})`}>
<line x1={0} x2={maxR - w} />
</g>
</g>
))}
</g>
<g>
{radiusTicks.map((v, i) => {
const r = radiusScale(v);
return (
<g key={i}>
<g transform={`translate(0,${h - r * 2})`}>
<circle
cx={maxR}
cy={r}
r={Math.max(0, r - 1)} /* stroke is drawn outside, hence r-1 */
/>
</g>
</g>
);
})}
</g>
</g>
</svg>
<LabelsOuter width={width} height={height}>
{radiusTicks.map((v, i) => (
<ValueLabel
key={i}
style={{
right: margin.right,
top: margin.top + h - radiusScale(v) * 2
}}
>
{formatValue(v)}
</ValueLabel>
))}
</LabelsOuter>
</StyledLegend>
);
};

export default RadiusLegend;
2 changes: 1 addition & 1 deletion src/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export {
StyledMapControlLegend,
LayerColorLegend,
VisualChannelMetric,
LayerSizeLegend,
LayerDefaultLegend,
SingleColorLegend
} from './map/map-legend';
export {default as MapDrawPanelFactory} from './map/map-draw-panel';
Expand Down
3 changes: 1 addition & 2 deletions src/components/src/map/map-legend-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ function MapLegendPanelFactory(MapControlTooltip, MapControlPanel, MapLegend) {
logoComponent,
actionIcons = defaultActionIcons,
mapState,
mapHeight,
offsetRight,
onToggleSplitMapViewport,
onClickControlBtn,
Expand Down Expand Up @@ -155,7 +154,7 @@ function MapLegendPanelFactory(MapControlTooltip, MapControlPanel, MapLegend) {
onToggleSplitMapViewport={onToggleSplitMapViewport}
isViewportUnsyncAllowed={isViewportUnsyncAllowed}
>
<MapLegend layers={layers} mapHeight={mapHeight} />
<MapLegend layers={layers} mapState={mapState} />
</MapControlPanel>
);

Expand Down
113 changes: 94 additions & 19 deletions src/components/src/map/map-legend.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
// SPDX-License-Identifier: MIT
// Copyright contributors to the kepler.gl project

import React from 'react';
import React, {FC} from 'react';
import styled from 'styled-components';
import {rgb} from 'd3-color';
import ColorLegend from '../common/color-legend';
import RadiusLegend from '../common/radius-legend';
import {CHANNEL_SCALES, DIMENSIONS} from '@kepler.gl/constants';
import {FormattedMessage} from '@kepler.gl/localization';
import {Layer, LayerBaseConfig, VisualChannel, VisualChannelDescription} from '@kepler.gl/layers';
import {MapState} from '@kepler.gl/types';

interface StyledMapControlLegendProps {
width?: number;
last?: boolean;
}
import {getDistanceScales} from 'viewport-mercator-project';

export const StyledMapControlLegend = styled.div<StyledMapControlLegendProps>`
padding: 10px ${props => props.theme.mapControl.padding}px 10px
Expand Down Expand Up @@ -71,8 +74,8 @@ export type LayerSizeLegendProps = {
name: string | undefined;
};

/** @type {typeof import('./map-legend').LayerSizeLegend} */
export const LayerSizeLegend: React.FC<LayerSizeLegendProps> = ({label, name}) =>
/** @type {typeof import('./map-legend').LayerDefaultLegend} */
export const LayerDefaultLegend: React.FC<LayerSizeLegendProps> = ({label, name}) =>
label ? (
<div className="legend--layer_size-schema">
<p>
Expand Down Expand Up @@ -151,6 +154,60 @@ export const LayerColorLegend: React.FC<LayerColorLegendProps> = React.memo(
// eslint-disable-next-line react/display-name
LayerColorLegend.displayName = 'LayerColorLegend';

function getLayerRadiusScaleMetersToPixelsMultiplier(layer, mapState) {
// @ts-ignore this actually exist
const {metersPerPixel} = getDistanceScales(mapState);
// if no field size is defined we need to pass fixed radius = false
const fixedRadius = layer.config.visConfig.fixedRadius && Boolean(layer.config.sizeField);
return layer.getRadiusScaleByZoom(mapState, fixedRadius) / metersPerPixel[0];
}

export type LayerRadiusLegendProps = {
layer: Layer;
mapState?: MapState;
width: number;
visualChannel: VisualChannel;
};

export const LayerRadiusLegend: FC<LayerRadiusLegendProps> = React.memo(
({layer, width, visualChannel, mapState}) => {
const description = layer.getVisualChannelDescription(visualChannel.key);
const config = layer.config;

const enableSizeBy = description.measure;
const {scale, field, domain, range} = visualChannel;
const [sizeScale, sizeField, sizeDomain] = [scale, field, domain].map(k => config[k]);
let sizeRange = config.visConfig[range];

if (mapState) {
const radiusMultiplier = getLayerRadiusScaleMetersToPixelsMultiplier(layer, mapState);
sizeRange = sizeRange.map(v => v * radiusMultiplier);
}

return (
<div className="legend--layer__item">
<div className="legend--layer_size-schema">
<div>
{enableSizeBy ? <VisualChannelMetric name={enableSizeBy} /> : null}
<div className="legend--layer_size-legend">
{enableSizeBy ? (
<RadiusLegend
scaleType={sizeScale}
domain={sizeDomain}
fieldType={(sizeField && sizeField.type) || 'real'}
range={sizeRange}
width={width}
/>
) : null}
</div>
</div>
</div>
</div>
);
}
);
LayerRadiusLegend.displayName = 'LayerRadiusLegend';

const isColorChannel = visualChannel =>
[CHANNEL_SCALES.color, CHANNEL_SCALES.colorAggr].includes(visualChannel.channelScaleType);

Expand All @@ -161,6 +218,9 @@ export type LayerLegendHeaderProps = {
};
};

const isRadiusChannel = visualChannel =>
[CHANNEL_SCALES.radius].includes(visualChannel.channelScaleType);

export function LayerLegendHeaderFactory() {
/** @type {typeof import('./map-legend').LayerLegendHeader }> */
const LayerLegendHeader: React.FC<LayerLegendHeaderProps> = ({options, layer}) => {
Expand All @@ -174,13 +234,15 @@ export function LayerLegendHeaderFactory() {
export type LayerLegendContentProps = {
layer: Layer;
containerW: number;
mapState?: MapState;
};

export function LayerLegendContentFactory() {
/** @type {typeof import('./map-legend').LayerLegendContent }> */
const LayerLegendContent: React.FC<LayerLegendContentProps> = ({layer, containerW}) => {
const LayerLegendContent: React.FC<LayerLegendContentProps> = ({layer, containerW, mapState}) => {
const colorChannels = Object.values(layer.visualChannels).filter(isColorChannel);
const nonColorChannels = Object.values(layer.visualChannels).filter(vc => !isColorChannel(vc));
const width = containerW - 2 * DIMENSIONS.mapControl.padding;

return (
<>
Expand All @@ -190,7 +252,7 @@ export function LayerLegendContentFactory() {
key={colorChannel.key}
description={layer.getVisualChannelDescription(colorChannel.key)}
config={layer.config}
width={containerW - 2 * DIMENSIONS.mapControl.padding}
width={width}
colorChannel={colorChannel}
/>
) : null
Expand All @@ -199,15 +261,28 @@ export function LayerLegendContentFactory() {
const matchCondition = !visualChannel.condition || visualChannel.condition(layer.config);
const enabled = layer.config[visualChannel.field] || visualChannel.defaultMeasure;

const description = layer.getVisualChannelDescription(visualChannel.key);

return matchCondition && enabled ? (
<LayerSizeLegend
key={visualChannel.key}
label={description.label}
name={description.measure}
/>
) : null;
if (matchCondition && enabled) {
const description = layer.getVisualChannelDescription(visualChannel.key);
if (isRadiusChannel(visualChannel)) {
return (
<LayerRadiusLegend
key={visualChannel.key}
layer={layer}
mapState={mapState}
width={width}
visualChannel={visualChannel}
/>
);
}
return (
<LayerDefaultLegend
key={visualChannel.key}
label={description.label}
name={description.measure}
/>
);
}
return null;
})}
</>
);
Expand All @@ -219,7 +294,7 @@ export function LayerLegendContentFactory() {
export type MapLegendProps = {
layers?: ReadonlyArray<Layer>;
width?: number;
mapHeight?: number;
mapState?: MapState;
options?: {
showLayerName?: boolean;
};
Expand All @@ -228,13 +303,13 @@ export type MapLegendProps = {
MapLegendFactory.deps = [LayerLegendHeaderFactory, LayerLegendContentFactory];
function MapLegendFactory(LayerLegendHeader, LayerLegendContent) {
/** @type {typeof import('./map-legend').MapLegend }> */
const MapLegend: React.FC<MapLegendProps> = ({layers = [], width, mapHeight, options}) => (
const MapLegend: React.FC<MapLegendProps> = ({layers = [], width, mapState, options}) => (
<div
className="map-legend"
{...(mapHeight && {
{...(mapState?.height && {
style: {
/* subtracting rough size of 4 map control buttons and padding */
maxHeight: mapHeight - 250
maxHeight: mapState.height - 250
}
})}
>
Expand All @@ -252,7 +327,7 @@ function MapLegendFactory(LayerLegendHeader, LayerLegendContent) {
width={containerW}
>
<LayerLegendHeader options={options} layer={layer} />
<LayerLegendContent containerW={containerW} layer={layer} />
<LayerLegendContent containerW={containerW} layer={layer} mapState={mapState} />
</StyledMapControlLegend>
);
})}
Expand Down
Loading

0 comments on commit a24ba5e

Please sign in to comment.