44function _styleUrl ( ) {
55 if ( _mapStyle === 'satellite' ) return STYLE_SAT ;
66 if ( _mapStyle === 'dark' ) return STYLE_DARK ;
7+ if ( _mapStyle === 'bright' ) return STYLE_BRIGHT ;
78 return STYLE_STREET ; // light, enriched, terrain3d, globe all use Liberty as base
89}
910const STYLE_STREET = 'https://tiles.openfreemap.org/styles/liberty' ;
11+ const STYLE_BRIGHT = 'https://tiles.openfreemap.org/styles/bright' ;
1012const STYLE_DARK = 'https://tiles.openfreemap.org/styles/dark' ;
1113const STYLE_SAT = {
1214 version :8 ,
@@ -94,18 +96,24 @@ function _patchStyleBoundaries(styleObj) {
9496// - Sets ocean fill color for light mode
9597function _patchStyleWater ( styleObj ) {
9698 if ( ! styleObj || ! styleObj . layers ) return styleObj ;
99+ // Remove unsupported layer types that cause MapLibre v5 validation errors
100+ styleObj . layers = styleObj . layers . filter ( l => l . type !== 'sky' ) ;
101+ if ( styleObj . sky ) delete styleObj . sky ;
102+ if ( styleObj . terrain ) delete styleObj . terrain ;
97103 // Strip natural-earth terrain raster in Light Map (not enriched) to speed up rendering
98104 if ( _mapStyle === 'light' ) {
99105 styleObj . layers = styleObj . layers . filter ( l => l . id !== 'natural_earth' ) ;
100106 if ( styleObj . sources ) delete styleObj . sources [ 'ne2_shaded' ] ;
101107 }
102108 // Apple Maps Dark palette — neutral dark land, distinctly blue water.
103109 // Colors pre-compensated for canvas filter brightness(1.8) contrast(0.9).
104- if ( _mapStyle === 'dark' ) {
110+ if ( _mapStyle === 'dark' || _mapStyle === 'terrain3d' ) {
111+ // Darker water for both Dark Map and 3D Terrain modes
112+ const waterColor = _mapStyle === 'terrain3d' ? '#1a2e3d' : '#131619' ;
105113 for ( const layer of styleObj . layers ) {
106114 if ( ! layer . paint ) layer . paint = { } ;
107115 if ( layer . type === 'fill' && / ^ w a t e r / . test ( layer . id ) ) {
108- layer . paint [ 'fill-color' ] = '#131619' ;
116+ layer . paint [ 'fill-color' ] = waterColor ;
109117 }
110118 }
111119 }
@@ -299,12 +307,18 @@ function addPinLayers() {
299307 try { openPinPopup ( lat , lng ) ; } catch ( err ) { console . error ( err ) ; }
300308 const isEmpty = f . properties . iconId === 'pin-empty' ;
301309 if ( ! isEmpty ) {
302- const targetZoom = Math . max ( map . getZoom ( ) , 14 ) ;
310+ // Zoom in by at most 4 levels from current zoom, capped at 14
311+ // Prevents a jarring jump from low zoom levels (e.g. zoom 3 → 14)
312+ const targetZoom = Math . min ( Math . max ( map . getZoom ( ) , map . getZoom ( ) + 4 ) , 14 ) ;
303313 const needsZoom = map . getZoom ( ) < targetZoom ;
304314 const dist = Math . hypot ( map . getCenter ( ) . lng - lng , map . getCenter ( ) . lat - lat ) ;
305315 const alreadyThere = dist < 0.005 && ! needsZoom ;
306316 if ( ! alreadyThere ) {
317+ // In 3D Terrain (pitched view), offset the focal point downward in screen space
318+ // so the pin appears in the visible ground area rather than drifting off-screen
319+ const pitchOffset = _mapStyle === 'terrain3d' ? [ 0 , Math . round ( map . transform ?. height * 0.15 || 100 ) ] : [ 0 , 0 ] ;
307320 map . flyTo ( { center : [ lng , lat ] , zoom : targetZoom , speed : 0.8 , curve : 1.0 , essential : true ,
321+ offset : pitchOffset ,
308322 easing : t => t < 0.5 ? 4 * t * t * t : 1 - Math . pow ( - 2 * t + 2 , 3 ) / 2 } ) ;
309323 }
310324 }
@@ -318,7 +332,7 @@ function addPinLayers() {
318332// ═══════════════════════════════════════
319333function initTheme ( ) {
320334 const stored = localStorage . getItem ( 'matrix-theme' ) ;
321- if ( stored && [ 'dark' , 'light' , 'enriched' , 'terrain3d' , 'globe' ] . includes ( stored ) ) {
335+ if ( stored && [ 'dark' , 'light' , 'bright' , ' enriched', 'terrain3d' , 'globe' ] . includes ( stored ) ) {
322336 _mapStyle = stored ;
323337 } else {
324338 _mapStyle = window . matchMedia ( '(prefers-color-scheme: light)' ) . matches ? 'light' : 'dark' ;
@@ -330,8 +344,9 @@ function applyTheme() {
330344 _tileTemplatesCache = null ;
331345 // Set initial button label
332346 const btn = document . getElementById ( 'tb-style-btn' ) ;
333- const labels = { light : 'Light Map' , enriched : 'Terrain' , dark : 'Dark Map' , satellite : 'Satellite' , terrain3d : '3D Terrain' , globe : 'Globe' } ;
347+ const labels = { light : 'Light Map' , bright : 'Bright Map' , enriched : 'Terrain' , dark : 'Dark Map' , satellite : 'Satellite' , terrain3d : '3D Terrain' , globe : 'Globe' } ;
334348 if ( btn ) btn . textContent = ( labels [ _mapStyle ] || _mapStyle ) + ' ▾' ;
349+
335350 // Set initial active state in menu
336351 document . querySelectorAll ( '.style-menu-item' ) . forEach ( el => el . classList . toggle ( 'active' , el . dataset . style === _mapStyle ) ) ;
337352 // Disable Export Video in Globe mode
@@ -429,7 +444,13 @@ async function initMap() {
429444 const otherUrl = styleUrl === STYLE_DARK ? STYLE_STREET : STYLE_DARK ;
430445 fetch ( otherUrl ) . then ( r => r . json ( ) ) . then ( j => { _styleJsonCache [ otherUrl ] = j ; } ) . catch ( ( ) => { } ) ;
431446 map = new maplibregl . Map ( { container :'map' , style : initStyle , center :[ 0 , 20 ] , zoom :1.8 , attributionControl :false , maxTileCacheSize :200 , canvasContextAttributes :{ preserveDrawingBuffer :true } } ) ;
432- map . on ( 'error' , ( e ) => console . error ( 'MapLibre error:' , e . error ?. message || e . message || e ) ) ;
447+ // Suppress non-critical style validation errors (sky/terrain-dem references in
448+ // unpatched style JSON when the pre-fetch falls back to URL loading)
449+ const _suppressedErrors = / s k y | t e r r a i n - d e m | n o n - e x i s t i n g l a y e r | s a m e s o u r c e .* h i l l s h a d e / i;
450+ map . on ( 'error' , ( e ) => {
451+ const msg = e . error ?. message || e . message || '' ;
452+ if ( ! _suppressedErrors . test ( msg ) ) console . error ( 'MapLibre error:' , msg ) ;
453+ } ) ;
433454 map . addControl ( new maplibregl . NavigationControl ( { showCompass :false } ) , 'bottom-right' ) ;
434455 // Recover from WebGL context loss (Safari loses context after sleep or memory pressure)
435456 const canvas = map . getCanvas ( ) ;
@@ -464,29 +485,40 @@ async function initMap() {
464485 zoomEl . style . cssText = 'position:absolute;bottom:24px;left:8px;background:rgba(0,0,0,.6);color:#fff;font-size:11px;padding:2px 6px;border-radius:4px;z-index:10;pointer-events:none;font-family:monospace' ;
465486 document . getElementById ( 'map' ) . appendChild ( zoomEl ) ;
466487 const pitchWrap = document . createElement ( 'div' ) ;
467- pitchWrap . style . cssText = 'position:absolute;bottom:24px;left:70px;display:none;align-items:center;gap:4px;z-index:10' ;
468- const pitchEl = document . createElement ( 'div' ) ;
488+ pitchWrap . id = 'pitch-wrap' ;
489+ pitchWrap . style . cssText = 'position:absolute;bottom:24px;left:70px;display:none;align-items:center;z-index:10;background:rgba(0,0,0,.6);border-radius:4px;font-family:monospace;font-size:11px;color:#fff' ;
490+ const pitchEl = document . createElement ( 'span' ) ;
469491 pitchEl . id = 'pitch-debug' ;
470- pitchEl . style . cssText = 'background:rgba(0,0,0,.6);color:#fff;font-size:11px; padding:2px 6px;border-radius:4px; pointer-events:none;font-family:monospace ' ;
492+ pitchEl . style . cssText = 'padding:2px 6px;pointer-events:none' ;
471493 const resetViewEl = document . createElement ( 'button' ) ;
472494 resetViewEl . id = 'reset-view-btn' ;
473- resetViewEl . title = 'Reset to top-down view ' ;
495+ resetViewEl . title = 'Reset north ' ;
474496 resetViewEl . textContent = '⊙' ;
475- resetViewEl . style . cssText = 'background:rgba(0,0,0,.6) ;color:#fff;font-size:14px ;border:none;border-radius:4px ;cursor:pointer;padding:2px 8px ;font-family:monospace;display:none;line-height:1' ;
497+ resetViewEl . style . cssText = 'background:none ;color:#fff;font-size:15px ;border:none;border-left:1px solid rgba(255,255,255,.2) ;cursor:pointer;padding:0 6px ;font-family:monospace;display:none;line-height:1' ;
476498 resetViewEl . addEventListener ( 'click' , ( ) => { map . easeTo ( { bearing : 0 , duration : 500 } ) ; } ) ;
477499 pitchWrap . appendChild ( pitchEl ) ;
478500 pitchWrap . appendChild ( resetViewEl ) ;
479501 document . getElementById ( 'map' ) . appendChild ( pitchWrap ) ;
480502 const exaggerationEl = document . createElement ( 'div' ) ;
481503 exaggerationEl . id = 'exaggeration-ctrl' ;
482504 exaggerationEl . style . cssText = 'position:absolute;bottom:24px;left:310px;background:rgba(0,0,0,.6);color:#fff;font-size:11px;padding:2px 8px;border-radius:4px;z-index:10;font-family:monospace;display:none;align-items:center;gap:6px' ;
483- exaggerationEl . innerHTML = '⛰ <input type="range" id="exaggeration-slider" min="1" max="3" step="0.1" value="1.5 " style="width:70px;accent-color:#fff;vertical-align:middle"> <span id="exaggeration-val">1.5 ×</span>' ;
505+ exaggerationEl . innerHTML = '⛰ <input type="range" id="exaggeration-slider" min="1" max="3" step="0.1" value="1.2 " style="width:70px;accent-color:#fff;vertical-align:middle"> <span id="exaggeration-val">1.2 ×</span>' ;
484506 document . getElementById ( 'map' ) . appendChild ( exaggerationEl ) ;
485507 document . getElementById ( 'exaggeration-slider' ) . addEventListener ( 'input' , ( e ) => {
486508 const val = parseFloat ( e . target . value ) ;
487509 document . getElementById ( 'exaggeration-val' ) . textContent = val . toFixed ( 1 ) + '×' ;
488510 if ( map . getTerrain ( ) ) map . setTerrain ( { source : 'terrain-dem' , exaggeration : val } ) ;
489511 } ) ;
512+ // Live GPS coordinates on mouse move
513+ const coordsEl = document . createElement ( 'div' ) ;
514+ coordsEl . id = 'coords-debug' ;
515+ coordsEl . style . cssText = 'position:absolute;bottom:24px;right:50px;background:rgba(0,0,0,.6);color:#fff;font-size:11px;padding:2px 6px;border-radius:4px;z-index:10;pointer-events:none;font-family:monospace;display:none' ;
516+ document . getElementById ( 'map' ) . appendChild ( coordsEl ) ;
517+ map . on ( 'mousemove' , ( e ) => {
518+ coordsEl . textContent = `${ e . lngLat . lat . toFixed ( 4 ) } °, ${ e . lngLat . lng . toFixed ( 4 ) } °` ;
519+ coordsEl . style . display = '' ;
520+ } ) ;
521+ map . getCanvas ( ) . addEventListener ( 'mouseout' , ( ) => { coordsEl . style . display = 'none' ; } ) ;
490522 const updateZoom = ( ) => {
491523 zoomEl . textContent = 'z' + map . getZoom ( ) . toFixed ( 2 ) ;
492524 const is3D = _mapStyle === 'terrain3d' ;
@@ -614,9 +646,13 @@ async function initMap() {
614646 // Elevation — only in 3D Terrain mode
615647 let elevationStr = '' ;
616648 if ( _mapStyle === 'terrain3d' ) {
617- const elev = map . queryTerrainElevation ( [ lng , lat ] ) ;
618- if ( elev !== null && elev !== undefined ) {
619- elevationStr = `${ Math . round ( elev ) . toLocaleString ( ) } m` ;
649+ try {
650+ const elev = map . queryTerrainElevation ( [ lng , lat ] ) ;
651+ if ( elev !== null && elev !== undefined ) {
652+ elevationStr = `${ Math . round ( elev ) . toLocaleString ( ) } m` ;
653+ }
654+ } catch ( e ) {
655+ // Elevation error when zoom exceeds source maxzoom — ignore
620656 }
621657 }
622658
@@ -736,7 +772,7 @@ function setMapStyle(mode) {
736772 }
737773
738774 // Update button label
739- const labels = { light : 'Light Map' , enriched : 'Terrain' , dark : 'Dark Map' , satellite : 'Satellite' , terrain3d : '3D Terrain' , globe : 'Globe' } ;
775+ const labels = { light : 'Light Map' , bright : 'Bright Map' , enriched : 'Terrain' , dark : 'Dark Map' , satellite : 'Satellite' , terrain3d : '3D Terrain' , globe : 'Globe' } ;
740776 const btn = document . getElementById ( 'tb-style-btn' ) ;
741777 if ( btn ) btn . textContent = ( labels [ mode ] || mode ) + ' ▾' ;
742778
@@ -760,37 +796,75 @@ function _applyTerrainAndProjection() {
760796
761797 // Set projection FIRST — before any camera moves — so easeTo never runs under the wrong projection
762798 if ( map . setProjection ) {
799+ // Add atmospheric fog for depth perception in 3D modes
800+ if ( map . setFog ) {
801+ map . setFog ( {
802+ color : 'white' ,
803+ 'high-color' : '#add8e6' ,
804+ 'horizon-blend' : 0.3 ,
805+ atmosphere : 0.5
806+ } ) ;
807+ }
763808 map . setProjection ( { type : isGlobe ? 'globe' : 'mercator' } ) ;
764809 }
765810
766811 // Terrain — AWS Terrain Tiles (free, no API key, terrarium encoding)
767812 if ( is3D ) {
813+ // Use separate sources for terrain mesh and hillshade to improve rendering quality
768814 if ( ! map . getSource ( 'terrain-dem' ) ) {
769815 map . addSource ( 'terrain-dem' , {
770816 type : 'raster-dem' ,
771817 tiles : [ 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png' ] ,
772818 tileSize : 512 , maxzoom : 15 , encoding : 'terrarium'
773819 } ) ;
774820 }
821+ if ( ! map . getSource ( 'hillshade-dem' ) ) {
822+ map . addSource ( 'hillshade-dem' , {
823+ type : 'raster-dem' ,
824+ tiles : [ 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png' ] ,
825+ tileSize : 512 , maxzoom : 15 , encoding : 'terrarium'
826+ } ) ;
827+ }
775828 const sliderEl = document . getElementById ( 'exaggeration-slider' ) ;
776- const exaggeration = sliderEl ? parseFloat ( sliderEl . value ) : 1.5 ;
829+ const exaggeration = sliderEl ? parseFloat ( sliderEl . value ) : 1.2 ;
777830 map . setTerrain ( { source : 'terrain-dem' , exaggeration } ) ;
831+ map . setPaintProperty ( 'terrain-dem' , 'raster-blur' , 3 ) ;
832+ // Add sky layer for atmospheric background in 3D
833+ if ( map . getLayer ( 'sky' ) === undefined ) {
834+ map . addLayer ( { id : 'sky' , type : 'sky' , paint : { 'sky-type' : 'gradient' , 'sky-gradient' : [ 'interpolate' , [ 'linear' ] , [ 'sky-radial-progress' ] , 0 , 'rgba(255,255,255,0)' , 0.5 , '#87cefa' , 1 , '#4682b4' ] } } ) ;
835+ }
778836 if ( ! map . getLayer ( 'terrain-hillshade' ) ) {
779837 // Insert below the first road/label layer so hillshading shows through
780838 const firstSymbol = map . getStyle ( ) . layers . find ( l => l . type === 'line' || l . type === 'symbol' ) ;
781839 map . addLayer ( {
782840 id : 'terrain-hillshade' ,
783841 type : 'hillshade' ,
784- source : 'terrain -dem' ,
842+ source : 'hillshade -dem' ,
785843 paint : {
786- 'hillshade-exaggeration' : 0.75 ,
787- 'hillshade-illumination-direction' : 315 ,
788- 'hillshade-illumination-anchor' : 'map ' ,
844+ 'hillshade-exaggeration' : 1.0 ,
845+ 'hillshade-illumination-direction' : 270 ,
846+ 'hillshade-illumination-anchor' : 'viewport ' ,
789847 'hillshade-shadow-color' : '#1a2a35' ,
790848 'hillshade-highlight-color' : '#f0f4f8' ,
791849 'hillshade-accent-color' : '#2d4a5a' ,
792850 }
793851 } , firstSymbol ?. id ) ;
852+ // Add 3D building extrusion if OpenMapTiles source is present
853+ if ( map . getSource ( 'openmaptiles' ) && ! map . getLayer ( '3d-buildings' ) ) {
854+ map . addLayer ( {
855+ id : '3d-buildings' ,
856+ source : 'openmaptiles' ,
857+ 'source-layer' : 'building' ,
858+ type : 'fill-extrusion' ,
859+ minzoom : 15 ,
860+ paint : {
861+ 'fill-extrusion-color' : '#ddd' ,
862+ 'fill-extrusion-height' : [ 'get' , 'height' ] ,
863+ 'fill-extrusion-base' : [ 'get' , 'min_height' ] ,
864+ 'fill-extrusion-opacity' : 0.6
865+ }
866+ } , 'terrain-hillshade' ) ;
867+ }
794868 }
795869 map . easeTo ( { pitch : 50 , bearing : 0 , duration : 800 } ) ;
796870 } else {
@@ -801,7 +875,7 @@ function _applyTerrainAndProjection() {
801875 map . flyTo ( { center : [ 0 , 0 ] , zoom : 2 , pitch : 0 , bearing : 0 , duration : 800 } ) ;
802876 } else {
803877 map . setMinZoom ( - 2 ) ;
804- map . easeTo ( { pitch : 0 , bearing : 0 , duration : 600 } ) ;
878+ map . jumpTo ( { pitch : 0 , bearing : 0 } ) ;
805879 }
806880 }
807881}
0 commit comments