Skip to content

Commit

Permalink
Merge pull request #257 from VEuPathDB/95-bubble-marker-component
Browse files Browse the repository at this point in the history
Bubble markers map type
  • Loading branch information
chowington committed Aug 9, 2023
2 parents 551eb20 + ff12879 commit fcf36a4
Show file tree
Hide file tree
Showing 29 changed files with 1,902 additions and 581 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ dist
# TernJS port file
.tern-port

# VSCode config files
.vscode

.editorconfig

.pnp.*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import React from 'react';
import { range } from 'd3';
import _ from 'lodash';

// set props for custom legend function
export interface PlotLegendBubbleProps {
legendMax: number;
valueToDiameterMapper: ((value: number) => number) | undefined;
}

// legend ellipsis function for legend title and legend items (from custom legend work)
// const legendEllipsis = (label: string, ellipsisLength: number) => {
// return (label || '').length > ellipsisLength
// ? (label || '').substring(0, ellipsisLength) + '...'
// : label;
// };

export default function PlotBubbleLegend({
legendMax,
valueToDiameterMapper,
}: PlotLegendBubbleProps) {
if (valueToDiameterMapper) {
// Declare constants
const tickFontSize = '0.8em';
// const legendTextSize = '1.0em';
const circleStrokeWidth = 3;
const padding = 5;
const numCircles = 3;

// The largest circle's value will be the first number that's larger than
// legendMax and has only one significant digit. Each smaller circle will
// be half the size of the last (rounded and >= 1)
const legendMaxLog10 = Math.floor(Math.log10(legendMax));
const largestCircleValue =
legendMax <= 10
? legendMax
: (Number(legendMax.toPrecision(1)[0]) + 1) * 10 ** legendMaxLog10;
const circleValues = _.uniq(
range(numCircles)
.map((i) => Math.round(largestCircleValue / 2 ** i))
.filter((value) => value >= 1)
);

const largestCircleDiameter = valueToDiameterMapper(largestCircleValue);
const largestCircleRadius = largestCircleDiameter / 2;

const tickLength = largestCircleRadius + 5;

return (
<svg
width={largestCircleDiameter + (tickLength - largestCircleRadius) + 50}
height={largestCircleDiameter + circleStrokeWidth * 2 + padding * 2}
>
{circleValues.map((value, i) => {
const circleDiameter = valueToDiameterMapper(value);
const circleRadius = circleDiameter / 2;
const tickY =
padding +
largestCircleDiameter +
circleStrokeWidth -
circleDiameter;

return (
<>
<circle
cx={padding + largestCircleRadius + circleStrokeWidth}
cy={
padding +
largestCircleDiameter +
circleStrokeWidth -
circleRadius
}
r={circleRadius}
stroke="black"
strokeWidth={circleStrokeWidth}
fill="white"
/>
<g
className="axisTick"
overflow="visible"
key={'gradientTick' + i}
>
<line
x1={padding + largestCircleRadius + circleStrokeWidth + 1}
x2={
padding +
largestCircleRadius +
circleStrokeWidth +
tickLength +
1
}
y1={tickY}
y2={tickY}
stroke="black"
strokeDasharray="2 2"
strokeWidth={2}
/>
<text
x={padding + largestCircleRadius + tickLength + 5}
y={tickY}
dominantBaseline="middle"
fontSize={tickFontSize}
>
{value}
</text>
</g>
</>
);
})}
</svg>
);
} else {
return null;
}

// for display, convert large value with k (e.g., 12345 -> 12k): return original value if less than a criterion
// const sumLabel = props.markerLabel ?? String(fullPieValue);
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ export default function PlotGradientLegend({

return (
<div>
<svg id="gradientLegend" height={gradientBoxHeight + 20} width={150}>
<svg
id="gradientLegend"
height={gradientBoxHeight + 20}
width={gradientBoxWidth + 60}
>
<defs>
<linearGradient id="linearGradient" x1="0" x2="0" y1="1" y2="0">
{stopPoints}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import PlotListLegend, { PlotListLegendProps } from './PlotListLegend';
import PlotGradientLegend, {
PlotLegendGradientProps,
} from './PlotGradientLegend';
import PlotBubbleLegend, { PlotLegendBubbleProps } from './PlotBubbleLegend';

interface PlotLegendBaseProps extends ContainerStylesAddon {
legendTitle?: string;
Expand All @@ -13,6 +14,7 @@ export type PlotLegendProps = PlotLegendBaseProps &
(
| ({ type: 'list' } & PlotListLegendProps)
| ({ type: 'colorscale' } & PlotLegendGradientProps)
| ({ type: 'bubble' } & PlotLegendBubbleProps)
);

export default function PlotLegend({
Expand All @@ -29,7 +31,8 @@ export default function PlotLegend({
{((type === 'list' &&
((otherProps as PlotListLegendProps).legendItems.length > 1 ||
(otherProps as PlotListLegendProps).showOverlayLegend)) ||
type === 'colorscale') && (
type === 'colorscale' ||
type === 'bubble') && (
<div
style={{
display: 'inline-block', // for general usage (e.g., story)
Expand Down Expand Up @@ -63,6 +66,9 @@ export default function PlotLegend({
{type === 'colorscale' && (
<PlotGradientLegend {...(otherProps as PlotLegendGradientProps)} />
)}
{type === 'bubble' && (
<PlotBubbleLegend {...(otherProps as PlotLegendBubbleProps)} />
)}
</div>
)}
</>
Expand Down
2 changes: 2 additions & 0 deletions packages/libs/components/src/map/BoundsDriftMarker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default function BoundsDriftMarker({
showPopup,
popupContent,
popupClass,
zIndexOffset,
}: BoundsDriftMarkerProps) {
const map = useMap();
const boundingBox = new LatLngBounds([
Expand Down Expand Up @@ -300,6 +301,7 @@ export default function BoundsDriftMarker({
mouseout: (e: LeafletMouseEvent) => handleMouseOut(e),
dblclick: handleDoubleClick,
}}
zIndexOffset={zIndexOffset}
{...optionalIconProp}
>
{showPopup && popup}
Expand Down
173 changes: 173 additions & 0 deletions packages/libs/components/src/map/BubbleMarker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// import React from 'react';
import L from 'leaflet';
import BoundsDriftMarker, { BoundsDriftMarkerProps } from './BoundsDriftMarker';

import { ContainerStylesAddon } from '../types/plots';

export interface BubbleMarkerProps extends BoundsDriftMarkerProps {
data: {
/* The size value */
value: number;
diameter: number;
/* The color value (shown in the popup) */
colorValue?: number;
/* Label shown next to the color value in the popup */
colorLabel?: string;
color?: string;
};
// isAtomic: add a special thumbtack icon if this is true
isAtomic?: boolean;
onClick?: (event: L.LeafletMouseEvent) => void | undefined;
}

/**
* this is a SVG bubble marker icon
*/
export default function BubbleMarker(props: BubbleMarkerProps) {
const { html: svgHTML, diameter: size } = bubbleMarkerSVGIcon(props);

// set icon as divIcon
const SVGBubbleIcon = L.divIcon({
className: 'leaflet-canvas-icon', // may need to change this className but just leave it as it for now
iconSize: new L.Point(size, size), // this will make icon to cover up SVG area!
iconAnchor: new L.Point(size / 2, size / 2), // location of topleft corner: this is used for centering of the icon like transform/translate in CSS
html: svgHTML, // divIcon HTML svg code generated above
});

// anim check duration exists or not
const duration: number = props.duration ? props.duration : 300;

const popupContent = (
<div style={{ fontSize: 16, lineHeight: '150%' }}>
<div>
<b style={{ marginRight: '0.15rem' }}>Count</b> {props.data.value}
</div>
{props.data.colorValue && (
<div style={{ marginTop: '0.5rem' }}>
<b style={{ marginRight: '0.15rem' }}>{props.data.colorLabel}</b>{' '}
{props.data.colorValue}
</div>
)}
</div>
);

return (
<BoundsDriftMarker
id={props.id}
position={props.position}
bounds={props.bounds}
icon={SVGBubbleIcon as L.Icon}
duration={duration}
// This makes sure smaller markers are on top of larger ones.
// The factor of 1000 ensures that the offset dominates over
// the default zIndex, which itself varies.
zIndexOffset={-props.data.value * 1000}
popupContent={{
content: popupContent,
size: {
width: 170,
height: 100,
},
}}
showPopup={props.showPopup}
/>
);
}

type BubbleMarkerStandaloneProps = Omit<
BubbleMarkerProps,
| 'id'
| 'position'
| 'bounds'
| 'onClick'
| 'duration'
| 'showPopup'
| 'popupClass'
| 'popupContent'
> &
ContainerStylesAddon;

export function BubbleMarkerStandalone(props: BubbleMarkerStandaloneProps) {
const { html, diameter } = bubbleMarkerSVGIcon(props);
// NOTE: the font size and line height would normally come from the .leaflet-container class
// but we won't be using that. You can override these with `containerStyles` if you like.
return (
<div
style={{
fontSize: '12px',
lineHeight: 1.5,
width: diameter,
height: diameter,
...props.containerStyles,
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

function bubbleMarkerSVGIcon(props: BubbleMarkerStandaloneProps): {
html: string;
diameter: number;
} {
// const scale = props.markerScale ?? MarkerScaleDefault;
const diameter = props.data.diameter;
const radius = diameter / 2;
// set outer white circle size to describe white boundary
const outlineWidth = 2;
const outlineRadius = radius + outlineWidth;

let svgHTML: string = '';

// set drawing area
svgHTML +=
'<svg width="' +
outlineRadius * 2 +
'" height="' +
outlineRadius * 2 +
'">'; // initiate svg marker icon

// for display, convert large value with k (e.g., 12345 -> 12k): return original value if less than a criterion
// const sumLabel = props.markerLabel ?? String(fullPieValue);

// draw a larger white-filled circle
svgHTML +=
'<circle cx="' +
outlineRadius +
'" cy="' +
outlineRadius +
'" r="' +
outlineRadius +
'" stroke="green" stroke-width="0" fill="white" />';

// create bubble
svgHTML +=
'<circle cx="' +
outlineRadius +
'" cy="' +
outlineRadius +
'" r="' +
radius +
'" stroke="white" stroke-width="0" fill="' +
props.data.color +
'" />';

//TODO: do we need to show total number for bubble marker?
// adding total number text/label and centering it
// svgHTML +=
// '<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" opacity="1" fill="white" font-family="Arial, Helvetica, sans-serif" font-weight="bold" font-size="1em">' +
// props.data.value +
// '</text>';

// check isAtomic: draw pushpin if true
if (props.isAtomic) {
let pushPinCode = '&#128392;';
svgHTML +=
'<text x="86%" y="14%" dominant-baseline="middle" text-anchor="middle" opacity="0.75" font-weight="bold" font-size="1.2em">' +
pushPinCode +
'</text>';
}

svgHTML += '</svg>';

return { html: svgHTML, diameter: diameter };
}
2 changes: 2 additions & 0 deletions packages/libs/components/src/map/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export interface MarkerProps {
height: number;
};
};
/* This offset gets added to the default zIndex */
zIndexOffset?: number;
}

export type AnimationFunction = ({
Expand Down
Loading

0 comments on commit fcf36a4

Please sign in to comment.