<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Orofarne</title>
    <description>The latest articles on DEV Community by Orofarne (@orofarne).</description>
    <link>https://dev.to/orofarne</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3973851%2F35423609-43f6-4563-ba9c-70e1114538d2.jpg</url>
      <title>DEV Community: Orofarne</title>
      <link>https://dev.to/orofarne</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/orofarne"/>
    <language>en</language>
    <item>
      <title>Scenic Routing Algorithm: How LLMs Plan Walks via Heatmaps and Custom Valhalla Costing</title>
      <dc:creator>Orofarne</dc:creator>
      <pubDate>Mon, 08 Jun 2026 10:05:40 +0000</pubDate>
      <link>https://dev.to/orofarne/scenic-routing-algorithm-how-llms-plan-walks-via-heatmaps-and-custom-valhalla-costing-ee2</link>
      <guid>https://dev.to/orofarne/scenic-routing-algorithm-how-llms-plan-walks-via-heatmaps-and-custom-valhalla-costing-ee2</guid>
      <description>&lt;p&gt;This document describes the algorithm behind PoC &lt;a href="https://github.com/orofarne/scenic-routing-mcp" rel="noopener noreferrer"&gt;scenic-routing-mcp&lt;/a&gt; — &lt;br&gt;
an open-source MCP server that lets LLMs plan pedestrian routes from natural-language requests using OpenStreetMap data.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Routing Approaches
&lt;/h2&gt;

&lt;p&gt;Four approaches in increasing order of complexity.&lt;/p&gt;
&lt;h3&gt;
  
  
  1.1 Plain Shortest Path
&lt;/h3&gt;

&lt;p&gt;Standard Dijkstra / A* minimising edge cost with no awareness of surroundings.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F846b72edeejzqe2gep65.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F846b72edeejzqe2gep65.png" alt="Baseline: Yerevan, Hrazdan river area" width="569" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1g3ntuzta2hla4uro4kv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1g3ntuzta2hla4uro4kv.png" alt="Baseline: London, Richmond → Battersea" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; fast, reliable.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; routes follow busy roads, ignoring riverside paths and parks.&lt;/p&gt;
&lt;h3&gt;
  
  
  1.2 Intermediate Waypoints
&lt;/h3&gt;

&lt;p&gt;Idea: select a set of scenic features, build a graph over them, find the best path through the graph, then re-route through the chosen nodes via Valhalla to get a real road route.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F14h13sj5nmbnyqxv16j0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F14h13sj5nmbnyqxv16j0.png" alt="Waypoints: Yerevan — orange circles = candidate peaks; filled = used" width="569" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb369e042a18985ez7wj0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb369e042a18985ez7wj0.png" alt="Waypoints: London — peaks along the Thames corridor" width="800" height="452"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Start ──→ wp1 ──→ wp2 ──→ wp3 ──→ End
          (park  (river  (park
          edge)  bank)   edge)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Feature query&lt;/strong&gt; — query features within a search corridor around the A→B line: vector similarity, tag filters, fuzzy name search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clustering&lt;/strong&gt; — group nearby features; each cluster becomes a candidate node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graph construction&lt;/strong&gt; — build a graph over the candidate nodes; edges represent travel between them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge weighting&lt;/strong&gt; — use Valhalla's time/distance matrix to weight edges with real pedestrian travel times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graph pruning&lt;/strong&gt; — reduce to a sparse graph (e.g. k-nearest-neighbours) to keep routing tractable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Path selection&lt;/strong&gt; — find the route through the graph that maximises scenic coverage within the detour budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Road re-routing&lt;/strong&gt; — pass the selected nodes as forced waypoints to Valhalla to obtain a real on-road route.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; route is guaranteed to pass through specific chosen features; works well when the goal is to visit a discrete set of POI.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Many Valhalla calls needed for edge weighting (one matrix call covers many pairs, but the graph can still be large).&lt;/li&gt;
