@@ -199,70 +199,110 @@ function WorldMap() {
199199
200200 function setBounds ( projection , maxLat ) {
201201 const [ yaw ] = projection . rotate ( ) ;
202- // Top left corner
202+ // Top- left corner of the “viewable” lat
203203 const xymax = projection ( [ - yaw + 180 - 1e-6 , - maxLat ] ) ;
204- // Bottom right corner
204+ // Bottom- right corner
205205 const xymin = projection ( [ - yaw - 180 + 1e-6 , maxLat ] ) ;
206- return [ xymin , xymax ] ;
206+ return [ xymin , xymax ] ; // [ [xMin, yMin], [xMax, yMax] ]
207+ }
208+
209+ function clampVertical ( projection , maxLat , height ) {
210+ const b = setBounds ( projection , maxLat ) ;
211+ const [ xMin , yMin ] = b [ 0 ] ; // top-left
212+ const [ xMax , yMax ] = b [ 1 ] ; // bottom-right
213+
214+ // Current translation
215+ const t = projection . translate ( ) ;
216+
217+ // If the top (yMin) is now below 0, push it down.
218+ if ( yMin > 0 ) {
219+ t [ 1 ] -= yMin ; // shift map upward
220+ }
221+ // If the bottom (yMax) is now above the container height, push it up.
222+ else if ( yMax < height ) {
223+ t [ 1 ] += ( height - yMax ) ; // shift map downward
224+ }
225+
226+ projection . translate ( t ) ;
207227 }
208228
209229 function zoomed ( event , projection , path , scaleExtent , g ) {
210- const newX = event . transform . x % width ;
211- const newY = event . transform . y ;
212230 const scale = event . transform . k ;
231+ const containerCenter = [ innerW ( ) / 2 , height / 2 ] ; // Use the center of the container
232+ let anchor = containerCenter ; // Always use the container's center as the anchor
233+
234+ if ( Math . abs ( scale - slast ) > 1e-3 ) {
235+ // Get the geographic coordinate at the container center
236+ const geoCoord = projection . invert ( anchor ) ;
213237
214- if ( scale != slast ) {
215- // Adjust the scale of the projection based on the zoom level
238+ // Update projection scale based on the new zoom factor
216239 projection . scale ( scale * ( innerW ( ) / ( 2 * Math . PI ) ) ) ;
240+
241+ // Re-project the same geographic coordinate to determine its new position
242+ const newPixel = projection ( geoCoord ) ;
243+ const dxAnchor = anchor [ 0 ] - newPixel [ 0 ] ;
244+ const dyAnchor = anchor [ 1 ] - newPixel [ 1 ] ;
245+
246+ // Shift translation so that the geoCoord remains at the chosen anchor
247+ const t = projection . translate ( ) ;
248+ projection . translate ( [ t [ 0 ] + dxAnchor , t [ 1 ] + dyAnchor ] ) ;
249+
250+ // Clamp vertical so the map doesn't float off the top/bottom
251+ clampVertical ( projection , maxLat , height ) ;
217252 } else {
218- // Calculate the new longitude based on the x-coordinate
219- let [ longitude ] = projection . rotate ( ) ;
253+ // --- Pure Panning (scale unchanged) ---
254+ const newX = event . transform . x ;
255+ const newY = event . transform . y ;
256+ const dx = newX - tlast [ 0 ] ;
257+ const dy = newY - tlast [ 1 ] ;
220258
221- // Use the X translation to rotate, based on the current scale
222- longitude += 360 * ( ( newX - tlast [ 0 ] ) / width ) * ( scaleExtent [ 0 ] / scale ) ;
259+ // Infinite horizontal scroll: adjust rotation (longitude)
260+ let [ longitude ] = projection . rotate ( ) ;
261+ longitude += 360 * ( dx / width ) * ( scaleExtent [ 0 ] / scale ) ;
223262 projection . rotate ( [ longitude , 0 , 0 ] ) ;
224263
225- // Calculate the new latitude based on the y-coordinate
226- const b = setBounds ( projection , maxLat ) ;
227- let dy = newY - tlast [ 1 ] ;
228- if ( b [ 0 ] [ 1 ] + dy > 0 )
229- dy = - b [ 0 ] [ 1 ] ;
230- else if ( b [ 1 ] [ 1 ] + dy < height )
231- dy = height - b [ 1 ] [ 1 ] ;
232- projection . translate ( [ projection . translate ( ) [ 0 ] , projection . translate ( ) [ 1 ] + dy ] ) ;
264+ // Vertical shift: adjust translation
265+ const t = projection . translate ( ) ;
266+ t [ 1 ] += dy ;
267+ projection . translate ( t ) ;
268+
269+ // Clamp vertical
270+ clampVertical ( projection , maxLat , height ) ;
233271 }
234272
235- // Redraw paths with the updated projection
273+ // Redraw paths
236274 g . selectAll ( 'path' ) . attr ( 'd' , path ) ;
237275
238- // Save last values
276+ // Save state for next event
277+ tlast = [ event . transform . x , event . transform . y ] ;
239278 slast = scale ;
240- tlast = [ newX , newY ] ;
241279 }
242280
243281 function createSVG ( selection ) {
244282 const svg = d3 . select ( selection )
245283 . append ( 'svg' )
246284 . attr ( 'class' , 'map' )
247- . attr ( 'width' , width )
285+ . attr ( 'style' , 'display:block; margin:auto;' )
286+ . attr ( 'width' , innerW ( ) )
248287 . attr ( 'height' , height )
249288 . lower ( ) ;
250289
251290 const g = svg . append ( 'g' )
252- . attr ( 'transform' , `translate(${ margin . left } , 0)` )
291+ . attr ( 'transform' , `translate(0 , 0)` )
253292 . attr ( 'transform-origin' , '50% 50%' ) ;
254293
255294 projection = d3 . geoMercator ( )
256295 . center ( [ 0 , 15 ] )
257- . scale ( [ ( innerW ( ) ) / ( 2 * Math . PI ) ] )
296+ . scale ( ( innerW ( ) ) / ( 2 * Math . PI ) )
258297 . translate ( [ ( innerW ( ) ) / 2 , height / 1.5 ] ) ;
298+
259299 path = d3 . geoPath ( ) . projection ( projection ) ;
260300
261301 // Calculate scale extent and initial scale
262302 const bounds = setBounds ( projection , maxLat ) ;
263- const s = width / ( bounds [ 1 ] [ 0 ] - bounds [ 0 ] [ 0 ] ) ;
303+ const s = innerW ( ) / ( bounds [ 1 ] [ 0 ] - bounds [ 0 ] [ 0 ] ) ;
264304 // The minimum and maximum zoom scales
265- const scaleExtent = [ s , 5 * s ] ;
305+ const scaleExtent = [ s , 6 * s ] ;
266306
267307 const zoom = d3 . zoom ( )
268308 . scaleExtent ( scaleExtent )
0 commit comments