diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index 52ce8a7f26a..f31be7a2a1f 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -13,9 +13,9 @@ import { getRelativeMouse, domainExtent, minBy, - maxBy, exposeInstanceOnWindow, PointVector, + clamp, } from "@ourworldindata/utils" import { TextWrap } from "@ourworldindata/components" import { observable, computed, action } from "mobx" @@ -31,7 +31,6 @@ import { GRAPHER_DARK_TEXT, GRAPHER_FONT_SCALE_9_6, GRAPHER_FONT_SCALE_10_5, - GRAPHER_FONT_SCALE_14, } from "../core/GrapherConstants" import { ScaleType, @@ -45,13 +44,12 @@ import { ChartManager } from "../chart/ChartManager" import { scaleLinear, ScaleLinear } from "d3-scale" import { extent } from "d3-array" import { select } from "d3-selection" -import { Text } from "../text/Text" import { DEFAULT_SLOPE_CHART_COLOR, LabelledSlopesProps, SlopeChartSeries, SlopeChartValue, - SlopeProps, + SlopeEntryProps, } from "./SlopeChartConstants" import { OwidTable } from "@ourworldindata/core-table" import { autoDetectYColumnSlugs, makeSelectionArray } from "../chart/ChartUtils" @@ -63,6 +61,12 @@ export interface SlopeChartManager extends ChartManager { isModalOpen?: boolean } +const LABEL_SLOPE_PADDING = 8 +const LABEL_LABEL_PADDING = 2 + +const TOP_PADDING = 6 +const BOTTOM_PADDING = 20 + @observer export class SlopeChart extends React.Component<{ @@ -111,11 +115,7 @@ export class SlopeChart }) } - @computed get maxLegendWidth() { - return this.sidebarMaxWidth - } - - @action.bound onSlopeMouseOver(slopeProps: SlopeProps) { + @action.bound onSlopeMouseOver(slopeProps: SlopeEntryProps) { this.hoverKey = slopeProps.seriesName } @@ -234,19 +234,16 @@ export class SlopeChart return uniq(this.series.map((series) => series.color)) } - @computed private get sidebarMaxWidth() { - return this.bounds.width * 0.5 + @computed private get legendWidth(): number { + return new VerticalColorLegend({ manager: this }).width } - private sidebarMinWidth = 100 - - @computed private get legendWidth() { - return new VerticalColorLegend({ manager: this }).width + @computed get maxLegendWidth(): number { + return this.bounds.width * 0.5 } @computed.struct private get sidebarWidth() { - const { sidebarMinWidth, sidebarMaxWidth, legendWidth } = this - return Math.max(Math.min(legendWidth, sidebarMaxWidth), sidebarMinWidth) + return Math.min(this.legendWidth, this.maxLegendWidth) } // correction is to account for the space taken by the legend @@ -254,7 +251,7 @@ export class SlopeChart const { sidebarWidth, showLegend } = this return showLegend - ? this.bounds.padRight(sidebarWidth + 20) + ? this.bounds.padRight(sidebarWidth + 16) : this.bounds } @@ -495,22 +492,8 @@ export class SlopeChart } } -function calculateBounds(containerBounds: Bounds, yAxis: VerticalAxis) { - const longestTick = maxBy( - yAxis.tickLabels.map((tickLabel) => tickLabel.formattedValue), - (tick) => tick.length - ) - const axisWidth = Bounds.forText(longestTick).width - return new Bounds( - containerBounds.x, - containerBounds.y, - axisWidth, - containerBounds.height - ) -} - @observer -class Slope extends React.Component { +class SlopeEntry extends React.Component { line: SVGElement | null = null @computed get isInBackground() { @@ -531,20 +514,20 @@ class Slope extends React.Component { size, hasLeftLabel, hasRightLabel, - leftValueStr, - rightValueStr, - leftLabel, - rightLabel, - labelFontSize, - leftLabelBounds, - rightLabelBounds, + leftValueLabel, + leftEntityLabel, + rightValueLabel, + rightEntityLabel, + leftEntityLabelBounds, + rightEntityLabelBounds, isFocused, isHovered, + isMultiHoverMode, } = this.props const { isInBackground } = this - const lineColor = isInBackground ? "#e2e2e2" : color //'#89C9CF' - const labelColor = isInBackground ? "#aaa" : "#333" + const lineColor = isInBackground ? "#e2e2e2" : color + const labelColor = isInBackground ? "#ccc" : GRAPHER_DARK_TEXT const opacity = isHovered ? 1 : isFocused ? 0.7 : 0.5 const lineStrokeWidth = isHovered ? size * 2 @@ -552,49 +535,17 @@ class Slope extends React.Component { ? 1.5 * size : size - const leftValueLabelBounds = Bounds.forText(leftValueStr, { - fontSize: labelFontSize, - }) - const rightValueLabelBounds = Bounds.forText(rightValueStr, { - fontSize: labelFontSize, - }) + const showDots = isFocused || isHovered + const showValueLabels = isFocused || isHovered + const showLeftEntityLabel = isFocused || (isHovered && isMultiHoverMode) + + const sharedLabelProps = { + fill: labelColor, + style: { cursor: "default" }, + } return ( - {hasLeftLabel && - leftLabel.render( - leftLabelBounds.x + leftLabelBounds.width, - leftLabelBounds.y, - { - textProps: { - textAnchor: "end", - fill: labelColor, - fontWeight: - isFocused || isHovered ? "bold" : undefined, - style: { cursor: "default" }, - }, - } - )} - {hasLeftLabel && ( - - {leftValueStr} - - )} - (this.line = el)} x1={x1} @@ -605,34 +556,76 @@ class Slope extends React.Component { strokeWidth={lineStrokeWidth} opacity={opacity} /> - - {hasRightLabel && ( - - {rightValueStr} - + {showDots && ( + <> + + + )} + {/* value label on the left */} + {hasLeftLabel && + showValueLabels && + leftValueLabel.render( + x1 - LABEL_SLOPE_PADDING, + leftEntityLabelBounds.y, + { + textProps: { + ...sharedLabelProps, + textAnchor: "end", + }, + } + )} + {/* entity label on the left */} + {hasLeftLabel && + showLeftEntityLabel && + leftEntityLabel.render( + // -2px is a minor visual correction + leftEntityLabelBounds.x - 2, + leftEntityLabelBounds.y, + { + textProps: { + ...sharedLabelProps, + textAnchor: "end", + }, + } + )} + {/* value label on the right */} {hasRightLabel && - rightLabel.render(rightLabelBounds.x, rightLabelBounds.y, { - textProps: { - fill: labelColor, - fontWeight: - isFocused || isHovered ? "bold" : undefined, - style: { cursor: "default" }, - }, - })} + showValueLabels && + rightValueLabel.render( + rightEntityLabelBounds.x + + rightEntityLabel.width + + LABEL_LABEL_PADDING, + rightEntityLabelBounds.y, + { + textProps: sharedLabelProps, + } + )} + {/* entity label on the right */} + {hasRightLabel && + rightEntityLabel.render( + rightEntityLabelBounds.x, + rightEntityLabelBounds.y, + { + textProps: { + ...sharedLabelProps, + fontWeight: + isFocused || isHovered ? "bold" : undefined, + }, + } + )} ) } @@ -688,6 +681,10 @@ class LabelledSlopes ) } + @computed private get isMultiHoverMode() { + return this.hoveredSeriesNames.length > 1 + } + @computed private get isPortrait() { return this.manager.isNarrow || this.manager.isStaticAndSmall } @@ -753,134 +750,156 @@ class LabelledSlopes } @computed get yRange(): [number, number] { - return this.props.bounds.padTop(6).padBottom(24).yRange() + return this.bounds + .padTop(TOP_PADDING) + .padBottom(BOTTOM_PADDING) + .yRange() } - @computed private get xScale(): ScaleLinear { - const { bounds, isPortrait, xDomain, yAxis } = this - const padding = isPortrait ? 0 : calculateBounds(bounds, yAxis).width - return scaleLinear() - .domain(xDomain) - .range(bounds.padWidth(padding).xRange()) + @computed get yAxisWidth(): number { + return this.yAxis.width + 5 // 5px account for the tick marks + } + + @computed get xRange(): [number, number] { + // take into account the space taken by the yAxis and slope labels + const bounds = this.bounds + .padLeft(this.yAxisWidth + 4) + .padLeft(this.maxLabelWidth) + .padRight(this.maxLabelWidth) + + // pick a reasonable width based on an ideal aspect ratio + const idealAspectRatio = 0.9 + const availableWidth = bounds.width + const idealWidth = idealAspectRatio * bounds.height + const slopeWidth = this.isPortrait + ? availableWidth + : clamp(idealWidth, 220, availableWidth) + + const leftRightPadding = (availableWidth - slopeWidth) / 2 + return bounds + .padLeft(leftRightPadding) + .padRight(leftRightPadding) + .xRange() } - @computed get maxLabelWidth() { - return this.bounds.width / 5 + @computed private get xScale(): ScaleLinear { + const { xDomain, xRange } = this + return scaleLinear().domain(xDomain).range(xRange) } - @computed private get initialSlopeData() { - const { - data, - isPortrait, - xScale, - yAxis, - sizeScale, - yColumn, - yDomain, - maxLabelWidth: maxWidth, - } = this - - const slopeData: SlopeProps[] = [] - - data.forEach((series) => { - // Ensure values fit inside the chart - if ( - !series.values.every( - (d) => d.y >= yDomain[0] && d.y <= yDomain[1] - ) + @computed get maxLabelWidth(): number { + const { slopeLabels } = this + const maxLabelWidths = slopeLabels.map((slope) => { + const entityLabelWidth = slope.leftEntityLabel.width + const maxValueLabelWidth = Math.max( + slope.leftValueLabel.width, + slope.rightValueLabel.width ) - return + return ( + entityLabelWidth + + maxValueLabelWidth + + LABEL_SLOPE_PADDING + + LABEL_LABEL_PADDING + ) + }) + return max(maxLabelWidths) ?? 0 + } + + @computed get allowedLabelWidth() { + return this.bounds.width * 0.2 + } + @computed private get slopeLabels() { + const { isPortrait, yColumn, allowedLabelWidth: maxWidth } = this + + return this.data.map((series) => { const text = series.seriesName const [v1, v2] = series.values - const [x1, x2] = [xScale(v1.x), xScale(v2.x)] - const [y1, y2] = [yAxis.place(v1.y), yAxis.place(v2.y)] const fontSize = (isPortrait ? GRAPHER_FONT_SCALE_9_6 : GRAPHER_FONT_SCALE_10_5) * this.fontSize const leftValueStr = yColumn.formatValueShort(v1.y) const rightValueStr = yColumn.formatValueShort(v2.y) - const leftValueWidth = Bounds.forText(leftValueStr, { - fontSize, - }).width - const rightValueWidth = Bounds.forText(rightValueStr, { - fontSize, - }).width - const leftLabel = new TextWrap({ - maxWidth, + + // value labels + const valueLabelProps = { + maxWidth: Infinity, // no line break fontSize, lineHeight: 1, - text, + } + const leftValueLabel = new TextWrap({ + text: leftValueStr, + ...valueLabelProps, + }) + const rightValueLabel = new TextWrap({ + text: rightValueStr, + ...valueLabelProps, }) - const rightLabel = new TextWrap({ + + // entity labels + const entityLabelProps = { + ...valueLabelProps, maxWidth, - fontSize, - lineHeight: 1, + fontWeight: 700, + } + const leftEntityLabel = new TextWrap({ text, + ...entityLabelProps, }) + const rightEntityLabel = new TextWrap({ + text, + ...entityLabelProps, + }) + + return { + seriesName: series.seriesName, + leftValueLabel, + leftEntityLabel, + rightValueLabel, + rightEntityLabel, + } + }) + } + + @computed private get initialSlopeData() { + const { data, slopeLabels, xScale, yAxis, yDomain, sizeScale } = this + + const slopeData: SlopeEntryProps[] = [] + + data.forEach((series, i) => { + // Ensure values fit inside the chart + if ( + !series.values.every( + (d) => d.y >= yDomain[0] && d.y <= yDomain[1] + ) + ) + return + + const labels = slopeLabels[i] + const [v1, v2] = series.values + const [x1, x2] = [xScale(v1.x), xScale(v2.x)] + const [y1, y2] = [yAxis.place(v1.y), yAxis.place(v2.y)] slopeData.push({ + ...labels, x1, y1, x2, y2, color: series.color, size: sizeScale(series.size) || 1, - leftValueStr, - rightValueStr, - leftValueWidth, - rightValueWidth, - leftLabel, - rightLabel, - labelFontSize: fontSize, seriesName: series.seriesName, isFocused: false, isHovered: false, hasLeftLabel: true, hasRightLabel: true, - } as SlopeProps) + } as SlopeEntryProps) }) return slopeData } - @computed get maxValueWidth() { - return max(this.initialSlopeData.map((s) => s.leftValueWidth)) as number - } - - @computed private get labelAccountedSlopeData() { - const { maxLabelWidth, maxValueWidth } = this - - return this.initialSlopeData.map((slope) => { - // Squish slopes to make room for labels - const x1 = slope.x1 + maxLabelWidth + maxValueWidth + 8 - const x2 = slope.x2 - maxLabelWidth - maxValueWidth - 8 - - // Position the labels - const leftLabelBounds = new Bounds( - x1 - slope.leftValueWidth - 12 - slope.leftLabel.width, - slope.y1 - slope.leftLabel.height / 2, - slope.leftLabel.width, - slope.leftLabel.height - ) - const rightLabelBounds = new Bounds( - x2 + slope.rightValueWidth + 12, - slope.y2 - slope.rightLabel.height / 2, - slope.rightLabel.width, - slope.rightLabel.height - ) - - return { - ...slope, - x1: x1, - x2: x2, - leftLabelBounds: leftLabelBounds, - rightLabelBounds: rightLabelBounds, - } - }) - } - @computed get backgroundGroups() { return this.slopeData.filter( (group) => !(group.isHovered || group.isFocused) @@ -894,20 +913,46 @@ class LabelledSlopes } // Get the final slope data with hover focusing and collision detection - @computed get slopeData(): SlopeProps[] { + @computed get slopeData(): SlopeEntryProps[] { const { focusedSeriesNames, hoveredSeriesNames } = this - let slopeData = this.labelAccountedSlopeData + + let slopeData = this.initialSlopeData slopeData = slopeData.map((slope) => { + // used for collision detection + const leftEntityLabelBounds = new Bounds( + // labels on the left are placed like this: | + slope.x1 - + LABEL_SLOPE_PADDING - + slope.leftValueLabel.width - + LABEL_LABEL_PADDING, + slope.y1 - slope.leftEntityLabel.lines[0].height / 2, + slope.leftEntityLabel.width, + slope.leftEntityLabel.height + ) + const rightEntityLabelBounds = new Bounds( + // labels on the left are placed like this: | + slope.x2 + LABEL_SLOPE_PADDING, + slope.y2 - slope.rightEntityLabel.height / 2, + slope.rightEntityLabel.width, + slope.rightEntityLabel.height + ) + + // used to determine priority for labelling conflicts + const isFocused = focusedSeriesNames.includes(slope.seriesName) + const isHovered = hoveredSeriesNames.includes(slope.seriesName) + return { ...slope, - isFocused: focusedSeriesNames.includes(slope.seriesName), - isHovered: hoveredSeriesNames.includes(slope.seriesName), + leftEntityLabelBounds, + rightEntityLabelBounds, + isFocused, + isHovered, } }) // How to work out which of two slopes to prioritize for labelling conflicts - function chooseLabel(s1: SlopeProps, s2: SlopeProps) { + function chooseLabel(s1: SlopeEntryProps, s2: SlopeEntryProps) { if (s1.isHovered && !s2.isHovered) // Hovered slopes always have priority return s1 @@ -916,10 +961,10 @@ class LabelledSlopes // Focused slopes are next in priority return s1 else if (!s1.isFocused && s2.isFocused) return s2 - else if (s1.hasLeftLabel && !s2.hasLeftLabel) + else if (s1.hasRightLabel && !s2.hasRightLabel) // Slopes which already have one label are prioritized for the other side return s1 - else if (!s1.hasLeftLabel && s2.hasLeftLabel) return s2 + else if (!s1.hasRightLabel && s2.hasRightLabel) return s2 else if (s1.size > s2.size) // Larger sizes get the next priority return s1 @@ -932,12 +977,16 @@ class LabelledSlopes slopeData.forEach((s2) => { if ( s1 !== s2 && - s1.hasLeftLabel && - s2.hasLeftLabel && - s1.leftLabelBounds.intersects(s2.leftLabelBounds) + s1.hasRightLabel && + s2.hasRightLabel && + // entity labels don't necessarily share the same x position. + // that's why we check for vertical intersection only + s1.rightEntityLabelBounds.hasVerticalOverlap( + s2.rightEntityLabelBounds + ) ) { - if (chooseLabel(s1, s2) === s1) s2.hasLeftLabel = false - else s1.hasLeftLabel = false + if (chooseLabel(s1, s2) === s1) s2.hasRightLabel = false + else s1.hasRightLabel = false } }) }) @@ -946,12 +995,16 @@ class LabelledSlopes slopeData.forEach((s2) => { if ( s1 !== s2 && - s1.hasRightLabel && - s2.hasRightLabel && - s1.rightLabelBounds.intersects(s2.rightLabelBounds) + s1.hasLeftLabel && + s2.hasLeftLabel && + // entity labels don't necessarily share the same x position. + // that's why we check for vertical intersection only + s1.leftEntityLabelBounds.hasVerticalOverlap( + s2.leftEntityLabelBounds + ) ) { - if (chooseLabel(s1, s2) === s1) s2.hasRightLabel = false - else s1.hasRightLabel = false + if (chooseLabel(s1, s2) === s1) s2.hasLeftLabel = false + else s1.hasLeftLabel = false } }) }) @@ -993,7 +1046,16 @@ class LabelledSlopes ? "right" : "chart" - const distToSlopeOrLabel = new Map() + // don't track mouse movements when hovering over labels on the left + if (mousePosition === "left") { + this.props.onMouseLeave() + return + } + + const distToSlopeOrLabel = new Map< + SlopeEntryProps, + number + >() for (const s of this.slopeData) { // start and end point of a line let p1: PointVector @@ -1003,15 +1065,8 @@ class LabelledSlopes // points define the slope line p1 = new PointVector(s.x1, s.y1) p2 = new PointVector(s.x2, s.y2) - } else if (mousePosition === "left") { - const labelBox = s.leftLabelBounds.toProps() - // points define a "strike-through" line that stretches from - // the left side of the left label to the start point of the slopes - const y = labelBox.y + labelBox.height / 2 - p1 = new PointVector(labelBox.x, y) - p2 = new PointVector(startX, y) } else { - const labelBox = s.rightLabelBounds.toProps() + const labelBox = s.rightEntityLabelBounds.toProps() // points define a "strike-through" line that stretches from // the end point of the slopes to the right side of the right label const y = labelBox.y + labelBox.height / 2 @@ -1070,29 +1125,21 @@ class LabelledSlopes .attr("stroke-dashoffset", "0%") } - renderGroups(groups: SlopeProps[]) { - const { isLayerMode } = this + renderGroups(groups: SlopeEntryProps[]) { + const { isLayerMode, isMultiHoverMode } = this return groups.map((slope) => ( - )) } render() { - const { - fontSize, - bounds, - slopeData, - isPortrait, - xDomain, - yAxis, - yRange, - onMouseMove, - } = this + const { bounds, slopeData, xDomain, yAxis, yRange, onMouseMove } = this if (isEmpty(slopeData)) return @@ -1145,34 +1192,32 @@ class LabelledSlopes ) })} - {!isPortrait && ( - - )} - - - + + + {xDomain[0].toString()} - - + {xDomain[1].toString()} - + {this.renderGroups(this.backgroundGroups)} {this.renderGroups(this.foregroundGroups)} diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts index f4e85c4bd7f..e5b0932f88e 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts @@ -17,26 +17,27 @@ export interface SlopeChartSeries extends ChartSeries { export const DEFAULT_SLOPE_CHART_COLOR = "#ff7f0e" -export interface SlopeProps extends ChartSeries { +export interface SlopeEntryProps extends ChartSeries { isLayerMode: boolean + isMultiHoverMode: boolean x1: number y1: number x2: number y2: number size: number + hasLeftLabel: boolean + leftEntityLabel: TextWrap + leftValueLabel: TextWrap + leftEntityLabelBounds: Bounds + hasRightLabel: boolean - labelFontSize: number - leftLabelBounds: Bounds - rightLabelBounds: Bounds - leftValueStr: string - rightValueStr: string - leftLabel: TextWrap - rightLabel: TextWrap + rightEntityLabel: TextWrap + rightEntityLabelBounds: Bounds + rightValueLabel: TextWrap + isFocused: boolean isHovered: boolean - leftValueWidth: number - rightValueWidth: number } export interface LabelledSlopesProps { @@ -46,7 +47,7 @@ export interface LabelledSlopesProps { seriesArr: SlopeChartSeries[] focusKeys: string[] hoverKeys: string[] - onMouseOver: (slopeProps: SlopeProps) => void + onMouseOver: (slopeProps: SlopeEntryProps) => void onMouseLeave: () => void onClick: () => void } diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx index 567de45f54c..975dff08242 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx @@ -9,6 +9,8 @@ import { sum, getRelativeMouse, colorScaleConfigDefaults, + omit, + FontFamily, } from "@ourworldindata/utils" import { VerticalAxisComponent, @@ -16,7 +18,6 @@ import { VerticalAxisGridLines, } from "../axis/AxisViews" import { NoDataModal } from "../noDataModal/NoDataModal" -import { Text } from "../text/Text" import { VerticalColorLegend, VerticalColorLegendManager, @@ -578,3 +579,27 @@ export class StackedBarChart ) } } + +interface TextProps extends React.SVGProps { + x: number + y: number + fontSize: number + fontFamily?: FontFamily + children: string +} + +class Text extends React.Component { + render(): JSX.Element { + const bounds = Bounds.forText(this.props.children, { + fontSize: this.props.fontSize, + fontFamily: this.props.fontFamily, + }) + const y = this.props.y + bounds.height - bounds.height * 0.2 + + return ( + + {this.props.children} + + ) + } +} diff --git a/packages/@ourworldindata/grapher/src/text/.eslintrc.yaml b/packages/@ourworldindata/grapher/src/text/.eslintrc.yaml deleted file mode 100644 index 2972fb8a9d9..00000000000 --- a/packages/@ourworldindata/grapher/src/text/.eslintrc.yaml +++ /dev/null @@ -1,3 +0,0 @@ -rules: - "@typescript-eslint/explicit-function-return-type": "warn" - "@typescript-eslint/explicit-module-boundary-types": "warn" diff --git a/packages/@ourworldindata/grapher/src/text/Text.tsx b/packages/@ourworldindata/grapher/src/text/Text.tsx deleted file mode 100644 index 4adbe4688bd..00000000000 --- a/packages/@ourworldindata/grapher/src/text/Text.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react" -import { Bounds, FontFamily, omit } from "@ourworldindata/utils" - -// The default SVG text behavior is to put the text on *top* of the specified y coordinate -// Nothing else we do works like that though, so this wraps it to use the same spatial behavior -// as other components - -interface TextProps extends React.SVGProps { - x: number - y: number - fontSize: number - fontFamily?: FontFamily - children: string -} - -export class Text extends React.Component { - render(): JSX.Element { - const bounds = Bounds.forText(this.props.children, { - fontSize: this.props.fontSize, - fontFamily: this.props.fontFamily, - }) - const y = this.props.y + bounds.height - bounds.height * 0.2 - - return ( - - {this.props.children} - - ) - } -} diff --git a/packages/@ourworldindata/utils/src/Bounds.test.ts b/packages/@ourworldindata/utils/src/Bounds.test.ts index 3cf51db6ab4..9f26b1fd604 100755 --- a/packages/@ourworldindata/utils/src/Bounds.test.ts +++ b/packages/@ourworldindata/utils/src/Bounds.test.ts @@ -66,3 +66,27 @@ it("can pad & expand by position", () => { const expandedBounds = paddedBounds.expand(pad) expect(expandedBounds.equals(bounds)).toBeTruthy() }) + +it("can detect overlapping bounds", () => { + const bounds = new Bounds(0, 0, 100, 100) + const otherBounds = new Bounds(50, 50, 100, 100) + expect(bounds.intersects(otherBounds)).toBeTruthy() + expect(bounds.hasVerticalOverlap(otherBounds)).toBeTruthy() + expect(bounds.hasHorizontalOverlap(otherBounds)).toBeTruthy() +}) + +it("can detect vertical overlap", () => { + const bounds = new Bounds(0, 0, 100, 100) + const otherBounds = new Bounds(200, 50, 100, 100) + expect(bounds.intersects(otherBounds)).toBeFalsy() + expect(bounds.hasVerticalOverlap(otherBounds)).toBeTruthy() + expect(bounds.hasHorizontalOverlap(otherBounds)).toBeFalsy() +}) + +it("can detect horizontal overlap", () => { + const bounds = new Bounds(0, 0, 100, 100) + const otherBounds = new Bounds(50, 200, 100, 100) + expect(bounds.intersects(otherBounds)).toBeFalsy() + expect(bounds.hasVerticalOverlap(otherBounds)).toBeFalsy() + expect(bounds.hasHorizontalOverlap(otherBounds)).toBeTruthy() +}) diff --git a/packages/@ourworldindata/utils/src/Bounds.ts b/packages/@ourworldindata/utils/src/Bounds.ts index 05e4158089b..1ff82ee5a2b 100644 --- a/packages/@ourworldindata/utils/src/Bounds.ts +++ b/packages/@ourworldindata/utils/src/Bounds.ts @@ -275,7 +275,6 @@ export class Bounds { intersects(otherBounds: Bounds): boolean { const r2 = otherBounds - return !( r2.left > this.right || r2.right < this.left || @@ -284,6 +283,16 @@ export class Bounds { ) } + hasVerticalOverlap(otherBounds: Bounds): boolean { + const r2 = otherBounds + return !(r2.top > this.bottom || r2.bottom < this.top) + } + + hasHorizontalOverlap(otherBounds: Bounds): boolean { + const r2 = otherBounds + return !(r2.left > this.right || r2.right < this.left) + } + lines(): PointVector[][] { return [ [this.topLeft, this.topRight],