From 73f511e3b767e8dc026306beebe95094e41fd8cb Mon Sep 17 00:00:00 2001 From: jakidxav Date: Fri, 6 Mar 2026 10:38:54 -0800 Subject: [PATCH 1/5] adding rectangular region picker files; changing demo landing page component by adding commented out option for rectangular region picker, slightly altering the map center to better show that the two region picker shapes produce different aggregated stats --- demo/pages/index.js | 5 +- src/region/region-picker/index.js | 139 ++++--- .../rectangle-picker/cursor-manager.js | 24 ++ .../region-picker/rectangle-picker/index.js | 103 +++++ .../rectangle-picker/rectangle-renderer.js | 375 ++++++++++++++++++ .../region-picker/rectangle-picker/utils.js | 36 ++ 6 files changed, 624 insertions(+), 58 deletions(-) create mode 100644 src/region/region-picker/rectangle-picker/cursor-manager.js create mode 100644 src/region/region-picker/rectangle-picker/index.js create mode 100644 src/region/region-picker/rectangle-picker/rectangle-renderer.js create mode 100644 src/region/region-picker/rectangle-picker/utils.js diff --git a/demo/pages/index.js b/demo/pages/index.js index 380b09a..bb665a5 100644 --- a/demo/pages/index.js +++ b/demo/pages/index.js @@ -42,7 +42,7 @@ const Index = () => { title={'@carbonplan/maps'} /> - + { backgroundColor={theme.colors.background} fontFamily={theme.fonts.mono} fontSize={'14px'} + minRadius={300} maxRadius={2000} + initialRadius={1000} + // mode={'rectangle'} // options: 'circle', 'rectangle', defaults to 'circle' /> )} = -90 && latitude <= 90 - ) + ); } function getInitialCenter(map, center) { @@ -34,78 +35,102 @@ function getInitialCenter(map, center) { center.length === 2 && isValidCoordinate(center[0], center[1]) ) { - return { lng: center[0], lat: center[1] } + return { lng: center[0], lat: center[1] }; } else { if (center) { console.warn( `Invalid initialCenter provided: ${center}. Should be [lng, lat]. Using map center instead.` - ) + ); } - const mapCenter = map.getCenter() - return { lng: mapCenter.lng, lat: mapCenter.lat } + const mapCenter = map.getCenter(); + return { lng: mapCenter.lng, lat: mapCenter.lat }; } } -// TODO: -// - accept mode (only accept mode="circle" to start) +// only accept mode="circle" or mode="rectangle" to start function RegionPicker({ backgroundColor, color, fontFamily, fontSize, - units = 'kilometers', + units = "kilometers", initialRadius: initialRadiusProp, initialCenter: initialCenterProp, minRadius, maxRadius, + mode='circle' }) { - const { map } = useMap() - const id = useRef(uuidv4()) + const { map } = useMap(); + const id = useRef(uuidv4()); - const initialCenter = useRef(getInitialCenter(map, initialCenterProp)) + const initialCenter = useRef(getInitialCenter(map, initialCenterProp)); const initialRadius = useRef( initialRadiusProp || getInitialRadius(map, units, minRadius, maxRadius) - ) - const { setRegion } = useRegionContext() + ); + const { setRegion } = useRegionContext(); - const [center, setCenter] = useState(initialCenter.current) + const [center, setCenter] = useState(initialCenter.current); useEffect(() => { return () => { // Clear region when unmounted - setRegion(null) - } - }, []) + setRegion(null); + }; + }, []); - const handleCircle = useCallback((circle) => { - if (!circle) return - setRegion(circle) - setCenter(circle.properties.center) - }, []) + const handleShape = useCallback((shape) => { + if (!shape) return; + setRegion(shape); + setCenter(shape.properties.center); + }, []); // TODO: consider extending support for degrees and radians - if (!['kilometers', 'miles'].includes(units)) { - throw new Error('Units must be one of miles, kilometers') + if (!["kilometers", "miles"].includes(units)) { + throw new Error("Units must be one of miles, kilometers"); } - return ( - - ) + if (mode == "circle") { + return ( + + ); + } else if (mode == "rectangle") { + return ( + + ); + } else { + throw new ValueError( + "RegionPicker `mode` must be one of ['circle', 'rectangle']" + ); + } } -export default RegionPicker +export default RegionPicker; diff --git a/src/region/region-picker/rectangle-picker/cursor-manager.js b/src/region/region-picker/rectangle-picker/cursor-manager.js new file mode 100644 index 0000000..4500c21 --- /dev/null +++ b/src/region/region-picker/rectangle-picker/cursor-manager.js @@ -0,0 +1,24 @@ +export default function CursorManager(map) { + const canvas = map.getCanvas() + const originalStyle = canvas.style.cursor + + let mouseState = { + onHandle: false, + draggingHandle: false, + onRectangle: false, + draggingRectangle: false, + } + + return function setCursor(newState) { + mouseState = { + ...mouseState, + ...newState, + } + + if (mouseState.onHandle || mouseState.draggingHandle) + canvas.style.cursor = 'ew-resize' + else if (mouseState.onRectangle || mouseState.draggingRectangle) + canvas.style.cursor = 'move' + else canvas.style.cursor = originalStyle + } + } \ No newline at end of file diff --git a/src/region/region-picker/rectangle-picker/index.js b/src/region/region-picker/rectangle-picker/index.js new file mode 100644 index 0000000..2056263 --- /dev/null +++ b/src/region/region-picker/rectangle-picker/index.js @@ -0,0 +1,103 @@ +import { useState, useEffect } from 'react' +import { useMap } from '../../../map-provider' +import RectangleRenderer from './rectangle-renderer' + +import { HANDLE_RADIUS } from './rectangle-renderer'; + +const RectanglePicker = ({ + id, + backgroundColor, + center, + color, + fontFamily, + fontSize, + radius, + onIdle, + onDrag, + units, + maxRadius, + minRadius, +}) => { + const { map } = useMap() + const [renderer, setRenderer] = useState(null) + + useEffect(() => { + const renderer = RectangleRenderer({ + id, + map, + onIdle, + onDrag, + initialCenter: center, + initialRadius: radius, + units, + maxRadius, + minRadius, + }) + + setRenderer(renderer) + + return function cleanup() { + // need to check load state for fast-refresh purposes + if (map.loaded()) renderer.remove() + } + }, []) + + return ( + + + + + + + + + + + + + + + + + + + + ) +} + +export default RectanglePicker \ No newline at end of file diff --git a/src/region/region-picker/rectangle-picker/rectangle-renderer.js b/src/region/region-picker/rectangle-picker/rectangle-renderer.js new file mode 100644 index 0000000..4f0e3a6 --- /dev/null +++ b/src/region/region-picker/rectangle-picker/rectangle-renderer.js @@ -0,0 +1,375 @@ +import { select } from 'd3-selection' +import { getPathMaker, project } from './utils' +import { + area, + bbox, + bboxPolygon, + convertArea, + distance, + rewind, + rhumbDestination, + lineString, + lineIntersect, + circle as turfCircle, + point, +} from '@turf/turf' +import CursorManager from './cursor-manager' + + +export const HANDLE_RADIUS = 8; +export const SHOW_RADIUS_GUIDELINE = true + +const POLES = [point([0, -90]), point([0, 90])] +const abbreviations = { + kilometers: 'km', + miles: 'mi', +} + +export default function RectangleRenderer({ + id, + map, + onIdle = (rectangle) => {}, + onDrag = (rectangle) => {}, + initialCenter = { lat: 0, lng: 0 }, + initialRadius = 0, + maxRadius, + minRadius, + units, +}) { + let circle = null + let rectangle = null + let center = initialCenter + let centerXY = project(map, center) + let radius = initialRadius + + const svg = select(`#rectangle-picker-${id}`).style('pointer-events', 'none') + const svgHandle = select(`#handle-${id}`).style('pointer-events', 'all') + const svgGuideline = select(`#radius-guideline-${id}`) + const svgRadiusTextContainer = select(`#radius-text-container-${id}`) + const svgRadiusText = select(`#radius-text-${id}`).attr('fill-opacity', 0) + const svgRectangle = select(`#rectangle-${id}`).style('pointer-events', 'all') + const svgRectCutout = select(`#rectangle-cutout-${id}`) + + let guidelineAngle = 135 + if (!SHOW_RADIUS_GUIDELINE) { + svgGuideline.style('display', 'none') + svgRadiusTextContainer.style('display', 'none') + } + + const removers = [] + + //// LISTENERS //// + + function addDragHandleListeners() { + const onMouseMove = (e) => { + let r = distance( + map.unproject(e.point).toArray(), + [center.lng, center.lat], + { units } + ) + r = maxRadius ? Math.min(r, maxRadius) : r + r = minRadius ? Math.max(r, minRadius) : r + setRadius(r) + onDrag(rectangle) + } + + const onMouseUp = () => { + onIdle(rectangle) + setCursor({ draggingHandle: false }) + map.off('mousemove', onMouseMove) + map.off('touchmove', onMouseMove) + svgHandle.style('pointer-events', 'all') + svgRectangle.style('pointer-events', 'all') + svgRadiusText.attr('fill-opacity', 0) + svgGuideline.attr('stroke-opacity', 0) + } + + const handleStart = (e) => { + if (e.type === 'touchstart') { + map.dragPan.disable() + map.on('touchmove', onMouseMove) + map.once('touchend', onMouseUp) + } else { + map.on('mousemove', onMouseMove) + map.once('mouseup', onMouseUp) + } + setCursor({ draggingHandle: true }) + svgHandle.style('pointer-events', 'none') + svgRectangle.style('pointer-events', 'none') + svgRadiusText.attr('fill-opacity', 1) + svgGuideline.attr('stroke-opacity', 1) + } + + svgHandle.on('mousedown', handleStart) + svgHandle.on('touchstart', handleStart) + + removers.push(function removeDragHandleListeners() { + svgHandle.on('mousedown', null) + svgHandle.on('touchstart', null) + }) + } + + function addRectangleListeners() { + let offset + const mapCanvas = map.getCanvas() + + const onMouseMove = (e) => { + setCenter( + { + lng: e.lngLat.lng - offset.lng, + lat: e.lngLat.lat - offset.lat, + }, + { + x: e.point.x, + y: e.point.y, + } + ) + onDrag(rectangle) + } + + const onMouseUp = () => { + onIdle(rectangle) + setCursor({ draggingRectangle: false }) + map.off('mousemove', onMouseMove) + map.off('touchmove', onMouseMove) + map.dragPan.enable() + svgRectangle.style('pointer-events', 'all') + svgHandle.style('pointer-events', 'all') + svgRectangle.attr('stroke-width', 1) + } + + const handleRectangleStart = (e) => { + let point + if (e.type === 'touchstart') { + const touch = e.touches[0] + point = { x: touch.pageX, y: touch.pageY } + svgRectangle.attr('stroke-width', 4) + map.dragPan.disable() + map.on('touchmove', onMouseMove) + map.once('touchend', onMouseUp) + } else { + point = { x: e.offsetX, y: e.offsetY } + map.on('mousemove', onMouseMove) + map.once('mouseup', onMouseUp) + } + const lngLat = map.unproject(point) + offset = { + lng: lngLat.lng - center.lng, + lat: lngLat.lat - center.lat, + } + setCursor({ draggingRectangle: true }) + svgRectangle.style('pointer-events', 'none') + svgHandle.style('pointer-events', 'none') + } + + svgRectangle.on('mousedown', handleRectangleStart) + svgRectangle.on('touchstart', handleRectangleStart) + + svgRectangle.on('wheel', (e) => { + e.preventDefault() + let newEvent = new e.constructor(e.type, e) + mapCanvas.dispatchEvent(newEvent) + }) + + removers.push(function removeRectangleListeners() { + svgRectangle.on('mousedown', null) + svgRectangle.on('touchstart', null) + svgRectangle.on('wheel', null) + }) + } + + function addMapMoveListeners() { + const onMove = setRectangle + + map.on('move', onMove) + removers.push(function removeMapMoveListeners() { + map.off('move', onMove) + }) + } + + //// RECTANGLE //// + + function geoCircle(center, radius, inverted = false) { + const c = turfCircle([center.lng, center.lat], radius, { + units, + steps: 64, + properties: { + center, + radius, + units, + }, + }) + + c.properties.area = convertArea(area(c), 'meters', units) + c.properties.zoom = map.getZoom() + + if (inverted) { + return c + } + + // need to rewind or svg fill is inside-out + return rewind(c, { reverse: true, mutate: true }) + } + + function geoRect(c, inverted = false) { + + let _bbox = bbox(c) + let r = bboxPolygon(_bbox) + + const corners = [ + [_bbox[0], _bbox[3]], // upper‑left (west, north) + [_bbox[2], _bbox[3]], // upper‑right (east, north) + [_bbox[2], _bbox[1]], // lower‑right (east, south) + [_bbox[0], _bbox[1]], // lower‑left (west, south) + ]; + + // console.log('Corner coordinate pairs:', corners); + r.properties.center = c?.properties?.center + r.properties.corners = corners + r.properties.zoom = map.getZoom() + r.properties.radius = c?.properties?.radius * Math.sqrt(2) + r.properties.radiusUnits = c?.properties?.units + r.properties.area = convertArea(area(r), 'meters', units) + + if (inverted) { + return r + } + + // need to rewind or svg fill is inside-out + return rewind(r, { reverse: true, mutate: true }) + } + + //// SETTERS //// + + const setCursor = CursorManager(map) + + function setCenter(_center, _point) { + if (_center && _center !== center) { + if (nearPoles(_center, radius)) { + center = { lng: _center.lng, lat: center.lat } + centerXY = { x: _point.x, y: centerXY.y } + } else { + center = _center + centerXY = _point + } + + setRectangle() + } + } + + function resetCenterXY() { + // reset centerXY value based on latest `map` value + centerXY = project(map, center, { referencePoint: centerXY }) + } + + function setRadius(_radius) { + if (_radius && _radius !== radius) { + if (!nearPoles(center, _radius)) { + radius = _radius + setRectangle() + } + } + } + + function nearPoles(center, radius) { + const turfPoint = point([center.lng, center.lat]) + + return POLES.some((pole) => distance(turfPoint, pole, { units }) < radius) + } + + function setRectangle() { + // ensure that centerXY is up-to-date with map + resetCenterXY() + + const makePath = getPathMaker(map, { + referencePoint: centerXY, + }) + + // update svg circle, then rectangle + circle = geoCircle(center, radius / Math.sqrt(2)) + rectangle = geoRect(circle) + let path = makePath(rectangle) + + svgRectangle.attr('d', path) + + const cutoutRect = geoRect(circle, true) + const cutoutRectPath = makePath(cutoutRect) + const { width, height } = svg.node().getBBox() + svgRectCutout.attr('d', cutoutRectPath + ` M0,0H${width}V${height}H0V0z`) + + // update other svg elements + const handleXY = (() => { + // by default just render handle based on radius and guideline angle + let coordinates = rhumbDestination( + [center.lng, center.lat], + radius * Math.sqrt(2), + guidelineAngle + ).geometry.coordinates + // let coordinates = point(rectangle.properties.corners[2]) + + // lower-right corner of rectangle, where handle is + const lineEnd = point(rectangle.properties.corners[2]) + + const line = lineString([ + [center.lng, center.lat], + lineEnd.geometry.coordinates, + ]) + + const inter = lineIntersect(line, rectangle) + // prefer rendering using intersection with rectangle to handle distortions near poles + if (inter.features.length > 0) { + coordinates = inter.features[0].geometry.coordinates + } + + return project(map, coordinates, { + referencePoint: centerXY, + }) + })() + + svgHandle.attr('cx', handleXY.x).attr('cy', handleXY.y) + + svgGuideline + .attr('x1', centerXY.x) + .attr('y1', centerXY.y) + .attr('x2', handleXY.x) + .attr('y2', handleXY.y) + + const translateY = 4 + + svgRadiusText + .text(radius.toFixed(0) + abbreviations[units]) + .attr( + 'transform', + `rotate(${-1 * guidelineAngle + 90}) ` + `translate(0, ${translateY})` + ) + + const translateX = (() => { + const { width: textWidth } = svgRadiusText.node().getBBox() + const coeff = 0.8 * Math.sin((guidelineAngle * Math.PI) / 180) + return 18 + Math.abs((coeff * textWidth) / 2) + })() + + svgRadiusTextContainer.attr( + 'transform', + `rotate(${guidelineAngle - 90}, ${handleXY.x}, ${handleXY.y}) ` + + `translate(${handleXY.x + translateX}, ${handleXY.y})` + ) + } + + //// INIT //// + + addDragHandleListeners() + addRectangleListeners() + addMapMoveListeners() + setRectangle() + onIdle(rectangle) + + //// INTERFACE //// + + return { + remove: () => { + removers.reverse().forEach((remove) => remove()) + onIdle(null) + }, + } +} \ No newline at end of file diff --git a/src/region/region-picker/rectangle-picker/utils.js b/src/region/region-picker/rectangle-picker/utils.js new file mode 100644 index 0000000..e59d5d2 --- /dev/null +++ b/src/region/region-picker/rectangle-picker/utils.js @@ -0,0 +1,36 @@ +import { geoPath, geoTransform } from 'd3-geo' +import mapboxgl from 'mapbox-gl' + +export const project = (map, coordinates, options = {}) => { + // Convert any LngLatLike to LngLat + const ll = mapboxgl.LngLat.convert(coordinates) + + let result = map.project(ll) + + // When present, use referencePoint to find closest renderable point + const { referencePoint } = options + if (referencePoint) { + const deltas = [-360, 360] + deltas.forEach((delta) => { + const alternate = map.project({ lat: ll.lat, lng: ll.lng + delta }) + if ( + Math.abs(alternate.x - referencePoint.x) < + Math.abs(result.x - referencePoint.x) + ) { + result = alternate + } + }) + } + + return result +} + +export function getPathMaker(map, options) { + const transform = geoTransform({ + point: function (lng, lat) { + const point = project(map, [lng, lat], options) + this.stream.point(point.x, point.y) + }, + }) + return geoPath().projection(transform) +} \ No newline at end of file From 8713cab16be3a8b7f9174b2c37dd2b4386ea5fba Mon Sep 17 00:00:00 2001 From: jakidxav Date: Fri, 6 Mar 2026 10:48:56 -0800 Subject: [PATCH 2/5] new region picker components pass pre-commit prettier checks --- src/region/region-picker/index.js | 88 +++++++++---------- .../rectangle-picker/cursor-manager.js | 44 +++++----- .../region-picker/rectangle-picker/index.js | 13 ++- .../rectangle-picker/rectangle-renderer.js | 10 +-- .../region-picker/rectangle-picker/utils.js | 2 +- 5 files changed, 80 insertions(+), 77 deletions(-) diff --git a/src/region/region-picker/index.js b/src/region/region-picker/index.js index 09129a2..b786617 100644 --- a/src/region/region-picker/index.js +++ b/src/region/region-picker/index.js @@ -1,32 +1,32 @@ -import React, { useState, useRef, useCallback, useEffect } from "react"; -import CirclePicker from "./circle-picker"; -import RectanglePicker from "./rectangle-picker" -import { UPDATE_STATS_ON_DRAG } from "./constants"; -import { distance } from "@turf/turf"; -import { v4 as uuidv4 } from "uuid"; +import React, { useState, useRef, useCallback, useEffect } from 'react' +import CirclePicker from './circle-picker' +import RectanglePicker from './rectangle-picker' +import { UPDATE_STATS_ON_DRAG } from './constants' +import { distance } from '@turf/turf' +import { v4 as uuidv4 } from 'uuid' -import { useRegionContext } from "../context"; -import { useMap } from "../../map-provider"; +import { useRegionContext } from '../context' +import { useMap } from '../../map-provider' function getInitialRadius(map, units, minRadius, maxRadius) { - const bounds = map.getBounds().toArray(); - const dist = distance(bounds[0], bounds[1], { units }); - let radius = Math.round(dist / 15); - radius = minRadius ? Math.max(minRadius, radius) : radius; - radius = maxRadius ? Math.min(maxRadius, radius) : radius; + const bounds = map.getBounds().toArray() + const dist = distance(bounds[0], bounds[1], { units }) + let radius = Math.round(dist / 15) + radius = minRadius ? Math.max(minRadius, radius) : radius + radius = maxRadius ? Math.min(maxRadius, radius) : radius - return radius; + return radius } function isValidCoordinate(longitude, latitude) { return ( - typeof longitude === "number" && - typeof latitude === "number" && + typeof longitude === 'number' && + typeof latitude === 'number' && !isNaN(longitude) && !isNaN(latitude) && latitude >= -90 && latitude <= 90 - ); + ) } function getInitialCenter(map, center) { @@ -35,15 +35,15 @@ function getInitialCenter(map, center) { center.length === 2 && isValidCoordinate(center[0], center[1]) ) { - return { lng: center[0], lat: center[1] }; + return { lng: center[0], lat: center[1] } } else { if (center) { console.warn( `Invalid initialCenter provided: ${center}. Should be [lng, lat]. Using map center instead.` - ); + ) } - const mapCenter = map.getCenter(); - return { lng: mapCenter.lng, lat: mapCenter.lat }; + const mapCenter = map.getCenter() + return { lng: mapCenter.lng, lat: mapCenter.lat } } } @@ -53,44 +53,44 @@ function RegionPicker({ color, fontFamily, fontSize, - units = "kilometers", + units = 'kilometers', initialRadius: initialRadiusProp, initialCenter: initialCenterProp, minRadius, maxRadius, - mode='circle' + mode = 'circle', }) { - const { map } = useMap(); - const id = useRef(uuidv4()); + const { map } = useMap() + const id = useRef(uuidv4()) - const initialCenter = useRef(getInitialCenter(map, initialCenterProp)); + const initialCenter = useRef(getInitialCenter(map, initialCenterProp)) const initialRadius = useRef( initialRadiusProp || getInitialRadius(map, units, minRadius, maxRadius) - ); - const { setRegion } = useRegionContext(); + ) + const { setRegion } = useRegionContext() - const [center, setCenter] = useState(initialCenter.current); + const [center, setCenter] = useState(initialCenter.current) useEffect(() => { return () => { // Clear region when unmounted - setRegion(null); - }; - }, []); + setRegion(null) + } + }, []) const handleShape = useCallback((shape) => { - if (!shape) return; - setRegion(shape); - setCenter(shape.properties.center); - }, []); + if (!shape) return + setRegion(shape) + setCenter(shape.properties.center) + }, []) // TODO: consider extending support for degrees and radians - if (!["kilometers", "miles"].includes(units)) { - throw new Error("Units must be one of miles, kilometers"); + if (!['kilometers', 'miles'].includes(units)) { + throw new Error('Units must be one of miles, kilometers') } - if (mode == "circle") { + if (mode == 'circle') { return ( - ); - } else if (mode == "rectangle") { + ) + } else if (mode == 'rectangle') { return ( - ); + ) } else { throw new ValueError( "RegionPicker `mode` must be one of ['circle', 'rectangle']" - ); + ) } } -export default RegionPicker; +export default RegionPicker diff --git a/src/region/region-picker/rectangle-picker/cursor-manager.js b/src/region/region-picker/rectangle-picker/cursor-manager.js index 4500c21..76c02c7 100644 --- a/src/region/region-picker/rectangle-picker/cursor-manager.js +++ b/src/region/region-picker/rectangle-picker/cursor-manager.js @@ -1,24 +1,24 @@ export default function CursorManager(map) { - const canvas = map.getCanvas() - const originalStyle = canvas.style.cursor - - let mouseState = { - onHandle: false, - draggingHandle: false, - onRectangle: false, - draggingRectangle: false, + const canvas = map.getCanvas() + const originalStyle = canvas.style.cursor + + let mouseState = { + onHandle: false, + draggingHandle: false, + onRectangle: false, + draggingRectangle: false, + } + + return function setCursor(newState) { + mouseState = { + ...mouseState, + ...newState, } - - return function setCursor(newState) { - mouseState = { - ...mouseState, - ...newState, - } - - if (mouseState.onHandle || mouseState.draggingHandle) - canvas.style.cursor = 'ew-resize' - else if (mouseState.onRectangle || mouseState.draggingRectangle) - canvas.style.cursor = 'move' - else canvas.style.cursor = originalStyle - } - } \ No newline at end of file + + if (mouseState.onHandle || mouseState.draggingHandle) + canvas.style.cursor = 'ew-resize' + else if (mouseState.onRectangle || mouseState.draggingRectangle) + canvas.style.cursor = 'move' + else canvas.style.cursor = originalStyle + } +} diff --git a/src/region/region-picker/rectangle-picker/index.js b/src/region/region-picker/rectangle-picker/index.js index 2056263..e94f338 100644 --- a/src/region/region-picker/rectangle-picker/index.js +++ b/src/region/region-picker/rectangle-picker/index.js @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { useMap } from '../../../map-provider' import RectangleRenderer from './rectangle-renderer' -import { HANDLE_RADIUS } from './rectangle-renderer'; +import { HANDLE_RADIUS } from './rectangle-renderer' const RectanglePicker = ({ id, @@ -77,8 +77,13 @@ const RectanglePicker = ({ fillOpacity={0.5} /> - - + + Date: Fri, 6 Mar 2026 11:17:45 -0800 Subject: [PATCH 3/5] circle and rectangle picker components can share utils and cursor manager component --- .../circle-picker/circle-renderer.js | 8 ++--- .../{circle-picker => }/cursor-manager.js | 6 ++-- .../rectangle-picker/cursor-manager.js | 24 ------------- .../rectangle-picker/rectangle-renderer.js | 8 ++--- .../region-picker/rectangle-picker/utils.js | 36 ------------------- .../{circle-picker => }/utils.js | 0 6 files changed, 11 insertions(+), 71 deletions(-) rename src/region/region-picker/{circle-picker => }/cursor-manager.js (81%) delete mode 100644 src/region/region-picker/rectangle-picker/cursor-manager.js delete mode 100644 src/region/region-picker/rectangle-picker/utils.js rename src/region/region-picker/{circle-picker => }/utils.js (100%) diff --git a/src/region/region-picker/circle-picker/circle-renderer.js b/src/region/region-picker/circle-picker/circle-renderer.js index 4bfafe8..56ad1f5 100644 --- a/src/region/region-picker/circle-picker/circle-renderer.js +++ b/src/region/region-picker/circle-picker/circle-renderer.js @@ -1,6 +1,6 @@ import { select } from 'd3-selection' import { FLOATING_HANDLE, SHOW_RADIUS_GUIDELINE } from '../constants' -import { getPathMaker, project } from './utils' +import { getPathMaker, project } from '../utils' import { area, convertArea, @@ -12,7 +12,7 @@ import { circle as turfCircle, point, } from '@turf/turf' -import CursorManager from './cursor-manager' +import CursorManager from '../cursor-manager' const POLES = [point([0, -90]), point([0, 90])] const abbreviations = { @@ -132,7 +132,7 @@ export default function CircleRenderer({ const onMouseUp = () => { onIdle(circle) - setCursor({ draggingCircle: false }) + setCursor({ draggingRegion: false }) map.off('mousemove', onMouseMove) map.off('touchmove', onMouseMove) map.dragPan.enable() @@ -160,7 +160,7 @@ export default function CircleRenderer({ lng: lngLat.lng - center.lng, lat: lngLat.lat - center.lat, } - setCursor({ draggingCircle: true }) + setCursor({ draggingRegion: true }) svgCircle.style('pointer-events', 'none') svgHandle.style('pointer-events', 'none') } diff --git a/src/region/region-picker/circle-picker/cursor-manager.js b/src/region/region-picker/cursor-manager.js similarity index 81% rename from src/region/region-picker/circle-picker/cursor-manager.js rename to src/region/region-picker/cursor-manager.js index c1e587f..fa05a2a 100644 --- a/src/region/region-picker/circle-picker/cursor-manager.js +++ b/src/region/region-picker/cursor-manager.js @@ -5,8 +5,8 @@ export default function CursorManager(map) { let mouseState = { onHandle: false, draggingHandle: false, - onCircle: false, - draggingCircle: false, + onRegion: false, + draggingRegion: false, } return function setCursor(newState) { @@ -17,7 +17,7 @@ export default function CursorManager(map) { if (mouseState.onHandle || mouseState.draggingHandle) canvas.style.cursor = 'ew-resize' - else if (mouseState.onCircle || mouseState.draggingCircle) + else if (mouseState.onRegion || mouseState.draggingRegion) canvas.style.cursor = 'move' else canvas.style.cursor = originalStyle } diff --git a/src/region/region-picker/rectangle-picker/cursor-manager.js b/src/region/region-picker/rectangle-picker/cursor-manager.js deleted file mode 100644 index 76c02c7..0000000 --- a/src/region/region-picker/rectangle-picker/cursor-manager.js +++ /dev/null @@ -1,24 +0,0 @@ -export default function CursorManager(map) { - const canvas = map.getCanvas() - const originalStyle = canvas.style.cursor - - let mouseState = { - onHandle: false, - draggingHandle: false, - onRectangle: false, - draggingRectangle: false, - } - - return function setCursor(newState) { - mouseState = { - ...mouseState, - ...newState, - } - - if (mouseState.onHandle || mouseState.draggingHandle) - canvas.style.cursor = 'ew-resize' - else if (mouseState.onRectangle || mouseState.draggingRectangle) - canvas.style.cursor = 'move' - else canvas.style.cursor = originalStyle - } -} diff --git a/src/region/region-picker/rectangle-picker/rectangle-renderer.js b/src/region/region-picker/rectangle-picker/rectangle-renderer.js index 43fb8af..0f73191 100644 --- a/src/region/region-picker/rectangle-picker/rectangle-renderer.js +++ b/src/region/region-picker/rectangle-picker/rectangle-renderer.js @@ -1,5 +1,5 @@ import { select } from 'd3-selection' -import { getPathMaker, project } from './utils' +import { getPathMaker, project } from '../utils' import { area, bbox, @@ -13,7 +13,7 @@ import { circle as turfCircle, point, } from '@turf/turf' -import CursorManager from './cursor-manager' +import CursorManager from '../cursor-manager' export const HANDLE_RADIUS = 8 export const SHOW_RADIUS_GUIDELINE = true @@ -128,7 +128,7 @@ export default function RectangleRenderer({ const onMouseUp = () => { onIdle(rectangle) - setCursor({ draggingRectangle: false }) + setCursor({ draggingRegion: false }) map.off('mousemove', onMouseMove) map.off('touchmove', onMouseMove) map.dragPan.enable() @@ -156,7 +156,7 @@ export default function RectangleRenderer({ lng: lngLat.lng - center.lng, lat: lngLat.lat - center.lat, } - setCursor({ draggingRectangle: true }) + setCursor({ draggingRegion: true }) svgRectangle.style('pointer-events', 'none') svgHandle.style('pointer-events', 'none') } diff --git a/src/region/region-picker/rectangle-picker/utils.js b/src/region/region-picker/rectangle-picker/utils.js deleted file mode 100644 index d6e68bf..0000000 --- a/src/region/region-picker/rectangle-picker/utils.js +++ /dev/null @@ -1,36 +0,0 @@ -import { geoPath, geoTransform } from 'd3-geo' -import mapboxgl from 'mapbox-gl' - -export const project = (map, coordinates, options = {}) => { - // Convert any LngLatLike to LngLat - const ll = mapboxgl.LngLat.convert(coordinates) - - let result = map.project(ll) - - // When present, use referencePoint to find closest renderable point - const { referencePoint } = options - if (referencePoint) { - const deltas = [-360, 360] - deltas.forEach((delta) => { - const alternate = map.project({ lat: ll.lat, lng: ll.lng + delta }) - if ( - Math.abs(alternate.x - referencePoint.x) < - Math.abs(result.x - referencePoint.x) - ) { - result = alternate - } - }) - } - - return result -} - -export function getPathMaker(map, options) { - const transform = geoTransform({ - point: function (lng, lat) { - const point = project(map, [lng, lat], options) - this.stream.point(point.x, point.y) - }, - }) - return geoPath().projection(transform) -} diff --git a/src/region/region-picker/circle-picker/utils.js b/src/region/region-picker/utils.js similarity index 100% rename from src/region/region-picker/circle-picker/utils.js rename to src/region/region-picker/utils.js From b339bc11c6684895fc4396ec32e4dc36962891d1 Mon Sep 17 00:00:00 2001 From: jakidxav Date: Fri, 27 Mar 2026 09:09:11 -0700 Subject: [PATCH 4/5] cleaning up unnecessary printouts; fixing missing react import; error statement thrown in region picker component now fixed; strict equality checking used for all strings in region picker; circle and rectangle pickers now use same fill opacity for cutout background; moving variables shared by circle and rectangle renderers to contants file --- .../region-picker/circle-picker/circle-renderer.js | 10 ++-------- src/region/region-picker/circle-picker/index.js | 3 ++- src/region/region-picker/constants.js | 13 +++++++++++-- src/region/region-picker/index.js | 6 +++--- src/region/region-picker/rectangle-picker/index.js | 7 +++---- .../rectangle-picker/rectangle-renderer.js | 13 ++----------- 6 files changed, 23 insertions(+), 29 deletions(-) diff --git a/src/region/region-picker/circle-picker/circle-renderer.js b/src/region/region-picker/circle-picker/circle-renderer.js index 56ad1f5..8ad2ce2 100644 --- a/src/region/region-picker/circle-picker/circle-renderer.js +++ b/src/region/region-picker/circle-picker/circle-renderer.js @@ -1,5 +1,5 @@ import { select } from 'd3-selection' -import { FLOATING_HANDLE, SHOW_RADIUS_GUIDELINE } from '../constants' +import { FLOATING_HANDLE, SHOW_RADIUS_GUIDELINE, POLES, UNITS_DICT } from '../constants' import { getPathMaker, project } from '../utils' import { area, @@ -14,12 +14,6 @@ import { } from '@turf/turf' import CursorManager from '../cursor-manager' -const POLES = [point([0, -90]), point([0, 90])] -const abbreviations = { - kilometers: 'km', - miles: 'mi', -} - export default function CircleRenderer({ id, map, @@ -311,7 +305,7 @@ export default function CircleRenderer({ const translateY = 4 svgRadiusText - .text(radius.toFixed(0) + abbreviations[units]) + .text(radius.toFixed(0) + UNITS_DICT[units]) .attr( 'transform', `rotate(${-1 * guidelineAngle + 90}) ` + `translate(0, ${translateY})` diff --git a/src/region/region-picker/circle-picker/index.js b/src/region/region-picker/circle-picker/index.js index a74a921..664adb7 100644 --- a/src/region/region-picker/circle-picker/index.js +++ b/src/region/region-picker/circle-picker/index.js @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react' import { useMap } from '../../../map-provider' import CircleRenderer from './circle-renderer' +import { HANDLE_RADIUS } from '../constants' const CirclePicker = ({ id, @@ -73,7 +74,7 @@ const CirclePicker = ({ fill={backgroundColor} fillOpacity={0.8} /> - + ) - } else if (mode == 'rectangle') { + } else if (mode === 'rectangle') { return ( ) } else { - throw new ValueError( + throw new Error( "RegionPicker `mode` must be one of ['circle', 'rectangle']" ) } diff --git a/src/region/region-picker/rectangle-picker/index.js b/src/region/region-picker/rectangle-picker/index.js index e94f338..eaf389b 100644 --- a/src/region/region-picker/rectangle-picker/index.js +++ b/src/region/region-picker/rectangle-picker/index.js @@ -1,8 +1,7 @@ -import { useState, useEffect } from 'react' +import React, { useState, useEffect } from 'react' import { useMap } from '../../../map-provider' import RectangleRenderer from './rectangle-renderer' - -import { HANDLE_RADIUS } from './rectangle-renderer' +import { HANDLE_RADIUS } from '../constants' const RectanglePicker = ({ id, @@ -74,7 +73,7 @@ const RectanglePicker = ({ height='100%' clipPath={`url(#rect-clip-${id})`} fill={backgroundColor} - fillOpacity={0.5} + fillOpacity={0.8} /> Date: Fri, 27 Mar 2026 16:09:26 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/region/region-picker/circle-picker/circle-renderer.js | 7 ++++++- src/region/region-picker/circle-picker/index.js | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/region/region-picker/circle-picker/circle-renderer.js b/src/region/region-picker/circle-picker/circle-renderer.js index 8ad2ce2..d71e3fe 100644 --- a/src/region/region-picker/circle-picker/circle-renderer.js +++ b/src/region/region-picker/circle-picker/circle-renderer.js @@ -1,5 +1,10 @@ import { select } from 'd3-selection' -import { FLOATING_HANDLE, SHOW_RADIUS_GUIDELINE, POLES, UNITS_DICT } from '../constants' +import { + FLOATING_HANDLE, + SHOW_RADIUS_GUIDELINE, + POLES, + UNITS_DICT, +} from '../constants' import { getPathMaker, project } from '../utils' import { area, diff --git a/src/region/region-picker/circle-picker/index.js b/src/region/region-picker/circle-picker/index.js index 664adb7..908832f 100644 --- a/src/region/region-picker/circle-picker/index.js +++ b/src/region/region-picker/circle-picker/index.js @@ -74,7 +74,12 @@ const CirclePicker = ({ fill={backgroundColor} fillOpacity={0.8} /> - +