Skip to content

Commit

Permalink
[feat] Free positioning of the legend (#2874)
Browse files Browse the repository at this point in the history
- Legend can be moved around
- Position is saved as offsets to the closest corner to handle window resizes

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>
Co-authored-by: Ilya Boyandin <iboyandin@foursquare.com>
  • Loading branch information
igorDykhta and ilyabo authored Dec 28, 2024
1 parent 2d1d8e5 commit cccc4be
Show file tree
Hide file tree
Showing 19 changed files with 682 additions and 138 deletions.
1 change: 1 addition & 0 deletions src/actions/src/action-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export const ActionTypes = {
OPEN_DELETE_MODAL: `${ACTION_PREFIX}OPEN_DELETE_MODAL`,
TOGGLE_MAP_CONTROL: `${ACTION_PREFIX}TOGGLE_MAP_CONTROL`,
SET_MAP_CONTROL_VISIBILITY: `${ACTION_PREFIX}SET_MAP_CONTROL_VISIBILITY`,
SET_MAP_CONTROL_SETTINGS: `${ACTION_PREFIX}SET_MAP_CONTROL_SETTINGS`,
ADD_NOTIFICATION: `${ACTION_PREFIX}ADD_NOTIFICATION`,
REMOVE_NOTIFICATION: `${ACTION_PREFIX}REMOVE_NOTIFICATION`,
SET_LOCALE: `${ACTION_PREFIX}SET_LOCALE`,
Expand Down
24 changes: 24 additions & 0 deletions src/actions/src/ui-state-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,30 @@ export const setMapControlVisibility: (
})
);

/** SET_MAP_CONTROL_SETTINGS */
export type setMapControlSettingsUpdaterAction = {
payload: {
panelId: string;
settings: Record<string, unknown>;
};
};

/**
* Set map control settings
* @memberof uiStateActions
* @param panelId - map control panel id, one of the keys of: [`DEFAULT_MAP_CONTROLS`](#default_map_controls)
* @public
*/
export const setMapControlSettings: (
panelId: string,
settings: Record<string, unknown>
) => Merge<
setMapControlSettingsUpdaterAction,
{type: typeof ActionTypes.SET_MAP_CONTROL_SETTINGS}
> = createAction(ActionTypes.SET_MAP_CONTROL_SETTINGS, (panelId, settings) => ({
payload: {panelId, settings}
}));

