@@ -84,6 +84,84 @@ const endStyle = new Style({
8484 } ) ,
8585} )
8686
87+ /** Target screen-space spacing between direction arrows, in CSS pixels. */
88+ const ARROW_SCREEN_SPACING_PX = 280
89+
90+ /** Logical pixel size of the square chevron icon (in CSS pixels). */
91+ const ARROW_CANVAS_SIZE = 18
92+
93+ /**
94+ * Chevron stroke widths relative to the track line. Drawing the chevron a bit
95+ * thinner than the track keeps the arrows visually subordinate to the route.
96+ */
97+ const ARROW_STROKE_SCALE = 0.75
98+
99+ /**
100+ * Traces the chevron path on `ctx` in CSS-pixel coordinates: two arms meeting
101+ * at an apex near the top of the canvas (= "north" when the icon is unrotated).
102+ * Symmetric around the vertical centerline so anchoring at [0.5, 0.5] places
103+ * the chevron's geometric center on the track point.
104+ */
105+ function traceArrowPath ( ctx : CanvasRenderingContext2D ) : void {
106+ const size = ARROW_CANVAS_SIZE
107+ const cx = size / 2
108+ const apexY = TRACK_LINE_HALO_WIDTH / 2 + 1
109+ const armY = size - TRACK_LINE_HALO_WIDTH / 2 - 1
110+ const armDx = 5
111+ ctx . beginPath ( )
112+ ctx . moveTo ( cx - armDx , armY )
113+ ctx . lineTo ( cx , apexY )
114+ ctx . lineTo ( cx + armDx , armY )
115+ }
116+
117+ /**
118+ * Creates a high-DPR canvas sized `ARROW_CANVAS_SIZE` CSS pixels with the
119+ * drawing transform pre-scaled by `dpr`, so subsequent path commands use
120+ * logical (CSS) coordinates and rasterize at full device resolution. Pair with
121+ * `scale: 1 / dpr` on the OL `Icon` to display crisp pixels on hi-DPI screens.
122+ */
123+ function createArrowCanvas ( dpr : number ) : HTMLCanvasElement {
124+ const canvas = document . createElement ( "canvas" )
125+ canvas . width = ARROW_CANVAS_SIZE * dpr
126+ canvas . height = ARROW_CANVAS_SIZE * dpr
127+ const ctx = canvas . getContext ( "2d" ) !
128+ ctx . scale ( dpr , dpr )
129+ ctx . lineCap = "round"
130+ ctx . lineJoin = "round"
131+ return canvas
132+ }
133+
134+ /**
135+ * Builds the chevron halo (white outer stroke) sprite. Rendered on its own
136+ * layer below the colored inner stroke so all white halos in the scene (track
137+ * + every chevron) compose into one continuous silhouette rather than crossing
138+ * each other.
139+ */
140+ function buildArrowHaloCanvas ( dpr : number ) : HTMLCanvasElement {
141+ const canvas = createArrowCanvas ( dpr )
142+ const ctx = canvas . getContext ( "2d" ) !
143+ ctx . lineWidth = TRACK_LINE_HALO_WIDTH * ARROW_STROKE_SCALE
144+ ctx . strokeStyle = "#ffffff"
145+ traceArrowPath ( ctx )
146+ ctx . stroke ( )
147+ return canvas
148+ }
149+
150+ /**
151+ * Builds the chevron inner (colored) sprite. Drawn on top of all halos so it
152+ * matches the track inner stroke and blends through the chevron apex into the
153+ * track line.
154+ */
155+ function buildArrowInnerCanvas ( color : string , dpr : number ) : HTMLCanvasElement {
156+ const canvas = createArrowCanvas ( dpr )
157+ const ctx = canvas . getContext ( "2d" ) !
158+ ctx . lineWidth = TRACK_LINE_INNER_WIDTH * ARROW_STROKE_SCALE
159+ ctx . strokeStyle = color
160+ traceArrowPath ( ctx )
161+ ctx . stroke ( )
162+ return canvas
163+ }
164+
87165interface TrackPoint {
88166 lat : number
89167 lon : number
@@ -221,17 +299,24 @@ export default memo(function TrackMap({
221299 const coords = points . map ( ( p ) => projectPoint ( p . lon , p . lat , layer . type ) )
222300 coordsRef . current = coords
223301
224- const trackFeature = new Feature ( {
302+ // Split track polyline into separate halo and inner features so the halo
303+ // can be drawn under the chevron halos and the colored inner over them.
304+ const trackHaloFeature = new Feature ( {
225305 geometry : new LineString ( coords ) ,
226306 } )
227- trackFeature . setStyle ( [
307+ trackHaloFeature . setStyle (
228308 new Style ( {
229309 stroke : new Stroke ( { color : "#ffffff" , width : TRACK_LINE_HALO_WIDTH } ) ,
230- } ) ,
310+ } )
311+ )
312+ const trackInnerFeature = new Feature ( {
313+ geometry : new LineString ( coords ) ,
314+ } )
315+ trackInnerFeature . setStyle (
231316 new Style ( {
232317 stroke : new Stroke ( { color, width : TRACK_LINE_INNER_WIDTH } ) ,
233- } ) ,
234- ] )
318+ } )
319+ )
235320
236321 const startFeature = new Feature ( { geometry : new Point ( coords [ 0 ] ) } )
237322 startFeature . setStyle ( startStyle )
@@ -240,8 +325,10 @@ export default memo(function TrackMap({
240325 } )
241326 endFeature . setStyle ( endStyle )
242327
243- const vectorSource = new VectorSource ( {
244- features : [ trackFeature , startFeature , endFeature ] ,
328+ const trackHaloSource = new VectorSource ( { features : [ trackHaloFeature ] } )
329+ const trackInnerSource = new VectorSource ( { features : [ trackInnerFeature ] } )
330+ const endpointSource = new VectorSource ( {
331+ features : [ startFeature , endFeature ] ,
245332 } )
246333
247334 const marker = new Feature ( { geometry : new Point ( coords [ 0 ] ) } )
@@ -286,6 +373,15 @@ export default memo(function TrackMap({
286373 const closureLayer = new VectorLayer ( { source : closureSource } )
287374 closureLayerRef . current = closureLayer
288375
376+ // Render chevron sprites at device pixel resolution and shrink them back
377+ // via Icon `scale: 1/dpr` so they're as crisp as the OL-drawn track line.
378+ const dpr = window . devicePixelRatio || 1
379+ const arrowIconScale = 1 / dpr
380+ const arrowHaloImg = buildArrowHaloCanvas ( dpr )
381+ const arrowInnerImg = buildArrowInnerCanvas ( color , dpr )
382+ const arrowHaloSource = new VectorSource ( )
383+ const arrowInnerSource = new VectorSource ( )
384+
289385 const overlay = new Overlay ( {
290386 element : tooltipRef . current ! ,
291387 positioning : "bottom-center" ,
@@ -294,9 +390,16 @@ export default memo(function TrackMap({
294390 } )
295391 overlayRef . current = overlay
296392
393+ // Layer order: all white halos first (track + chevrons) so they compose
394+ // into one silhouette, then all colored inners on top, then closures and
395+ // endpoint markers above the line, hover marker on top.
297396 mapLayers . push (
298- new VectorLayer ( { source : vectorSource } ) ,
397+ new VectorLayer ( { source : trackHaloSource } ) ,
398+ new VectorLayer ( { source : arrowHaloSource } ) ,
399+ new VectorLayer ( { source : trackInnerSource } ) ,
400+ new VectorLayer ( { source : arrowInnerSource } ) ,
299401 closureLayer ,
402+ new VectorLayer ( { source : endpointSource } ) ,
300403 new VectorLayer ( { source : mSource } )
301404 )
302405
@@ -312,14 +415,90 @@ export default memo(function TrackMap({
312415 view,
313416 } )
314417
315- const extent = vectorSource . getExtent ( )
418+ const extent = trackHaloSource . getExtent ( )
316419 if ( extent && ! extentIsEmpty ( extent ) ) {
317420 map . getView ( ) . fit ( extent , { padding : [ 40 , 40 , 40 , 40 ] , maxZoom : 16 } )
318421 }
319422
320423 mapInstance . current = map
321424
425+ const rebuildArrows = ( ) => {
426+ arrowHaloSource . clear ( )
427+ arrowInnerSource . clear ( )
428+ const cs = coordsRef . current
429+ if ( cs . length < 2 ) return
430+ const resolution = map . getView ( ) . getResolution ( )
431+ if ( ! resolution || ! Number . isFinite ( resolution ) ) return
432+ const spacing = resolution * ARROW_SCREEN_SPACING_PX
433+
434+ // Cumulative segment lengths in projected map units.
435+ const cum : number [ ] = new Array ( cs . length )
436+ cum [ 0 ] = 0
437+ for ( let i = 1 ; i < cs . length ; i ++ ) {
438+ const dx = cs [ i ] [ 0 ] - cs [ i - 1 ] [ 0 ]
439+ const dy = cs [ i ] [ 1 ] - cs [ i - 1 ] [ 1 ]
440+ cum [ i ] = cum [ i - 1 ] + Math . hypot ( dx , dy )
441+ }
442+ const total = cum [ cs . length - 1 ]
443+ // Skip when the visible track is too short to fit even one arrow comfortably.
444+ if ( total < spacing * 1.2 ) return
445+
446+ // Keep arrows away from the start/end markers.
447+ const margin = Math . min ( spacing * 0.6 , total * 0.1 )
448+ const haloFeatures : Feature [ ] = [ ]
449+ const innerFeatures : Feature [ ] = [ ]
450+ let segIdx = 1
451+ for ( let target = margin ; target < total - margin ; target += spacing ) {
452+ while ( segIdx < cum . length && cum [ segIdx ] < target ) segIdx ++
453+ if ( segIdx >= cum . length ) break
454+ const a = cs [ segIdx - 1 ]
455+ const b = cs [ segIdx ]
456+ const segLen = cum [ segIdx ] - cum [ segIdx - 1 ]
457+ const t = segLen > 0 ? ( target - cum [ segIdx - 1 ] ) / segLen : 0
458+ const dx = b [ 0 ] - a [ 0 ]
459+ const dy = b [ 1 ] - a [ 1 ]
460+ if ( dx === 0 && dy === 0 ) continue
461+ const point : [ number , number ] = [ a [ 0 ] + t * dx , a [ 1 ] + t * dy ]
462+ // OL Icon rotation is clockwise from the icon's natural orientation
463+ // (canvas top = north on an unrotated view); atan2(dx, dy) gives the
464+ // clockwise angle from north for a (dx, dy) step in projected coords.
465+ const rotation = Math . atan2 ( dx , dy )
466+ const haloFeature = new Feature ( { geometry : new Point ( point ) } )
467+ haloFeature . setStyle (
468+ new Style ( {
469+ image : new Icon ( {
470+ img : arrowHaloImg ,
471+ anchor : [ 0.5 , 0.5 ] ,
472+ rotation,
473+ rotateWithView : true ,
474+ scale : arrowIconScale ,
475+ } ) ,
476+ } )
477+ )
478+ const innerFeature = new Feature ( { geometry : new Point ( point ) } )
479+ innerFeature . setStyle (
480+ new Style ( {
481+ image : new Icon ( {
482+ img : arrowInnerImg ,
483+ anchor : [ 0.5 , 0.5 ] ,
484+ rotation,
485+ rotateWithView : true ,
486+ scale : arrowIconScale ,
487+ } ) ,
488+ } )
489+ )
490+ haloFeatures . push ( haloFeature )
491+ innerFeatures . push ( innerFeature )
492+ }
493+ arrowHaloSource . addFeatures ( haloFeatures )
494+ arrowInnerSource . addFeatures ( innerFeatures )
495+ }
496+
497+ rebuildArrows ( )
498+ map . on ( "moveend" , rebuildArrows )
499+
322500 return ( ) => {
501+ map . un ( "moveend" , rebuildArrows )
323502 map . setTarget ( undefined )
324503 mapInstance . current = null
325504 markerFeature . current = null
0 commit comments