&lt;li&gt;Clustering and graph topology add significant implementation complexity.&lt;/li&gt;
&lt;li&gt;Path selection in a weighted graph with a budget constraint is NP-hard in general; heuristics are required.&lt;/li&gt;
&lt;li&gt;Works best for discrete POI (museums, landmarks); less natural for continuous scenic corridors like a riverbank or a park edge.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  1.3 Heat Map
&lt;/h3&gt;

&lt;p&gt;Idea: build a continuous heat map over POI in the search area and feed it to Valhalla as an edge cost signal. Edges near high-heat zones cost less, so the router naturally gravitates toward scenic features without explicit waypoints.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzllxvrkrz38gcxir47nm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzllxvrkrz38gcxir47nm.png" alt="Heat map: Yerevan — gray = baseline route, heatmap shows Hrazdan gorge and parks" width="569" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5px2youd5aeifsj8zxg7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5px2youd5aeifsj8zxg7.png" alt="Heat map: London — gray = baseline route, heatmap along the Thames" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Compute heatmap over all POI in the bbox and feed it to Valhalla as &lt;code&gt;scenic_pedestrian&lt;/code&gt;.&lt;br&gt;&lt;br&gt;
The router finds the cheapest path where edges near high-heat zones cost less.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; POI-query semantics are fully captured; single Valhalla call.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; if the hot zone lies off any pedestrian path within the cutoff radius, the router ignores it — the discount changes edge costs but not graph topology.&lt;/p&gt;
&lt;h3&gt;
  
  
  1.4 Current: Combined Approach
&lt;/h3&gt;

&lt;p&gt;Heat map + route quality score + explicit peak waypoints when needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz7mkzh90uu89gnq7q7ha.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz7mkzh90uu89gnq7q7ha.png" alt="Combined: Yerevan — heatmap + scenic route + orange waypoint peaks" width="569" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxjv439t5ism22vplno7f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxjv439t5ism22vplno7f.png" alt="Combined: London — heatmap + scenic route following the Thames" width="800" height="452"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                ┌─ heatmap.Compute(POI, bbox) ───────────────┐
                │                                            │
Plan() ─→ baseRoute  ─→  val.RouteScenic(heatmap) ─→ scoreRoute()
                                   │
                          score ≥ routeHeatThreshold?
                          ┌── YES → return scenic route
                          └── NO  → heatGrid.Peaks(10) → TSP → RouteScenic(peaks + heatmap)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Baseline&lt;/strong&gt; — plain &lt;code&gt;pedestrian&lt;/code&gt; A→B for bbox computation, speed estimate, and length comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heat map&lt;/strong&gt; — &lt;code&gt;heatmap.Compute(features, bbox)&lt;/code&gt; builds a 50 m/cell grid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scenic route&lt;/strong&gt; — &lt;code&gt;RouteScenic&lt;/code&gt; with the heat map and no explicit waypoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality score&lt;/strong&gt; — &lt;code&gt;ScoreRoute&lt;/code&gt;: average normalised heat along the route sampled every 50 m.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Peaks&lt;/strong&gt; — if &lt;code&gt;score &amp;lt; min_heat_score&lt;/code&gt; (default 0.4, API-configurable), compute &lt;code&gt;heatGrid.Peaks(10)&lt;/code&gt; and route through the subset with maximum total heat (TSP bitmask DP).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Iterations&lt;/strong&gt; — try [3, 6, 10] peaks; stop when &lt;code&gt;score ≥ min_heat_score&lt;/code&gt; or peaks exhausted.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  2. Scenic Routing in Valhalla: Custom &lt;code&gt;scenic_pedestrian&lt;/code&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  2.1 Why a Custom Costing
&lt;/h3&gt;

&lt;p&gt;Valhalla has no built-in support for per-request raster edge weights.&lt;br&gt;&lt;br&gt;
Workarounds include: modifying tiles on the fly, an external scoring service, or a custom costing.&lt;br&gt;&lt;br&gt;
Custom costing was chosen — it requires no tile storage changes and works in a single HTTP request.&lt;/p&gt;
&lt;h3&gt;
  
  
  2.2 Patch Structure
