@@ -449,6 +449,80 @@ function isFeatureCollection(value: unknown): value is FeatureCollection {
449449 ) ;
450450}
451451
452+ const WEB_MERCATOR_RADIUS = 6378137 ;
453+ const MAX_WEB_MERCATOR_LATITUDE = 85.0511287798066 ;
454+
455+ /**
456+ * Prepare a WGS84 map layer for Whitebox's planar buffer operation.
457+ *
458+ * `buffer_vector` treats both axes as Cartesian map units. Buffering RFC 7946
459+ * longitude/latitude directly therefore creates a circle in degrees which is
460+ * stretched into an oval when MapLibre displays it in Web Mercator. Projecting
461+ * the input to EPSG:3857 makes the tool operate in the same conformal plane as
462+ * the map. The GeoJSON writer sees the attached CRS and reprojects the result
463+ * back to WGS84 before GeoLibre imports it.
464+ *
465+ * The dialog stores the buffer distance in degrees, including values converted
466+ * from metres by its explicitly approximate geographic-distance control. Using
467+ * the equatorial metres-per-degree scale preserves the old buffer's horizontal
468+ * radius while making its vertical radius match.
469+ */
470+ export function prepareGeographicBufferInput (
471+ geojson : FeatureCollection ,
472+ distance : unknown ,
473+ ) : { geojson : FeatureCollection ; distance : number } | null {
474+ const degrees = typeof distance === "number" ? distance : Number ( distance ) ;
475+ if ( ! Number . isFinite ( degrees ) || degrees <= 0 ) return null ;
476+
477+ const projectPosition = ( position : number [ ] ) : number [ ] => {
478+ const longitude = position [ 0 ] ;
479+ const latitude = Math . max (
480+ - MAX_WEB_MERCATOR_LATITUDE ,
481+ Math . min ( MAX_WEB_MERCATOR_LATITUDE , position [ 1 ] ) ,
482+ ) ;
483+ const x = WEB_MERCATOR_RADIUS * ( ( longitude * Math . PI ) / 180 ) ;
484+ const y = WEB_MERCATOR_RADIUS * Math . log ( Math . tan ( Math . PI / 4 + ( latitude * Math . PI ) / 360 ) ) ;
485+ return [ x , y , ...position . slice ( 2 ) ] ;
486+ } ;
487+
488+ const projectCoordinates = ( coordinates : unknown ) : unknown => {
489+ if ( ! Array . isArray ( coordinates ) ) return coordinates ;
490+ if (
491+ coordinates . length >= 2 &&
492+ typeof coordinates [ 0 ] === "number" &&
493+ typeof coordinates [ 1 ] === "number"
494+ ) {
495+ return projectPosition ( coordinates as number [ ] ) ;
496+ }
497+ return coordinates . map ( projectCoordinates ) ;
498+ } ;
499+
500+ const projected = structuredClone ( geojson ) as FeatureCollection & {
501+ crs ?: { type : "name" ; properties : { name : string } } ;
502+ } ;
503+ const projectGeometry = ( geometry : ( typeof projected . features ) [ number ] [ "geometry" ] ) : void => {
504+ if ( ! geometry ) return ;
505+ if ( geometry . type === "GeometryCollection" ) {
506+ for ( const member of geometry . geometries ) {
507+ projectGeometry ( member ) ;
508+ }
509+ return ;
510+ }
511+ if ( "coordinates" in geometry ) {
512+ geometry . coordinates = projectCoordinates ( geometry . coordinates ) as never ;
513+ }
514+ } ;
515+ for ( const feature of projected . features ) {
516+ projectGeometry ( feature . geometry ) ;
517+ }
518+ projected . crs = { type : "name" , properties : { name : "EPSG:3857" } } ;
519+
520+ return {
521+ geojson : projected ,
522+ distance : WEB_MERCATOR_RADIUS * ( ( degrees * Math . PI ) / 180 ) ,
523+ } ;
524+ }
525+
452526/**
453527 * Whether bytes start with the TIFF signature: "II" (little-endian) or "MM"
454528 * (big-endian) followed by the version number in the byte order's own
@@ -567,6 +641,18 @@ export async function runWhiteboxToolWasm(request: RunWhiteboxToolRequest): Prom
567641 const encoder = new TextEncoder ( ) ;
568642 const input : Record < string , Uint8Array > = { } ;
569643 const args : string [ ] = [ ] ;
644+ const parameterOverrides : Record < string , unknown > = { } ;
645+ let geographicBufferInput : FeatureCollection | null = null ;
646+ if ( request . tool_id === "buffer_vector" ) {
647+ const geojson = request . layer_inputs ?. input ?. geojson ;
648+ const prepared = geojson
649+ ? prepareGeographicBufferInput ( geojson , request . parameters . distance )
650+ : null ;
651+ if ( prepared ) {
652+ geographicBufferInput = prepared . geojson ;
653+ parameterOverrides . distance = prepared . distance ;
654+ }
655+ }
570656 // How each output file is turned into a job output: "geojson" is parsed into a
571657 // FeatureCollection (a map layer); "raster" is normalized to a COG before it
572658 // reaches the map; "bytes" is returned raw (a file_out blob or a
@@ -591,8 +677,12 @@ export async function runWhiteboxToolWasm(request: RunWhiteboxToolRequest): Prom
591677 const name = param . name ;
592678
593679 if ( kind === "vector_in" ) {
594- const geojson = request . layer_inputs ?. [ name ] ?. geojson ;
680+ let geojson = request . layer_inputs ?. [ name ] ?. geojson ;
595681 if ( ! geojson ) throw new Error ( `Missing vector input for "${ name } "` ) ;
682+ // A map layer is RFC 7946 WGS84, while Whitebox's buffer is Cartesian.
683+ // Run this one operation in Web Mercator so its round buffers stay round
684+ // on the map; the EPSG tag makes the tool's GeoJSON writer return WGS84.
685+ if ( name === "input" && geographicBufferInput ) geojson = geographicBufferInput ;
596686 const file = `${ name } .geojson` ;
597687 input [ file ] = encoder . encode ( JSON . stringify ( geojson ) ) ;
598688 args . push ( `--${ name } =/work/${ file } ` ) ;
@@ -659,7 +749,7 @@ export async function runWhiteboxToolWasm(request: RunWhiteboxToolRequest): Prom
659749 outputs . push ( { name, file, kind : kind === "raster_out" ? "raster" : "bytes" } ) ;
660750 args . push ( `--${ name } =/work/${ file } ` ) ;
661751 } else {
662- const value = request . parameters [ name ] ;
752+ const value = parameterOverrides [ name ] ?? request . parameters [ name ] ;
663753 if ( value !== undefined && value !== null && value !== "" ) {
664754 args . push ( `--${ name } =${ value } ` ) ;
665755 }
0 commit comments