Skip to content

Commit 4e4307d

Browse files
committed
frontend: overlay arrows on tracks
1 parent 2c51e61 commit 4e4307d

1 file changed

Lines changed: 188 additions & 9 deletions

File tree

frontend/src/components/TrackMap.tsx

Lines changed: 188 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
87165
interface 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

Comments
 (0)