/** OPEN_DELETE_MODAL */
export type OpenDeleteModalUpdaterAction = {
payload: string;
Expand Down
31 changes: 31 additions & 0 deletions src/components/src/common/icons/draggable-dots.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import Base from './base';

export default class DraggableDots extends Component {
static propTypes = {
/** Set the height of the icon, ex. '16px' */
height: PropTypes.string
};

static defaultProps = {
height: '16px',
viewBox: '0 0 16 16',
predefinedClassName: 'data-ex-icons-draggabledots'
};

render() {
return (
<Base {...this.props}>
<circle cx="14" cy="5.5" r="1" transform="rotate(90 14 5.5)" />
<circle cx="14" cy="10.5" r="1" transform="rotate(90 14 10.5)" />
<circle cx="10" cy="5.5" r="1" transform="rotate(90 10 5.5)" />
<circle cx="10" cy="10.5" r="1" transform="rotate(90 10 10.5)" />
<circle cx="6" cy="5.5" r="1" transform="rotate(90 6 5.5)" />
<circle cx="6" cy="10.5" r="1" transform="rotate(90 6 10.5)" />
<circle cx="2" cy="5.5" r="1" transform="rotate(90 2 5.5)" />
<circle cx="2" cy="10.5" r="1" transform="rotate(90 2 10.5)" />
</Base>
);
}
}
25 changes: 25 additions & 0 deletions src/components/src/common/icons/horizontal-resize-handle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import Base from './base';

export default class HorizontalResizeHandle extends Component {
static propTypes = {
/** Set the height of the icon, ex. '16px' */
height: PropTypes.string
};

static defaultProps = {
height: '16px',
viewBox: '0 0 16 16',
predefinedClassName: 'data-ex-icons-horizontalresizehandle'
};

render() {
return (
<Base {...this.props}>
<rect x="15" y="6" width="1" height="14" rx="0.5" transform="rotate(90 15 6)" />
<rect x="15" y="9" width="1" height="14" rx="0.5" transform="rotate(90 15 9)" />
</Base>
);
}
}
2 changes: 2 additions & 0 deletions src/components/src/common/icons/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export {default as Db} from './db';
export {default as Delete} from './delete';
export {default as Docs} from './docs';
export {default as DragNDrop} from './drag-n-drop';
export {default as DraggableDots} from './draggable-dots';
export {default as Edit} from './edit';
export {default as Email} from './email';
export {default as Expand} from './expand';
Expand All @@ -44,6 +45,7 @@ export {default as Gear} from './gear';
export {default as Hash} from './hash';
export {default as Help} from './help';
export {default as Histogram} from './histogram';
export {default as HorizontalResizeHandle} from './horizontal-resize-handle';
export {default as IconWrapper} from './base';
export {default as Info} from './info';
export {default as Layers} from './layers';
Expand Down
137 changes: 137 additions & 0 deletions src/components/src/hooks/use-legend-position.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import {useMemo, useRef, useCallback, useEffect} from 'react';

export type MapLegendControlSettings = {
position: {
x: number;
y: number;
anchorX: 'left' | 'right';
anchorY: 'top' | 'bottom';
};
contentHeight: number;
};

type Params = {
legendContentRef: React.MutableRefObject<HTMLElement | null>;
isSidePanelShown: boolean;
settings: MapLegendControlSettings;
onChangeSettings: (settings: Partial<MapLegendControlSettings>) => void;
theme: Record<string, any>;
};

type ReturnType = {
positionStyles: Record<string, unknown>;
updatePosition: () => void;
contentHeight: number;
startResize: () => void;
resize: (deltaY: number) => void;
};

const MARGIN = {
left: 10,
top: 10,
right: 10,
bottom: 30
};
const DEFAULT_POSITION: MapLegendControlSettings['position'] = {
x: MARGIN.right,
y: MARGIN.bottom,
anchorX: 'right',
anchorY: 'bottom'
};
const MIN_CONTENT_HEIGHT = 100;

/**
* Returns a function that calculates the anchored position of the map legend
* that is being dragged.
*/
export default function useLegendPosition({
legendContentRef,
isSidePanelShown,
settings,
onChangeSettings,
theme
}: Params): ReturnType {
const pos = settings?.position ?? DEFAULT_POSITION;
const contentHeight = settings?.contentHeight ?? -1;
const positionStyles = useMemo(() => ({[pos.anchorX]: pos.x, [pos.anchorY]: pos.y}), [pos]);
const startHeightRef = useRef(0);

const calcPosition = useCallback((): MapLegendControlSettings['position'] => {
const root = legendContentRef.current?.closest('.kepler-gl');
const legendContent = legendContentRef.current;
if (!legendContent || !(root instanceof HTMLElement)) {
return DEFAULT_POSITION;
}
const legendRect = legendContent.getBoundingClientRect();
const mapRootBounds = root.getBoundingClientRect();
const leftSidebarOffset = isSidePanelShown ? sidePanelWidth : 0;

const leftOffset = Math.max(
MARGIN.left,
legendRect.left - mapRootBounds.left - leftSidebarOffset
);
const rightOffset = Math.max(MARGIN.right, mapRootBounds.right - legendRect.right);

const topOffset = Math.max(MARGIN.top, legendRect.top);
const bottomOffset = Math.max(MARGIN.bottom, mapRootBounds.bottom - legendRect.bottom);

return {
...(leftOffset < rightOffset
? {x: leftOffset + leftSidebarOffset, anchorX: 'left'}
: {x: rightOffset, anchorX: 'right'}),
...(topOffset < bottomOffset
? {y: topOffset, anchorY: 'top'}
: {y: bottomOffset, anchorY: 'bottom'})
};
}, [isSidePanelShown]);

Check warning on line 86 in src/components/src/hooks/use-legend-position.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

React Hook useCallback has missing dependencies: 'legendContentRef' and 'sidePanelWidth'. Either include them or remove the dependency array
const updatePosition = useCallback(() => onChangeSettings({position: calcPosition()}), [
calcPosition,
onChangeSettings
]);

const startResize = useCallback(() => {
const content = legendContentRef.current?.querySelector('.map-control__panel-content');
if (content instanceof HTMLElement) {
startHeightRef.current = content.offsetHeight;
}
}, []);
const resize = useCallback(
deltaY => {
const root = legendContentRef.current?.closest('.kepler-gl');
const legendContent = legendContentRef.current;
if (root instanceof HTMLElement && legendContent) {
const legendRect = legendContent.getBoundingClientRect();
const nextHeight = Math.min(
root.offsetHeight - legendRect.top - 100,
Math.max(MIN_CONTENT_HEIGHT, startHeightRef.current + deltaY)
);
onChangeSettings({contentHeight: nextHeight});
if (contentHeight > 0 && pos.anchorY === 'bottom') {
onChangeSettings({position: {...pos, y: pos.y - (nextHeight - contentHeight)}});
}
}
},
[contentHeight, pos, onChangeSettings]
);

// Shift when side panel is shown/hidden
const sidePanelWidth = theme.sidePanel?.width || 0;
const posRef = useRef(pos);
posRef.current = pos;
useEffect(() => {
const currentPos = posRef.current;
if (currentPos.anchorX === 'left') {
if (isSidePanelShown) {
if (currentPos.x <= sidePanelWidth + MARGIN.left) {
onChangeSettings({position: {...currentPos, x: sidePanelWidth + MARGIN.left}});
}
} else {
onChangeSettings({
position: {...currentPos, x: Math.max(MARGIN.left, currentPos.x - sidePanelWidth)}
});
}
}
}, [isSidePanelShown, onChangeSettings]);

return {positionStyles, updatePosition, contentHeight, startResize, resize};
}
1 change: 1 addition & 0 deletions src/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,3 +486,4 @@ export {CloudListProvider, useCloudListProvider} from './hooks/use-cloud-list-pr
export {default as useDndEffects} from './hooks/use-dnd-effects';
export {default as useDndLayers} from './hooks/use-dnd-layers';
export {default as useFeatureFlags} from './hooks/use-feature-flags';
export {default as useLegendPosition} from './hooks/use-legend-position';
3 changes: 2 additions & 1 deletion src/components/src/map/map-control-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {Close, Pin} from '../common/icons';
import Switch from '../common/switch';
import {MapState} from '@kepler.gl/types';
import {ActionHandler, toggleSplitMapViewport} from '@kepler.gl/actions';
import classNames from 'classnames';

const StyledMapControlPanel = styled.div`
background-color: ${props => props.theme.mapPanelBackgroundColor};
Expand Down Expand Up @@ -154,7 +155,7 @@ function MapControlPanelFactory() {
transform: `scale(${scale})`,
marginBottom: '8px !important'
}}
className={className}
className={classNames('map-control-panel', className)}
>
{mapState?.isSplit && isViewportUnsyncAllowed ? (
<StyledMapControlPanelHeaderSplitViewportsTools>
Expand Down
Loading

0 comments on commit cccc4be

Please sign in to comment.