&lt;/h3&gt;

&lt;p&gt;Patch: &lt;code&gt;infra/valhalla-base/patches/scenic_pedestrian.patch&lt;/code&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;proto/descriptors/options.proto&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;new type &lt;code&gt;Costing::scenic_pedestrian = 13&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/proto_conversions.cc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;string name &lt;code&gt;"scenic_pedestrian"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/sif/pedestriancost.cc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ScenicPedestrianCost&lt;/code&gt; class + &lt;code&gt;ParseScenicPedestrianCostOptions&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;valhalla/sif/scenic_pedestriancost.h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;declarations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;valhalla/sif/costfactory.h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;register &lt;code&gt;CreateScenicPedestrianCost&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;valhalla/sif/dynamiccost.h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;map type to itself (not expanded into sub-costs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/thor/worker.cc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;max distance for the new type (7200 s, same as pedestrian)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/sif/dynamiccost.cc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;dispatch in &lt;code&gt;ParseCosting&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  2.3 Passing the Heat Map Between Loki and Thor
&lt;/h3&gt;

&lt;p&gt;Valhalla's pipeline: &lt;strong&gt;Loki&lt;/strong&gt; (parses the request) → &lt;strong&gt;Thor&lt;/strong&gt; (runs A*).&lt;br&gt;&lt;br&gt;
They run in separate threads and communicate via a protobuf-serialised &lt;code&gt;Costing&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; the heat map is a large byte array; &lt;code&gt;Costing&lt;/code&gt; has no field for it.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Solution:&lt;/strong&gt; pack everything into &lt;code&gt;Costing.name&lt;/code&gt; using ASCII unit-separator &lt;code&gt;\x1F&lt;/code&gt; (never appears in base64):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"scenic_pedestrian\x1F&amp;lt;base64_data&amp;gt;\x1F&amp;lt;min_lat&amp;gt;\x1F&amp;lt;min_lon&amp;gt;\x1F&amp;lt;max_lat&amp;gt;\x1F&amp;lt;max_lon&amp;gt;\x1F&amp;lt;width&amp;gt;\x1F&amp;lt;height&amp;gt;\x1F&amp;lt;weight&amp;gt;"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ParseScenicPedestrianCostOptions&lt;/code&gt; (Loki) packs.&lt;br&gt;&lt;br&gt;
&lt;code&gt;ScenicPedestrianCost::loadFromName&lt;/code&gt; (Thor) unpacks.&lt;/p&gt;
&lt;h3&gt;
  
  
  2.4 Edge Cost Discount Formula
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;Cost&lt;/span&gt; &lt;span class="nf"&gt;EdgeCost&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Cost&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PedestrianCost&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;EdgeCost&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;   &lt;span class="c1"&gt;// standard pedestrian cost&lt;/span&gt;

    &lt;span class="c1"&gt;// Sample the heat map at every shape point of the edge, take the mean.&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ll&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;edgeinfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;sampleHeatmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ll&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;ll&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;/=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// mean ∈ [0, 1]&lt;/span&gt;

    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;scenic_weight_&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Cost&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;secs&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;  &lt;span class="c1"&gt;// secs unchanged&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Key:&lt;/strong&gt; &lt;code&gt;secs&lt;/code&gt; (actual walking time) is untouched — only &lt;code&gt;cost&lt;/code&gt; (the A* virtual cost) is modified.&lt;br&gt;&lt;br&gt;
With &lt;code&gt;scenic_weight = 1.0&lt;/code&gt; and &lt;code&gt;h = 1.0&lt;/code&gt; the edge costs 10× less (&lt;code&gt;factor = 0.1&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;heat:   0.00   0.25   0.50   0.75   1.00
factor: 1.00   0.75   0.50   0.25   0.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2.5 Heat Map Sampling
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nf"&gt;sampleHeatmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lon&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;min_lon_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_lon_&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;min_lon_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;width_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_lat_&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_lat_&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;min_lat_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;height_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;heatmap_&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;width_&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;255.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// uint8 → [0, 1]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nearest-neighbour (no interpolation) — sufficient for 50 m grid cells and edge shape point spacing of ~10–20 m.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Heat Map Construction
&lt;/h2&gt;

&lt;h3&gt;
  
  
  3.1 Typical Heat Map Dimensions
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cell size&lt;/td&gt;
&lt;td&gt;50 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BBox buffer&lt;/td&gt;
&lt;td&gt;1500 m on each side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 km route, Δlat≈5 km, Δlon≈8 km&lt;/td&gt;
&lt;td&gt;grid ≈ 220 × 140 cells&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;uint8 byte size&lt;/td&gt;
&lt;td&gt;~31 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After base64&lt;/td&gt;
&lt;td&gt;~41 KB in JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  3.2 Input Data
&lt;/h3&gt;

&lt;p&gt;Each &lt;code&gt;Feature&lt;/code&gt; has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Similarity float64&lt;/code&gt; — relevance score ∈ &lt;a href="https://dev.tocosine,%20tag,%20name,%20or%20combined"&gt;0, 1&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Geom []byte&lt;/code&gt; — GeoJSON geometry from PostGIS (Point / LineString / Polygon / Multi…)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3.3 Kernel Formula
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;heat(cell) = max_i( sim_i^4 × (1 − d_i / cutoff)² )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sim_i^4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;feature weight; sharpens contrast — sim=0.9 → w=0.66, sim=0.5 → w=0.06&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(1 − d/cutoff)²&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;quadratic spline; 1 at d=0, 0 at d=cutoff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cutoff = 3σ = 450 m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;with σ=150 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the nearest significant feature wins; dense clusters do not accumulate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Grid initialised to &lt;code&gt;−∞&lt;/code&gt; (log-sum-exp identity), updated via &lt;code&gt;math.Max&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.4 Geometry Distance
&lt;/h3&gt;

&lt;p&gt;Distance &lt;code&gt;d&lt;/code&gt; to the &lt;strong&gt;nearest point&lt;/strong&gt; on the geometry (&lt;code&gt;distM&lt;/code&gt; method on each geometry type):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;d&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Point&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Euclidean to the point&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LineString&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;minimum over segments (projection onto segment)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Polygon&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 if inside the ring (ray casting), otherwise to the boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MultiPolygon&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;minimum over parts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GeometryCollection&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;minimum over components&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;σ is the same for all geometry types — equal relevance means equal influence radius regardless of object size.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why not σ = f(area)?&lt;/strong&gt; With σ proportional to size, a small pond (high sim) would heat the same area as a small square, while a large park like Hyde Park would dominate half the map. A fixed σ=150 m gives an intuitive "pedestrian reach radius".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3.5 Kernel Formula Comparison
&lt;/h3&gt;

&lt;p&gt;The images below are generated by &lt;code&gt;docs/gen/kernel_compare_test.go&lt;/code&gt;. The aggregation comparison uses a synthetic scene; the kernel shape and zone-of-influence comparisons use real OSM geometries (Richmond / Kew, London). © OpenStreetMap contributors, &lt;a href="https://www.openstreetmap.org/copyright" rel="noopener noreferrer"&gt;ODbL&lt;/a&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Aggregation: max vs sum
&lt;/h4&gt;

&lt;p&gt;Scene: a river (LineString, sim=0.80) plus a &lt;strong&gt;tight cluster&lt;/strong&gt; of 9 points within a ~250 m radius (sim=0.65 each) and 6 &lt;strong&gt;sparse&lt;/strong&gt; points of the same sim spaced 2 km+ apart.&lt;/p&gt;

&lt;p&gt;Left: &lt;strong&gt;max&lt;/strong&gt; (current). Right: &lt;strong&gt;sum&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftt01fzabmo6mpibecdow.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftt01fzabmo6mpibecdow.png" alt="max vs sum" width="600" height="298"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;max&lt;/strong&gt;, every cell is won by the single best nearby feature. The tight cluster and each sparse point all peak at 0.65⁴ ≈ 0.18 — they look identical.&lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;sum&lt;/strong&gt;, contributions from all 9 overlapping cluster points accumulate at the cluster centre to ~1.4, exceeding the river's 0.80⁴ ≈ 0.41 and becoming the image ceiling. The cluster turns into the dominant red hot-spot; sparse points stay small.&lt;/p&gt;

&lt;p&gt;This is the core failure mode of sum aggregation: a dense group of medium-quality urban POI (cafés, benches, bollards) outcompetes a high-quality scenic feature by sheer quantity. &lt;strong&gt;max&lt;/strong&gt; avoids this — density does not inflate the score.&lt;/p&gt;

&lt;h4&gt;
  
  
  Kernel Shape: quadratic vs linear vs gaussian
&lt;/h4&gt;

&lt;p&gt;Scene: Richmond / Thames area — Thames centreline (OSM way 26456375, sim=0.95), parks (sim=0.80), viewpoints (sim=0.75), cafés/pubs (sim=0.55). Major roads overlaid in dark grey; footpaths in light grey. σ=150 m (production default), max aggregation, per-variant ceiling.&lt;br&gt;
© OpenStreetMap contributors, &lt;a href="https://www.openstreetmap.org/copyright" rel="noopener noreferrer"&gt;ODbL&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Left: &lt;strong&gt;quadratic&lt;/strong&gt; (current). Middle: &lt;strong&gt;linear&lt;/strong&gt;. Right: &lt;strong&gt;gaussian&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fadgagedbd4j1ubsdtudb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fadgagedbd4j1ubsdtudb.png" alt="kernel shapes" width="800" height="258"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At d = ½ × cutoff = 225 m from the feature edge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;linear&lt;/strong&gt;: 1 − 0.5 = 0.50 → widest halo, most gradual fade to white&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gaussian&lt;/strong&gt;: exp(−(1.5)²/2) ≈ 0.32 → moderate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;quadratic&lt;/strong&gt;: (1 − 0.5)² = 0.25 → narrowest, sharpest edge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Looking at the right cold (white) area where residential streets sit far from the river and parks: with linear, those streets gain more residual warmth from distant features; with quadratic the transition to cold is sharper. The difference is subtle at σ=150 m — it becomes more pronounced at larger σ.&lt;/p&gt;

&lt;p&gt;Quadratic was chosen over linear because a wider halo biases the router toward every cell within 450 m equally, losing spatial focus. Quadratic concentrates the pull closer to the feature. Gaussian requires the same hard cutoff at 3σ with no practical advantage.&lt;/p&gt;
&lt;h4&gt;
  
  
  Zone of Influence: σ=50 m vs σ=150 m vs σ=400 m
&lt;/h4&gt;

&lt;p&gt;Same Richmond / Thames scene, quadratic kernel, max aggregation, per-variant ceiling. Roads and footpaths overlaid to show which streets fall inside vs outside the warm zone.&lt;br&gt;
© OpenStreetMap contributors, &lt;a href="https://www.openstreetmap.org/copyright" rel="noopener noreferrer"&gt;ODbL&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Left: &lt;strong&gt;σ=50 m&lt;/strong&gt; (cutoff 150 m). Middle: &lt;strong&gt;σ=150 m&lt;/strong&gt; (production default, cutoff 450 m). Right: &lt;strong&gt;σ=400 m&lt;/strong&gt; (cutoff 1200 m).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiqzqxte5v3xai6bf3q31.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiqzqxte5v3xai6bf3q31.png" alt="sigma comparison" width="800" height="258"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;σ=50 m&lt;/strong&gt;: almost no influence beyond the feature boundary. The Thames is a narrow red ribbon; the park polygon has a near-hard edge; streets 100 m from the river are already cold (white). Only the Thames Path footway running directly alongside the river appears on a warm background — every other road in the frame is on white.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;σ=150 m&lt;/strong&gt;: production default. Streets within ~1 block of the Thames or park edges get a visible warm boost. Roads in the residential area to the right remain cold; paths through the park interior are on warm background. This gives the router a focused, spatially meaningful preference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;σ=400 m&lt;/strong&gt;: too broad. Most of the map is orange or warm; only the far corners remain cool. The router would favour almost any path in the area regardless of its actual proximity to scenic features — routing loses spatial selectivity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;σ=150 m was chosen so that features within ~3 min walk (≈450 m) influence the route; beyond cutoff they do not.&lt;/p&gt;
&lt;h4&gt;
  
  
  Similarity Weight: sim¹ vs sim² vs sim⁴
&lt;/h4&gt;

&lt;p&gt;Same Richmond / Thames scene as above. Roads and footpaths overlaid.&lt;br&gt;
© OpenStreetMap contributors, &lt;a href="https://www.openstreetmap.org/copyright" rel="noopener noreferrer"&gt;ODbL&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Left: &lt;strong&gt;sim¹&lt;/strong&gt;. Middle: &lt;strong&gt;sim²&lt;/strong&gt;. Right: &lt;strong&gt;sim⁴&lt;/strong&gt; (current).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fft58hhfjspf5x0pykxo3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fft58hhfjspf5x0pykxo3.png" alt="similarity weight" width="800" height="258"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Park (sim=0.80) vs Thames (sim=0.95), normalised brightness ratio:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;sim¹&lt;/strong&gt;: 0.80 / 0.95 = 84% — parks nearly as warm as the river; cafés (sim=0.55) at 58% → most streets in the area sit on a warm background&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sim²&lt;/strong&gt;: 0.64 / 0.90 = 71% — river is visibly brighter than parks; cafés at 34%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sim⁴&lt;/strong&gt;: 0.41 / 0.81 = 51% — river clearly dominates; cafés (0.09 / 0.81 = 11%) barely register; the residential streets to the right are noticeably cooler&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;sim⁴&lt;/strong&gt; gives the router a strong, focused preference for the most relevant features. A café with sim=0.55 contributes only 11% of the Thames weight — it should not pull the route away from the riverside path.&lt;/p&gt;
&lt;h3&gt;
  
  
  3.6 Normalisation Before Passing to Valhalla
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Grid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// 95th-percentile ceiling over non-zero values&lt;/span&gt;
    &lt;span class="n"&gt;ceiling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nonzero&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nonzero&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;

    &lt;span class="c"&gt;// Normalise to uint8&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;ceiling&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The 95th-percentile ceiling prevents a single outlier (very popular landmark) from suppressing all other features to near-zero.&lt;/p&gt;

&lt;p&gt;For PNG rendering (&lt;code&gt;HeatmapPNG&lt;/code&gt;) gamma correction &lt;code&gt;t^0.4&lt;/code&gt; additionally compresses the dynamic range visually — this does not affect Valhalla.&lt;/p&gt;
&lt;h3&gt;
  
  
  3.7 Colour Scale
&lt;/h3&gt;

&lt;p&gt;The illustrations in this document use a blue→cyan→yellow→red ramp, normalised to the 95th-percentile ceiling with gamma 0.4 applied before colour mapping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t = 0.00       → white (cold, no heat)
t = 0–0.33     → white → blue  (R=0,   G=0→255, B=255)
t = 0.33–0.66  → blue → yellow through cyan
t = 0.66–1.00  → yellow → red  (R=255, G=255→0, B=0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each colour is blended over a white background at opacity &lt;code&gt;t × 0.86&lt;/code&gt;, so the ramp never fully occludes the background and mid-range values remain distinguishable.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Peaks and Route Extension
&lt;/h2&gt;

&lt;p&gt;When heat-map discounts alone do not steer the route through hot zones (for example, a riverside path exists but lies 500 m from the river itself, outside the discount cutoff), a second level kicks in: explicit peak waypoints.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.1 Route Quality Score (&lt;code&gt;ScoreRoute&lt;/code&gt;)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Grid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ScoreRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coords&lt;/span&gt; &lt;span class="p"&gt;[][&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// Sample every 50 m; normalise to 95th-percentile ceiling&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;mean_heat&lt;/span&gt;  &lt;span class="c"&gt;// ∈ [0, 1]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;min_heat_score&lt;/code&gt; (default &lt;code&gt;0.4&lt;/code&gt;, API-configurable via &lt;code&gt;Params.MinHeatScore&lt;/code&gt;) — if the mean heat along the route is below this threshold, the router is considered to have missed the hot zones and explicit peak waypoints are added.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.2 Peak Detection (&lt;code&gt;Peaks&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — Gini coefficient&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Gini coefficient measures statistical inequality in a distribution: 0 means every element has the same value (perfect equality), 1 means one element holds all the value. It was originally defined for income distributions — the same property that makes it useful there makes it useful here.&lt;/p&gt;

&lt;p&gt;The heat map cells are the "population". A &lt;strong&gt;low Gini&lt;/strong&gt; means heat is spread evenly — like a city where parks are everywhere and every cell has roughly the same warmth. A &lt;strong&gt;high Gini&lt;/strong&gt; means heat is concentrated in a small fraction of cells — like a single river corridor flanked by cold city blocks.&lt;/p&gt;

&lt;p&gt;That distinction is exactly what we need: peaks are only worth computing when there is a clear concentration to navigate toward. When heat is diffuse, the router is already incentivised uniformly and explicit waypoints add nothing.&lt;/p&gt;

&lt;p&gt;Gini is computed over &lt;strong&gt;all&lt;/strong&gt; cells, including zeros (cold cells). This is critical: a river covering 5% of the grid and cold everywhere else gives Gini ≈ 0.90, because the vast majority of cells have value 0 — maximum "inequality". The same river without the zero cells (computed only over non-zero values) would give a much lower score and miss the point.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;G = (2·Σᵢ(i+1)·vᵢ − (n+1)·Σvᵢ) / (n·Σvᵢ),  values sorted ascending
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Concrete examples for the 11 km × 11 km London bbox:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scene&lt;/th&gt;
&lt;th&gt;Gini&lt;/th&gt;
&lt;th&gt;Conclusion&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Thames corridor only&lt;/td&gt;
&lt;td&gt;≈ 0.85&lt;/td&gt;
&lt;td&gt;concentrated → peaks useful&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parks distributed across the whole area&lt;/td&gt;
&lt;td&gt;≈ 0.40&lt;/td&gt;
&lt;td&gt;diffuse → peaks not needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mixed: river + scattered cafés&lt;/td&gt;
&lt;td&gt;≈ 0.60&lt;/td&gt;
&lt;td&gt;concentrated enough → try peaks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Threshold &lt;code&gt;peakFlatGini = 0.50&lt;/code&gt;: if Gini &amp;lt; 0.50, &lt;code&gt;Peaks()&lt;/code&gt; returns nil.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — Hot-cell threshold&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nonzero&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nonzero&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;peakHotFrac&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;  &lt;span class="c"&gt;// peakHotFrac = 0.75&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Top 25% of non-zero cells are considered "hot".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — BFS components&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;8-connected flood-fill over hot cells → list of connected components.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4 — PCA + bucket split&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For each component:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Weighted centroid in metric coordinates.&lt;/li&gt;
&lt;li&gt;Covariance matrix → principal eigenvector (2×2 closed-form).&lt;/li&gt;
&lt;li&gt;Project all cells onto the principal axis → range &lt;code&gt;extent&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;k = round(extent / peakSpacingM)&lt;/code&gt;, &lt;code&gt;peakSpacingM = 500 m&lt;/code&gt;, &lt;code&gt;peakMaxPerComp = 4&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Within each bucket — &lt;strong&gt;two passes&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pass 1: weighted centroid of the bucket.&lt;/li&gt;
&lt;li&gt;Pass 2: for each cell &lt;code&gt;score = heat × exp(−dist²/2σ²)&lt;/code&gt; where &lt;code&gt;dist&lt;/code&gt; is distance to the centroid — pick the cell with maximum score.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This prevents boundary artefacts: the peak is chosen where heat is high &lt;strong&gt;and&lt;/strong&gt; near the bucket centre.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fymqtiwng9j0zye4j4tkc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fymqtiwng9j0zye4j4tkc.png" alt="Peaks algorithm: elongated hot component split into 3 buckets along the principal axis; one yellow circle marks the peak in each bucket" width="520" height="200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The double-headed arrow shows the component &lt;strong&gt;extent&lt;/strong&gt; along the principal PCA axis. Dashed lines are bucket boundaries; yellow circles are the selected peaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5 — Minimum-separation filter&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sort candidates by heat; greedy suppression with &lt;code&gt;peakMinSepM = 300 m&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.3 TSP: Choosing the Optimal Peak Subset
&lt;/h3&gt;

&lt;p&gt;Goal: choose a subset of found peaks that maximises total heat and fits within &lt;code&gt;budgetS&lt;/code&gt; seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;budgetS = maxDetourRatio × haversine(Start, End) × speed_s_per_km
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Algorithm: bitmask DP (Held–Karp), &lt;code&gt;n ≤ 10&lt;/code&gt;, O(n² × 2ⁿ):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// matrix layout: index 0 = Start, 1 = End, 2..n+1 = peaks&lt;/span&gt;
&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;min&lt;/span&gt; &lt;span class="n"&gt;travel&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;reach&lt;/span&gt; &lt;span class="n"&gt;peak&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;visited&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After solving the shortest path for each subset, select the feasible subset (≤ budgetS including the final leg to End) with maximum heat sum.&lt;/p&gt;

&lt;p&gt;Visiting order is recovered by tracing back through &lt;code&gt;prev[mask][i]&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.4 Iteration Strategy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nPeaks&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;peakIterCounts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peaks&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  &lt;span class="c"&gt;// [3, 6, 10]&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tspPeakOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nPeaks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;budgetS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;heats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;nPeaks&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RouteScenic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;heatGrid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costingOpts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;heatGrid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScoreRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;routeHeatThreshold&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;peakIterCounts(10)&lt;/code&gt; → &lt;code&gt;[3, 6, 10]&lt;/code&gt;: start with fewer peaks (shorter, faster), add more if the score threshold is not met.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.5 Why Peaks Are Needed
&lt;/h3&gt;

&lt;p&gt;Heat-map discounts make edges cheaper but do not change graph topology.&lt;br&gt;&lt;br&gt;
If no pedestrian paths exist near the river within the cutoff radius (450 m), the discount has no effect — A* simply takes the parallel street.&lt;/p&gt;

&lt;p&gt;Explicit peak waypoints force Valhalla to visit specific coordinates. The combination: the peak provides the direction, the heat-map discount pulls the approach path toward the riverside.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Without peaks:              With peak:
Start ─── street ─── End   Start → riverside → Peak → riverside → End
       (river 600 m away)          (heat map attracts the approach)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;Repository: &lt;a href="https://github.com/orofarne/scenic-routing-mcp" rel="noopener noreferrer"&gt;github.com/orofarne/scenic-routing-mcp&lt;/a&gt;&lt;/p&gt;

</description>
      <category>openstreetmap</category>
      <category>algorithms</category>
      <category>go</category>
      <category>gis</category>
    </item>
  </channel>
</rss>
