WME Junction Angle Info

Show the angle between two connected (or selected) segments — experimental branch adding Junction Box and Path (far turn) support

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

You will need to install an extension such as Tampermonkey to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name          WME Junction Angle Info
// @description   Show the angle between two connected (or selected) segments — experimental branch adding Junction Box and Path (far turn) support
// @namespace     https://greasyfork.org/en/users/166843-wazedev
// @match         *://*.waze.com/*editor*
// @exclude       *://*.waze.com/user/editor*
// @exclude       *://*.waze.com/editor/sdk/*
// @version       3.2.2
// @grant         GM_xmlhttpRequest
// @grant         GM_info
// @connect       greasyfork.org
// @namespace     https://greasyfork.org/scripts/35547-wme-junction-angle-info/
// @require       https://greasyfork.org/scripts/24851-wazewrap/code/WazeWrap.js
// @require       https://update.greasyfork.org/scripts/509664/WME%20Utils%20-%20Bootstrap.js
// @require       https://cdn.jsdelivr.net/npm/@turf/turf@7/turf.min.js
// @author        WazeDev / JS55CT
// @copyright     2026 JS55CT, 2018 seb-d59, 2016 Michael Wikberg <[email protected]>
// @license       CC-BY-NC-SA
// @icon          data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAA6pSURBVFhHrZkJcBNnlscbgjEZmHAYsHVZUrek7pbUuo9W674l65Z8WzbGxoAxhzNgcweMDeY0GLDHwYBhGCABTEwIOSaTZBIyye6mcjBJFTs7O5Xazc4ks7NbU7vJzjKVrc0+RVqwhWEC5NW/VFKr3//79fe+fv3JRrp2bjs00NfW3gqqSldojCqFVk4q8LK4z+a2kEosVRVIVnrL60KMU602SfnaohIaQR0zb/72428eNr7+n6/r14bBh2+ahaJCDMOcWk9ndOuFFdeQlrbF4USZ0aqlNDJKS8YrovXNNSa7PlEVSTdVK3REMGIDcbEiuU5c3hDhmgvA6PV3X8p5P2x89ecv6XIRWCX94bFVP//l+l9lhYikpTB8MOauW1y1pqN13ab26oZkrCLo9JuBZj77CYfH0LysJt1UXtdcqXALwOJHu5bkXB8tRl85C26Mj3xm5eU7QLWN5TB8RV0MXpPVYbVRNuOJqVoT5fAx9c1V3bs2Ny2pYSxqX9AWSvlYpqkc87RPP/tNzvKRo6Y9AEzratrvAD25rrW+sYKQCUQ4F17Ti8pXrlmytLUhFHVX1kTLIq7mltrautSuPTsa1qQgubEznjP7PuKtv3sVPGkPfgeomD1bSqHA1La6OVUZikFJYx54raiOdGxYtXvf9i1b1h3q37dpc6cmiELyqdHBnNn3Ef/+pz9aKgkeM/3UinM5oOp0tLy6rLYh7g9Z043Jhqby9nVLgePg4d4Nm9vXrV99fHhw6OmjiUSshJ4CQGCRM5ssvri+SVeIIPVjuY9jy4UzkUJO6MQn2QOTRNv2NNj2NvbmgKJJD3DU1MfWrm/t3Lh6X1/34NN9q59cfnxk8NrLzw2f/PGh/v1LW5pNNgOk6ROCnM0k8dX7R30LkMLCO0CvrZqr3Hnjq18fNBc2X/v2yCSx//h2cG6vbhtte2VjfBuycduT+/p3Dp8e6N6zdf/h3U+PDJw+f2LkpyeODPXv6O3auHlDV1fXzu5doZQf0sItTM7m7rh1Yw9DrXrx59tlDwY0cmkAnAk9B7oRiqJI74Fth4f2n3n2xPnRM8dODe7p6926Y/OmbRuf6t7avbtn995eiKOHjyRqM32sYpUnZ3PPGKtHJinZ0I1b2SN3xzMvjICzxFxSWV0bjsWRsRcvDB4/1LmlfV9/b++B7u7dXb37d/YdOTBw7GjXzq66dK1erwNwIcGBtNgya87mnjEB6LvET54bAmeucjZBEC6vC9nc1bH3UM/A8MHdfT19R/cOHDuyrfupqtoqlVoFHBKRJGKKd5XvOb70NKTRKSxnc894YKCDJ7vBOdrkunDtwtU3riKwaAaG+3fu69q0bX2yIiGn5MCBi/GkpbK36uArT/4yu/jf6vwQ0kDQ8nNOk8cDA0HfB9vOvauA5tyVc8ja9e2hSJCUksAhxWVVtvT+2oHX1v5tlmO8QiEaMs9dPZFzmjweDOjL//pPV1oJtn0ndw+eGjRbzQhwUISi3tl0uH74zY738yBA729+++aOK5/tOdbTnFnX7T2Lc2bfR7z2zovgaamWXn3jeZihTMmGGk9f7/wwD+Kd9Tfe33L91zsu/8ueoc/3Hfl0108+3vbK6JpRSObbZvzh3z7P+T1yZLti88aqLM2lly8heRwfbHnzH7ov/m7vIHD8dufZG0+9+u6GO7h18QTkb+lbk/N7tHjpzTHUOVMV4WzcvW7V2lXegBfH8QzQOxs++mjr67/peeb3e4+C4A18hIO3OUDnV17e33RgUbyGRU+BB/5HN9/LuT5swB7N36iDy0M1C3GSwHCCLUAFuBT5x56fAgRMCUwMTE8ex9m2i0srFslsJZA5XvLgwt//4bOc90NF9ubi0oWJdHm0psIdCetsdlSuQG52jX6w5S0o1ngO0KXVL3h9ptsEQh/X0JK2rml3djyFBTKPfUet/KEX08GRTO+Bzacn5YzVJi1+pycW8kQjGKVA3uqYsKKh3wwuGrHbjFCaTI51pn3thvTJD1de++a2lo3+SRwi4Ft1lPugO+tbf/nv7EJmmaaYI/pITSxYERLIMEIrswXdhEaNvNnxwdvrf/XGuvf608fSjkY5LseM8yEBZGpdsuTZz8ej3BYcl1dY4BzUOWvf8HYYJjfgfeNnb1+FeYUsLlMQaQgualvsjnkT9SkOzlsgLFZbdErGgOyp7q+y1WmU2mgi2rml05POLDSubVZ8/8U8iDy1Xf2aaVueRafKSqAK99raQnO/9NKZ5ApH9uRS80yRBtPaDI6wy+y32kN2voxfjBWjCpFISSKta1r7j/Vfee0KtAHoB5DAdxXl1eg+qh66np2qrNz1qjXdjT0DG2DaQLBy4ecOtK7cCcapXNUC2mVRM/qFghI+KXSF3dVNNeUNFaGKsMaiK8F4yPOv51rkU/0dkMNmpsEYeaP+VaUOXaWXNfJduVrfpSks3ePF0rlskUBEybUmo9FqVurVEjmB4pjD6zTQRpKSU1q1wmjIAV1+dVQRYkFyZNdI3mDfXVDE2mPvRnefUdR4wIplKOBQbLaUwxGjbFQkJGU8iZiHYXyxmI+hLB53IZsF0uoMBCljc3gcvkAEt312elZ2LQYLdX3Z8sv/kTfM3Qq54Pn5bbjG8r7KavHZT6VJIxhyKTZPTHAwCQDxxDhLiLH4fI5QCFilIhFKECSlWMAqQSViMSmFj6hUnpsh3DsX8mHm86wn19bME/0+QCCYaTBk62cKSbmAkPFxKRsVA5ZATEjkclKpxCkKXuUajRAXYyRezOPNYcGqkiK7+naFqjMzTCZ0rVf+nOc7ibI090bJquXiv4qCYhb9mEAuwaQqTKYQyZWYjIKiiCmlgCSKBfx5HHYRl0MZtNaAh3bDvYYvEPIRq91qDEoBCLpwnukk+m40WUEbA1sOVSLAKZRUimTq7GwJCClXLCoWCtgYypdKZhUXKRiDJeDBDeo5fC5SUZcUWed8p3o9CA0otvc82LJUs3kiOV8iZ6F4CSriiPFSguSTBF9GElqlykIrbUa1w6SwGnReq7s8ijQtTWMAZETqT97Ic5yoTxjhA9CAqgbeyABpZgJQKa4oFohLhBIA4pEkWyJaIOQtREtZhLBUKSYsaqlNixooFiVCevdu5zM/YDPToep5jo+o+pGPAahYOx1oUJmGK5bO4wkXCjGBQsGTk0UojyPFFHa9yscovbS9IiCiKVPEhfzi0knYA7BN09te+N88x0dU09lPMzNkmM6TyIq4wgUCEUdCsAlioRhlERimk5OMCjcrxWaFPmK3lfs1AbOrMoS8fPm0yPoEi57afP53eY6PqPTJjwCIrX9crFQLZVQJJhbIKKGKEmrkhEmp8tBqn5F0qCiv3lEdsKTcujKrMeRABgYPiMxzAKh2+L08R1Dr8Bl0NoII99RmPx4dQktmwsp+bKYYXXmjFQ6eHZPDkQKOYM0n4xNBlUczf2qZT01nYSKBVM4SSeajaClFqJwmfdBKubQyh1IT1Dtr/eZKNx2zE3al1KZB1q5dhluLSoxT4vsuTHS81di1am5B4WMFt4H+3iJGENaK8NM3wyEMQZyOU9/EI3PnN9xYduwgq6A5MiH9m1DPMADxdPPkBiOh0Qopiq+QkbTa4LeaonZj2GSvckZaYp76AO6m6JgVZssUcSJeH62ycuH5Z2ptGW+38toXIRdV2vo3XtttoH92awoRVkdq9KtUCkMKEv5n7wekb64BIKGGI1Yq5nE5M+bPWyDil+CCH/KL5ojnEzaSSTK2Spuz1oOaJZaU0xBikksqkeqacGW1FzJLnUXwdBzv+K2+vdtvlyxbwUxgVM8XmRNulyxbwf/X8ue+ZJsL4TrlBqXKbIQGKFZTKjutchgwPS5mcHPCEmj0+Ro8AKQJ6U1xizluT7VUIen6WCziKKUhedJH/XigWzUtTAGyoMQeLZqBPK4Zqhkbf+YEeTf1Zi7S+EOZnlKZtZiC0NpohVmjsGkNPhMdovUBnc6v1pVpPbVeX11A7zdawvZoXQKprCwL+s0aa+bPq7IU3XTunyZajwN69gy8ndeQ6Z+teztmIYX41lsTT84pfeIDUZkYtlZiIweVCwVSvsai5pOljrCjvLHcVmbBdZjWofQk7I6o1eDRWwIW2DqqGI2IIhCTSUnBwxjnsHXTgAm29BPdxwGNjeEFSKHxWNWpm+XLooVIoXT7+DPvSN9cDVaYZZ7aoqBoqcxA0G69vcwSSHltQbPZR8dqy6I1QbPPYHCqMQoVK0USSoKRYqFYhNC0QqFAlUrMaCdZpql3FW7CGmrq+VHJ7Mxtj8wQoouvN9057Y6yxWKbphl9SpgGkNpGGT3ayuYk7dU5IpZ4fdgeNpt8ekuQphiS1BJCmYCDctgCbikqzMyQTkeYzAq336RyZH5wsZjH/uoO/16CWx3S4cIIhqf3qKVGicpG6V1qg0drcGvcCQeQaRxKwJIzpEgt8KVcRqdBqiMFuICLlgpEGMJY1EaT3O7U+cosZqeWsOZ+pLo6t+cNdn/BHZrdb4BkLoGUxpUOhUDBk5pJ2qc3+PVMwFiq5MFxeK91qpxJR6gmoLDIlGYlAOEKHKdIQi5DnB6T1a5x+ehUTShS4QsmPQoPxmEy/9DQLIrH9+d1y0m04vm/hHeNZPfRHGaGyi8O1AXooJGJMKhWKLfL4b3ao4ZXX43PGDCo3Kr44liyJanzagkTTupxAFLTaqPVpDHoEaffbHVqHR6D0aq2+xlHwGrymmQWMZvOMIHU9WFYVS0X/5jHAWo+/1nZjh/f/hnEYx5Xe2Wxplh0ScyWsDqrXDKb1BA2OssdCo8yvCjkrw8kWxKhxWFr3GKvcNiTNpyWSA0EocEVeoWOMSi1GiReWRZJuGMpL+PQ+WMud8ihcxikeopDlPINJRxmenYwFjNNEpGr4NKXNYKoKoeoLPO/nNy3xqliMwvXYzILCSjQ9OQuiombKbcC2gzw0VET0AQXlbmq3fCtrdwOQJak1ZFyiFQoV8LhYlyUEKES8f8BmIA7Ka4NUW4AAAAASUVORK5CYII=
// ==/UserScript==

/**
 * Copyright 2016 Michael Wikberg <[email protected]>
 * WME Junction Angle Info extension is licensed under a Creative Commons
 * Attribution-NonCommercial-ShareAlike 3.0 Unported License.
 * See README.md for full contributor history.
 *
 * Original author:
 *  2013–2019 Michael Wikberg "milkboy" <[email protected]>
 *            Core logic, architecture, Swedish & Finnish translations
 *
 * Contributions by:
 *  2014 Paweł Pyrczak "tkr85"       WME update compatibility fixes
 *  2014 "AlanOfTheBerg"             WME update compatibility fixes
 *  2014 "berestovskyy"              WME update compatibility fixes
 *  2015 "FZ69617"                   Best-continuation (BC) logic fixes
 *  2015 "wlodek76"                  Contributions
 *  2016 Sergey Kuznetsov "WazeRus"  Russian translation
 *  2016 "MajkiiTelini"              Czech translation
 *  2016 "witoco"                    Latin-American Spanish translation
 *  2017 "seb-d59"                   French translation; override instruction detection
 *  2019 "Sapozhnik"                 Ukrainian translation
 *       "ccclxv"                    British English (UK) translation
 *  2024 "g1220k"                    Contributions
 *  2026 "JS55CT"                    V3.0.0 Current maintainer; SDK migration,
 *                                   V3.1.0 - 3.1.5 RoundAbout support, JB and Paths
 *                                   V3.2.0  Added Continuous scanning for problem angles
 */

/*global I18n, $, bootstrap, turf, getWmeSdk, SDK_INITIALIZED, GM_info, GM_xmlhttpRequest*/

(async function () {
  'use strict';

  // **************************************************************************************************************
  // IMPORTANT: Update this when releasing a new version of script
  // **************************************************************************************************************
  const SHOW_UPDATE_MESSAGE = true;
  const SCRIPT_VERSION_CHANGES = [
    'Version 3.2.2',
    'Small bug fix',
    'Version 3.2.0',
    'Experimenmtal: "Scan for Angles to Avoid" feature: background detection of PROBLEM angles replaces the need for the BJAI script!',
    'Version 3.1.5',
    'Intermediate breadcrumbs inside a JB inherit all the routing logic: road type hierarchy (Primary vs Street), left-hand traffic, best-continuation detection, and Keep/Exit classification.',
    'Version 3.1.4',
    'More robust marker overlap prevention system',
    'Version 3.1.3',
    'Roundabout turn restriction detection — exits with local restrictions display as NO_TURN (gray), works for RHT and LHT countries',
    'version 3.1.1',
    'Experimental: Far-turn angle display for Junction Boxes — breadcrumb trail through complex intersections (disabled by default)',
    'Experimental: Far-turn angle display for Paths — complete angle annotations along Path routes (disabled by default)',
  ];
  const SCRIPT_VERSION = GM_info.script.version.toString();
  const DOWNLOAD_URL = 'https://update.greasyfork.org/scripts/35547/WME%20Junction%20Angle%20Info.user.js';

  // ── Debug & execution state ───────────────────────────────────────────────
  // Runtime flags and counters used across the module.
  var junctionangle_debug = 1; // 0=off, 1=errors+warnings, 2=key decisions (function outcomes), 3=per-segment detail, 4=object dumps+internals — lower to 1 before release
  var ja_last_restart = 0; // epoch ms timestamp — throttles auto-restart on stale data errors
  var sdk; // WME SDK instance, assigned by bootstrap()

  // ── Settings storage ──────────────────────────────────────────────────────
  // ja_options holds all persisted user preferences (read/written via localStorage).
  // ja_getOption() / ja_setOption() are the only safe accessors — they apply defaults
  // and validate values against the ja_settings schema.
  var ja_options = {};

  // ── Cached data model properties ──────────────────────────────────────────
  // Cached values from SDK to avoid redundant queries during module execution.
  var ja_is_left_hand_traffic = false; // Cached from Countries.getAll()[0].isLeftHandTraffic (set during bootstrap)

  // ── Map layer state ───────────────────────────────────────────────────────
  // Shared state for the 'junction_angles' SDK map layer.
  var ja_layer_created = false; // true once sdk.Map.addLayer() has returned
  var ja_layer_visible = true; // mirrors the current Layer Switcher checkbox state
  var ja_roundabout_points = []; // GeoJSON Points of RA markers placed this pass (collision detection)
  var ja_current_features = []; // all features placed this render pass (overlap avoidance)
  var ja_feature_counter = 0; // monotonic counter; features are named 'ja_' + (++ja_feature_counter)

  // ── Marker position tracking (for local/far-turn overlap prevention) ─────────────────────────
  // Maps to track marker positions during a render pass so local and far-turn markers can coordinate.
  // Structure: map<nodeId, array<{bearing, distance}>>
  // Reset each render pass at start of testSelectedItem().
  var ja_local_markers_by_node = {}; // Local turn markers indexed by node, with bearings and distances
  var ja_far_turn_bearings_by_node = {}; // Far-turn markers indexed by node, with bearings and distances

  // ── UI state ──────────────────────────────────────────────────────────────
  var ja_sidebar_tabPane = null; // SDK tab pane element — retained so setupHtml() can re-render it

  // ── Continuous Scanning State (Phase 1: Cache + Event Infrastructure) ────────────────────
  // Persistent cache survives across render passes (unlike ja_current_features which clears each pass)
  var ja_continuous_cache = {};                   // { nodeId: { timestamp, angles: [] } } — cross-render persistent
  var ja_continuous_rendered = new Set();         // Track which angle markers already rendered to prevent duplicates
  var ja_continuous_mode = false;                 // Feature flag: continuous scanning enabled/disabled
  var ja_continuous_scan_timer = null;            // Debounce timer (100ms, separate from ja_calculation_timer)
  var ja_continuous_batch_index = 0;              // Track progress in batch scan (for incremental processing)
  var ja_nodes_all = [];                          // Copy of all viewport nodes (refreshed each scan start)
  var ja_continuous_enabled = false;              // Load from localStorage on init
  var ja_continuous_needs_batching = [];          // Nodes that need cache calculation (misses)

  // ── Angle classification thresholds ──────────────────────────────────────
  // Waze routing instruction boundaries derived from map experiments and the Waze wiki.
  var TURN_ANGLE = 45.5; // degrees — boundary between a Keep and a Turn instruction (wiki: 45.04°)
  var U_TURN_ANGLE = 168.24; // degrees — boundary above which a turn is classified as a U-Turn
  var GRAY_ZONE = 1.5; // degrees — margin around TURN_ANGLE to absorb measurement noise

  // ── Timing configuration for continuous scanning ──────────────────────────
  var BATCH_PROCESSING_DELAY = 50;     // milliseconds — delay between incremental batch processing cycles
  var CONTINUOUS_SCAN_DEBOUNCE = 100;  // milliseconds — debounce window for coalescing rapid pan/zoom/edit events
  var OVERLAPPING_ANGLE = 0.666; // degrees — two segments closer than this are treated as collinear
  var MIN_ZOOM_LEVEL = 17; // hide all markers when zoomed out past this level
  var PERPENDICULAR_TOLERANCE = 15; // degrees — tolerance for ±15° of perpendicular (90° multiple) in roundabouts and angle classification
  var WAZE_PARALLELISM_TOLERANCE = 5; // degrees — parallelism threshold for A and C segments
  var WAZE_MEDIAN_LENGTH_DEFAULT = 30; // meters — default median segment length for Waze U-turn qualification
  var WAZE_MEDIAN_LENGTH_EXTENDED = 50; // meters — extended threshold with lane guidance on incoming segment
  var WAZE_MEDIAN_LENGTH_THRESHOLD = 15; // meters — strict median threshold for optional Waze restriction (when setting is ON)
  // Roundabout instruction thresholds (CCW angle ranges for normal roundabouts in right-hand traffic)
  var ROUNDABOUT_INSTRUCTION_UTURN_THRESHOLD = 45; // < 45° or ≥ 315° → U-Turn
  var ROUNDABOUT_INSTRUCTION_TURN_RIGHT_THRESHOLD = 135; // 45° – 135° → Turn Right (45 + 90)
  var ROUNDABOUT_INSTRUCTION_CONTINUE_THRESHOLD = 225; // 135° – 225° → Continue (135 + 90)
  var ROUNDABOUT_INSTRUCTION_TURN_LEFT_THRESHOLD = 315; // 225° – 315° → Turn Left (225 + 90)

  // ── Routing instruction type enum ────────────────────────────────────────
  // String keys stored in GeoJSON feature properties and matched by SDK styleRules predicates.
  // BC = "best continuation" — Waze gives no spoken instruction for this turn.
  // Override* variants are used when a turn has a manually set instruction opcode.
  var ja_routing_type = {
    BC: 'junction_none',
    KEEP: 'junction_keep',
    KEEP_LEFT: 'junction_keep_left',
    KEEP_RIGHT: 'junction_keep_right',
    TURN: 'junction_turn',
    TURN_LEFT: 'junction_turn_left',
    TURN_RIGHT: 'junction_turn_right',
    EXIT: 'junction_exit',
    EXIT_LEFT: 'junction_exit_left',
    EXIT_RIGHT: 'junction_exit_right',
    U_TURN: 'junction_u_turn',
    PROBLEM: 'junction_problem',
    NO_TURN: 'junction_no_turn',
    NO_U_TURN: 'junction_no_u_turn',
    ROUNDABOUT: 'junction_roundabout',
    ROUNDABOUT_EXIT: 'junction_roundabout_exit',

    OverrideBC: 'Override_none',
    OverrideCONTINUE: 'Override_continue',
    OverrideKEEP_LEFT: 'Override_keep_left',
    OverrideKEEP_RIGHT: 'Override_keep_right',
    OverrideTURN_LEFT: 'Override_turn_left',
    OverrideTURN_RIGHT: 'Override_turn_right',
    OverrideEXIT: 'Override_exit',
    OverrideEXIT_LEFT: 'Override_exit_left',
    OverrideEXIT_RIGHT: 'Override_exit_right',
    OverrideU_TURN: 'Override_u_turn',

    // ── Far turn types (experimental) ─────────────────────────────────────
    // Far turns (isPathTurn or isJunctionBoxTurn) now use the same ja_routing_type values
    // as regular node turns (BC, TURN, KEEP, etc.) — classified by ja_guess_routing_instruction
    // using the instruction-firing angle (entry segment → first intermediate segment at entry node).
    //
    // The distinction between Path turns and JB turns is communicated via:
    //   • ja_is_far_turn: true on the GeoJSON feature properties → purple outline ring
    //
    // No separate PATH_TURN or JB_TURN type constants are needed. The two SDK flags are
    // still used to identify far turns during processing in ja_draw_far_turn_markers():
    //   turn.isPathTurn === true       → Path (FL2) far turn
    //   turn.isJunctionBoxTurn === true → Junction Box far turn
    // Note: these two flags are mutually exclusive.
  };

  // ── Road type enum ────────────────────────────────────────────────────────
  // Numeric road-type IDs matching WME data model values.
  // Used by ja_is_primary_road(), ja_is_ramp(), and routing instruction prediction.
  var ja_road_type = {
    //Streets
    NARROW_STREET: 22,
    STREET: 1,
    PRIMARY_STREET: 2,
    //Highways
    RAMP: 4,
    FREEWAY: 3,
    MAJOR_HIGHWAY: 6,
    MINOR_HIGHWAY: 7,
    //Other drivable
    DIRT_ROAD: 8,
    FERRY: 14,
    PRIVATE_ROAD: 17,
    PARKING_LOT_ROAD: 20,
    //Non-drivable
    WALKING_TRAIL: 5,
    PEDESTRIAN_BOARDWALK: 10,
    STAIRWAY: 16,
    RAILROAD: 18,
    RUNWAY: 19,
  };

  // ── Settings schema ───────────────────────────────────────────────────────
  // Each entry describes one user-configurable option: the UI element type/id and
  // the default value applied when no stored value exists or the stored value is invalid.
  // Settings with a 'group' key are visually disabled when their parent checkbox is unchecked.
  // ja_getOption() / ja_setOption() are the only safe accessors — never read ja_options directly.
  var ja_settings = {
    angleMode: { elementType: 'select', elementId: '_jaSelAngleMode', defaultValue: 'aDeparture', options: ['aAbsolute', 'aDeparture'] },
    angleDisplay: { elementType: 'select', elementId: '_jaSelAngleDisplay', defaultValue: 'displayFancy', options: ['displayFancy', 'displaySimple'] },
    angleDisplayArrows: { elementType: 'select', elementId: '_jaSelAngleDisplayArrows', defaultValue: '⇐⇒⇖⇗⇑', options: ['<><>', '⇦⇨⇦⇨⇧', '⇐⇒⇐⇒⇑', '←→←→↑', '⇐⇒⇖⇗⇑', '←→↖↗↑'] },
    override: { elementType: 'checkbox', elementId: '_jaCbOverride', defaultValue: true, group: 'guess' },
    overrideAngles: { elementType: 'checkbox', elementId: '_jaCboverrideAngles', defaultValue: false, group: 'override' },
    guess: { elementType: 'checkbox', elementId: '_jaCbGuessRouting', defaultValue: true },
    noInstructionColor: { elementType: 'color', elementId: '_jaTbNoInstructionColor', defaultValue: '#ffffff', group: 'guess' },
    continueInstructionColor: { elementType: 'color', elementId: '_jaTbContinueInstructionColor', defaultValue: '#ffffff', group: 'guess' },
    keepInstructionColor: { elementType: 'color', elementId: '_jaTbKeepInstructionColor', defaultValue: '#cbff84', group: 'guess' },
    exitInstructionColor: { elementType: 'color', elementId: '_jaTbExitInstructionColor', defaultValue: '#6cb5ff', group: 'guess' },
    turnInstructionColor: { elementType: 'color', elementId: '_jaTbTurnInstructionColor', defaultValue: '#4cc600', group: 'guess' },
    uTurnInstructionColor: { elementType: 'color', elementId: '_jaTbUTurnInstructionColor', defaultValue: '#b66cff', group: 'guess' },
    noTurnColor: { elementType: 'color', elementId: '_jaTbNoTurnColor', defaultValue: '#a0a0a0', group: 'guess' },
    problemColor: { elementType: 'color', elementId: '_jaTbProblemColor', defaultValue: '#feed40', group: 'guess' },
    roundaboutOverlayDisplay: { elementType: 'select', elementId: '_jaSelRoundaboutOverlayDisplay', defaultValue: 'rOverNever', options: ['rOverNever', 'rOverSelected', 'rOverAlways'] },
    roundaboutOverlayColor: { elementType: 'color', elementId: '_jaTbRoundaboutOverlayColor', defaultValue: '#aa0000', group: 'roundaboutOverlayDisplay' },
    roundaboutColor: { elementType: 'color', elementId: '_jaTbRoundaboutColor', defaultValue: '#ff8000', group: 'roundaboutOverlayDisplay' },
    uTurnIncludeStreet: { elementType: 'checkbox', elementId: '_jaCbUTurnIncludeStreet', defaultValue: false },
    uTurnIncludeParkingLot: { elementType: 'checkbox', elementId: '_jaCbUTurnIncludeParkingLot', defaultValue: false },
    uTurnIncludePrivateRoad: { elementType: 'checkbox', elementId: '_jaCbUTurnIncludePrivateRoad', defaultValue: false },
    wazeDoubleUTurnRestriction: { elementType: 'checkbox', elementId: '_jaCbWazeDoubleUTurnRestriction', defaultValue: true },
    enableFarTurnJB: { elementType: 'checkbox', elementId: '_jaCbEnableFarTurnJB', defaultValue: false, group: 'experimental' },
    enableFarTurnPath: { elementType: 'checkbox', elementId: '_jaCbEnableFarTurnPath', defaultValue: false, group: 'experimental' },
    decimals: { elementType: 'number', elementId: '_jaTbDecimals', defaultValue: 2, min: 0, max: 2 },
    pointSize: { elementType: 'number', elementId: '_jaTbPointSize', defaultValue: 12, min: 6, max: 20 },
    // PHASE 4: Continuous Scanning Settings
    continuousScanning: { elementType: 'checkbox', elementId: '_jaCbContinuousScanning', defaultValue: false, group: 'experimental' },
  };

  // ── Direction arrow character sets ────────────────────────────────────────
  // Provides named accessors for the currently selected arrow character set.
  // The actual character set string is stored in ja_options.angleDisplayArrows.
  var ja_arrow = {
    get: function (at) {
      var arrows = ja_getOption('angleDisplayArrows');
      return arrows[at % arrows.length];
    },
    left: function () {
      return this.get(0);
    },
    right: function () {
      return this.get(1);
    },
    left_up: function () {
      return this.get(2);
    },
    right_up: function () {
      return this.get(3);
    },
    up: function () {
      return this.get(4);
    },
  };

  /**
   * Returns the current WME editor selection as a flat array of feature descriptors.
   *
   * Wraps sdk.Editing.getSelection() and maps each selected item to a plain object
   * with { id, type } so callers do not need to interact with the SDK selection model
   * directly. Filters to only segments and nodes (the only types JAI supports).
   * Returns an empty array when nothing is selected or only unsupported types are selected.
   *
   * @returns {Array<{id: number, type: string}>} Selected segments/nodes, or [] if none.
   */
  function getselfeat() {
    var sel = sdk.Editing.getSelection();
    if (!sel) {
      return [];
    }
    // Only return segment and node selections — ignore all other types (bigJunction, venue, etc.)
    var SUPPORTED_TYPES = { 'segment': true, 'node': true };
    if (!SUPPORTED_TYPES[sel.objectType]) {
      return [];
    }
    return sel.ids.map(function (id) {
      return { type: sel.objectType, id: id };
    });
  }

  /**
   * Returns true if anything is currently selected in the editor.
   *
   * @returns {boolean} True if the selection is non-empty.
   */
  function hasSelection() {
    return getselfeat().length > 0;
  }

  /**
   * Returns true if the given segment ID is currently selected in the editor.
   *
   * Uses getselfeat() to read the live selection state. Called during angle
   * calculation to distinguish the incoming (selected) segment from other segments
   * at a node so the correct departure angle can be identified.
   *
   * @param {number} segmentId - The segment ID to test.
   * @returns {boolean} True if the segment is part of the current selection.
   */
  function ja_is_segment_selected(segmentId) {
    var sel = sdk.Editing.getSelection();
    return sel != null && sel.objectType === 'segment' && sel.ids.indexOf(segmentId) !== -1;
  }

  /**
   * Computes the base marker offset distance in meters for the current zoom level.
   *
   * The distance is looked up from a hard-coded table keyed by WME zoom level
   * (13–22), then scaled by the configured decimal-places setting so that wider
   * labels receive more spacing. Returns undefined at unsupported zoom levels and
   * logs a warning.
   *
   * @returns {number} Base label distance in meters.
   */
  function ja_compute_label_distance() {
    var ja_label_distance;
    switch (sdk.Map.getZoomLevel()) {
      case 22:
        ja_label_distance = 1.2;
        break;
      case 21:
        ja_label_distance = 2.2;
        break;
      case 20:
        ja_label_distance = 4.5;
        break;
      case 19:
        ja_label_distance = 8;
        break;
      case 18:
        ja_label_distance = 16;
        break;
      case 17:
        ja_label_distance = 32;
        break;
      case 16:
        ja_label_distance = 45;
        break;
      case 15:
        ja_label_distance = 50;
        break;
      case 14:
        ja_label_distance = 100;
        break;
      case 13:
        ja_label_distance = 300;
        break;
      default:
        ja_log('Unsupported zoom level: ' + sdk.Map.getZoomLevel() + '!', 1);
    }
    ja_label_distance *= 1 + 0.2 * parseInt(ja_getOption('decimals'));
    ja_log('zoom: ' + sdk.Map.getZoomLevel() + ' -> distance: ' + ja_label_distance, 3);
    return ja_label_distance;
  }

  /**
   * Scans the given node list for roundabout (junction) membership and builds an
   * entry/exit map.
   *
   * For each node, inspects connected segments for a non-null junctionId. When a
   * junction is found, records the non-junction segment and node as the entry side
   * (in_s / in_n). If the same junctionId is encountered a second time (second
   * selected node on the same RA), the second node is recorded as the exit side
   * (out_s / out_n). Also stores the junction geometry center point (p).
   *
   * @param {number[]} ja_nodes - Array of node IDs from the current selection.
   * @returns {Object} Map of junctionId → { in_s, in_n, out_s, out_n, p }.
   */
  function ja_find_roundabouts(ja_nodes) {
    var ja_selected_roundabouts = {};
    ja_nodes.forEach(function (node) {
      var nodeObj = sdk.DataModel.Nodes.getById({ nodeId: node });
      ja_log(nodeObj, 3);

      var tmp_s = null,
        tmp_n = null,
        tmp_junctionID = null;
      if (nodeObj == null || typeof nodeObj.connectedSegmentIds === 'undefined') {
        return;
      }
      nodeObj.connectedSegmentIds.forEach(function (segment) {
        ja_log(segment, 3);

        var segObj = sdk.DataModel.Segments.getById({ segmentId: segment });
        if (segObj != null && segObj.junctionId != null) {
          ja_log('Roundabout detected: ' + segObj.junctionId, 3);
          tmp_junctionID = segObj.junctionId;
        } else {
          tmp_s = segment;
          tmp_n = node;
        }
        ja_log('tmp_s: ' + (tmp_s === null ? 'null' : tmp_s), 3);
      });
      ja_log('final tmp_s: ' + (tmp_s === null ? 'null' : tmp_s), 3);
      if (tmp_junctionID === null) {
        return;
      }
      if (ja_selected_roundabouts.hasOwnProperty(tmp_junctionID)) {
        ja_selected_roundabouts[tmp_junctionID].out_s = tmp_s;
        ja_selected_roundabouts[tmp_junctionID].out_n = node;
      } else {
        var tmp_junction = sdk.DataModel.Junctions.getById({ junctionId: tmp_junctionID });
        ja_selected_roundabouts[tmp_junctionID] = {
          in_s: tmp_s,
          in_n: tmp_n,
          out_s: null,
          out_n: null,
          p: tmp_junction ? tmp_junction.geometry : undefined,
        };
      }
    });
    return ja_selected_roundabouts;
  }

  /**
   * Draws center-angle and ±N° deviation markers for all detected roundabouts.
   *
   * For each roundabout in the selection map: draws an optional circle overlay
   * (rOverSelected mode), renders the triangle legs (in_n → center → out_n) as a
   * LineString feature, calls ja_is_roundabout_normal() for its side effect of
   * placing ±N° deviation markers at oblique exits, then places the center-angle
   * marker colored per the specific entry→exit path angle (white = within ±PERPENDICULAR_TOLERANCE° of
   * perpendicular; orange = non-normal).
   *
   * @param {Object} ja_selected_roundabouts - Map from ja_find_roundabouts().
   * @param {number} ja_label_distance - Base marker offset distance in meters.
   */
  function ja_draw_roundabout_markers(ja_selected_roundabouts, ja_label_distance) {
    //Do some fancy painting for the roundabouts...
    for (var tmp_roundabout in ja_selected_roundabouts) {
      if (ja_selected_roundabouts.hasOwnProperty(tmp_roundabout)) {
        // for...in always yields string keys; SDK requires a number type
        var tmp_roundabout_id = parseInt(tmp_roundabout, 10);
        ja_log(tmp_roundabout_id, 3);
        ja_log(ja_selected_roundabouts[tmp_roundabout], 3);

        //New roundabouts don't have coordinates yet..
        if (typeof ja_selected_roundabouts[tmp_roundabout].p === 'undefined') {
          continue;
        }
        // Entry-only selection (no exit node in selection): show all exits relative to entry
        if (ja_selected_roundabouts[tmp_roundabout].out_n === null) {
          ja_draw_roundabout_entry_exits(tmp_roundabout_id, ja_selected_roundabouts[tmp_roundabout].in_n, ja_label_distance);
          continue;
        }

        // Roundabout arc selected: use the segment's entry node based on direction of travel.
        // Show all exits from that entry — same view as selecting an entry segment connected at that node.
        var _selfeat = getselfeat();
        if (_selfeat.length === 1 && _selfeat[0].type === 'segment') {
          var _selSeg = sdk.DataModel.Segments.getById({ segmentId: _selfeat[0].id });
          if (_selSeg && _selSeg.junctionId !== null) {
            // Determine entry node based on direction of travel (not geometric direction)
            // isAtoB means traffic flows from fromNodeId to toNodeId; isBtoA means toNodeId to fromNodeId
            var entryNodeForRA = _selSeg.isAtoB ? _selSeg.fromNodeId : _selSeg.toNodeId;
            ja_log('[RA] Selected RA arc: isAtoB=' + _selSeg.isAtoB + ', isBtoA=' + _selSeg.isBtoA + ', entry node=' + entryNodeForRA, 2);
            ja_draw_roundabout_entry_exits(tmp_roundabout_id, entryNodeForRA, ja_label_distance);
            continue;
          }
        }

        //Draw circle overlay for this roundabout
        if (ja_getOption('roundaboutOverlayDisplay') === 'rOverSelected') {
          ja_draw_roundabout_overlay(tmp_roundabout_id);
        }

        //Transform LonLat to actual layer projection
        var tmp_roundabout_center = ja_coordinates_to_point(ja_selected_roundabouts[tmp_roundabout].p.coordinates);
        var tmp_in_geom = sdk.DataModel.Nodes.getById({ nodeId: ja_selected_roundabouts[tmp_roundabout].in_n }).geometry;
        var tmp_out_geom = sdk.DataModel.Nodes.getById({ nodeId: ja_selected_roundabouts[tmp_roundabout].out_n }).geometry;
        var angle = ja_angle_between_points(tmp_in_geom, tmp_roundabout_center, tmp_out_geom);

        //Draw the two legs of the triangle (in_n → center → out_n)
        ja_add_feature({ type: 'LineString', coordinates: [tmp_in_geom.coordinates, tmp_roundabout_center.coordinates, tmp_out_geom.coordinates] }, { ja_type: 'arrow_line' });
        // Call ja_is_roundabout_normal for its side effect: places ±N° deviation markers
        // at any exit node that is more than 15° off perpendicular.
        ja_is_roundabout_normal(tmp_roundabout_id, ja_selected_roundabouts[tmp_roundabout].in_n, ja_label_distance);
        // Color the center marker based on THIS specific path's angle only.
        // Per Waze: a roundabout can be normal for one entry and non-normal for another.
        var ra_path_is_normal = ja_is_angle_normal(angle);
        ja_add_feature(tmp_roundabout_center, {
          angle: ja_round(angle) + '°',
          ja_type: ra_path_is_normal ? ja_routing_type.BC : ja_routing_type.ROUNDABOUT,
        });
      }
    }
  }

  /**
   * Identifies short connector segments (≤30m, or ≤50m with lane guidance) that create
   * double-turn or U-turn paths and returns an accumulator object for use during marker drawing.
   *
   * A double-turn occurs when a driver enters a short connector segment from one
   * road and exits onto another road such that the combined heading change is near
   * 180°. Waze may misclassify such paths; this function flags them as NO_U_TURN
   * or PROBLEM so ja_draw_node_markers can render warning markers.
   *
   * Optional stricter Waze restriction: when the "Disable for <15m and ±5° parallel"
   * setting is enabled, paths with median ≤15m and parallel arms are blocked.
   *
   * Only active in Departure angle mode when more than one node is selected.
   *
   * @param {number[]} ja_nodes - Array of selected node IDs.
   * @returns {{data: Object, collect: Function, forEachItem: Function}} Accumulator.
   */
  function ja_collect_double_turns(ja_nodes, allBigJunctions) {
    /**
     * Collect double-turn (inc. U-turn) segments info
     */
    var doubleTurns = {
      data: {}, //Structure: map<s_id, map<s_out_id, list<{s_in_id, angle, turn_type}>>>
      farExitMarkers: [], //Markers to draw at the median's far exit node when an arm is selected

      collect: function (s_id, s_in_id, s_out_id, angle, turn_type) {
        ja_log('Collecting double-turn path from ' + s_in_id + ' to ' + s_out_id + ' via ' + s_id + ' with angle ' + angle + ' type: ' + turn_type, 2);
        var info = this.data[s_id];
        if (info === undefined) {
          info = this.data[s_id] = {};
        }
        var list = info[s_out_id];
        if (list === undefined) {
          list = info[s_out_id] = [];
        }
        list.push({ s_in_id: s_in_id, angle: angle, turn_type: turn_type });
      },

      forEachItem: function (s_id, s_out_id, fn) {
        var info = this.data[s_id];
        if (info !== undefined) {
          var list = info[s_out_id];
          if (list !== undefined) {
            list.forEach(function (item, i) {
              fn(item, i);
            });
          }
        }
      },
    };

    //Loop through segments <=30 m (always qualifies) or 31-49 m with lane guidance on the incoming segment
    if (ja_getOption('angleMode') === 'aDeparture' && ja_nodes.length > 1) {
      getselfeat().forEach(function (selectedSegment) {
        var segmentId = selectedSegment.id;
        var segment = sdk.DataModel.Segments.getById({ segmentId: segmentId });
        ja_log('Checking ' + segmentId + ' for double turns ...', 3);

        var len = ja_segment_length(segment);
        ja_log('Segment ' + segmentId + ' length: ' + len, 3);
        if (!ja_is_uturn_qualifying_road(segment)) return;

        // SUPPRESS LOCAL DOUBLE U-TURNS FOR ENTRY SEGMENTS CROSSING INTO JB
        // When a segment crosses INTO a JB (one endpoint outside, one inside),
        // JB far-turn logic (with U-TURN instructions) takes over. Local double-turn
        // markers would duplicate the JB U-TURN marker.
        var bjCrossing = ja_segment_crosses_bj_boundary(segment, allBigJunctions);
        if (bjCrossing.crosses) {
          ja_log('Skip double turns: ' + segmentId + ' crosses JB boundary', 3);
          return; // Skip double-turn collection for this entry-to-JB segment
        }

        var fromNode = sdk.DataModel.Nodes.getById({ nodeId: segment.fromNodeId });
        var toNode = sdk.DataModel.Nodes.getById({ nodeId: segment.toNodeId });

        var lenRounded = Math.round(len);
        if (lenRounded <= 49) {
          var a_from = ja_getAngleMidleSeg(segment.fromNodeId, segment);
          var a_to = ja_getAngleMidleSeg(segment.toNodeId, segment);

          fromNode.connectedSegmentIds.forEach(function (fromSegmentId) {
            if (fromSegmentId === segmentId) return;
            var fromSegment = sdk.DataModel.Segments.getById({ segmentId: fromSegmentId });
            if (!ja_is_uturn_qualifying_road(fromSegment)) return;
            var from_a = ja_getAngle(segment.fromNodeId, fromSegment);
            var from_angle = ja_angle_diff(from_a, a_from, false);
            ja_log('Segment from ' + fromSegmentId + ' angle: ' + from_a + ', turn angle: ' + from_angle, 3);

            toNode.connectedSegmentIds.forEach(function (toSegmentId) {
              if (toSegmentId === segmentId) return;
              var toSegment = sdk.DataModel.Segments.getById({ segmentId: toSegmentId });
              if (!ja_is_uturn_qualifying_road(toSegment)) return;
              var to_a = ja_getAngle(segment.toNodeId, toSegment);
              var to_angle = ja_angle_diff(to_a, a_to, false);
              ja_log('Segment to ' + toSegmentId + ' angle: ' + to_a + ', turn angle: ' + to_angle, 3);

              var angle = Math.abs(to_angle - from_angle);
              ja_log('Angle from ' + fromSegmentId + ' to ' + toSegmentId + ' is: ' + angle, 3);

              // Path 1: fromSegment → segment → toSegment (A → B → C)
              var hasLGFromToMedian = ja_segment_has_lane_guidance(fromSegmentId, fromNode.id, segmentId);
              var turn_type_path1 = ja_classify_uturn_angle(angle, lenRounded, fromSegment, toSegment, fromNode.id, toNode.id, hasLGFromToMedian);

              if (turn_type_path1 !== null) {
                var useWazeRestriction = turn_type_path1 === ja_routing_type.NO_U_TURN;

                // Collect if turns are allowed (same logic for both paths)
                if (ja_is_turn_allowed(fromSegment, fromNode, segment) && ja_is_turn_allowed(segment, toNode, toSegment)) {
                  // When Waze restriction applies, always collect; otherwise check length/lane guidance
                  if (useWazeRestriction || lenRounded <= 30 || hasLGFromToMedian) {
                    doubleTurns.collect(segmentId, fromSegmentId, toSegmentId, angle, turn_type_path1);
                  }
                }
              }

              // Path 2: toSegment → segment → fromSegment (C → B → A)
              var hasLGToToMedian = ja_segment_has_lane_guidance(toSegmentId, toNode.id, segmentId);
              var turn_type_path2 = ja_classify_uturn_angle(angle, lenRounded, toSegment, fromSegment, toNode.id, fromNode.id, hasLGToToMedian);

              if (turn_type_path2 !== null) {
                useWazeRestriction = turn_type_path2 === ja_routing_type.NO_U_TURN;

                if (ja_is_turn_allowed(toSegment, toNode, segment) && ja_is_turn_allowed(segment, fromNode, fromSegment)) {
                  // When Waze restriction applies, always collect; otherwise check length/lane guidance
                  if (useWazeRestriction || lenRounded <= 30 || hasLGToToMedian) {
                    doubleTurns.collect(segmentId, toSegmentId, fromSegmentId, angle, turn_type_path2);
                  }
                }
              }
            });
          });
        }
      });

      // Second pass: trigger double-turn detection when an entry/exit arm is selected.
      // For each selected segment, look at its endpoint nodes for qualifying median neighbors.
      // Skips any neighbor that is itself a selected segment (already handled by first loop).
      var selectedIds = {};
      getselfeat().forEach(function (s) {
        selectedIds[s.id] = true;
      });

      getselfeat().forEach(function (selectedSegment) {
        var armId = selectedSegment.id;
        var armSeg = sdk.DataModel.Segments.getById({ segmentId: armId });
        if (!ja_is_uturn_qualifying_road(armSeg)) return;

        // SUPPRESS LOCAL DOUBLE U-TURNS FOR ARM SEGMENTS CROSSING INTO JB
        var armBjCrossing = ja_segment_crosses_bj_boundary(armSeg, allBigJunctions);
        if (armBjCrossing.crosses) {
          ja_log('Skip arm double turns: ' + armId + ' crosses JB boundary', 3);
          return;
        }

        [armSeg.fromNodeId, armSeg.toNodeId].forEach(function (armNodeId) {
          var armNode = sdk.DataModel.Nodes.getById({ nodeId: armNodeId });

          armNode.connectedSegmentIds.forEach(function (neighborId) {
            if (neighborId === armId) return;
            if (selectedIds[neighborId]) return; // already handled as direct median selection

            var neighbor = sdk.DataModel.Segments.getById({ segmentId: neighborId });
            if (!ja_is_uturn_qualifying_road(neighbor)) return;
            var nLen = Math.round(ja_segment_length(neighbor));
            if (nLen > 49) return;
            if (nLen > 30 && !ja_segment_has_lane_guidance(armId, armNodeId, neighborId)) return;
            if (!ja_is_turn_allowed(armSeg, armNode, neighbor)) return;

            var a_arm_side = ja_getAngleMidleSeg(armNodeId, neighbor);
            var medianFarNodeId = neighbor.fromNodeId === armNodeId ? neighbor.toNodeId : neighbor.fromNodeId;
            var a_exit_side = ja_getAngleMidleSeg(medianFarNodeId, neighbor);
            var medianFarNode = sdk.DataModel.Nodes.getById({ nodeId: medianFarNodeId });

            var arm_a = ja_getAngle(armNodeId, armSeg);
            var arm_angle = ja_angle_diff(arm_a, a_arm_side, false);

            medianFarNode.connectedSegmentIds.forEach(function (exitId) {
              if (exitId === neighborId) return;
              var exitSeg = sdk.DataModel.Segments.getById({ segmentId: exitId });
              if (!ja_is_uturn_qualifying_road(exitSeg)) return;
              if (!ja_is_turn_allowed(neighbor, medianFarNode, exitSeg)) return;

              var exit_a = ja_getAngle(medianFarNodeId, exitSeg);
              var exit_angle = ja_angle_diff(exit_a, a_exit_side, false);
              var combined_angle = Math.abs(exit_angle - arm_angle);
              ja_log('Entry-arm trigger: ' + armId + ' -> ' + neighborId + ' -> ' + exitId + ' angle: ' + combined_angle, 3);

              var turn_type = ja_classify_uturn_angle(combined_angle, nLen, armSeg, exitSeg, armNodeId, medianFarNodeId);
              if (turn_type !== null) {
                doubleTurns.farExitMarkers.push({
                  farNodeId: medianFarNodeId,
                  exitBearing: exit_a,
                  angle: combined_angle,
                  turn_type: turn_type,
                });
              }
            });
          });
        });
      });
    }

    ja_log('Double-turns collected: ' + doubleTurns.data.length + ' total', 3);
    ja_log(doubleTurns.data, 4);
    return doubleTurns;
  }

  /**
   * Iterates selected nodes and draws angle markers for all connected segment pairs.
   *
   * For each node: computes the bearing of every connected segment, determines
   * which segments are selected (incoming), then applies either Departure mode
   * (one marker per exit, placed along that exit's bearing) or Absolute mode (one
   * marker per adjacent pair, placed in the gap between them). Calls
   * ja_guess_routing_instruction() to classify each turn and ja_draw_marker() to
   * place the feature. Also draws double-turn markers for short connector paths.
   *
   * @param {number[]} ja_nodes - Array of node IDs to process.
   * @param {number} ja_label_distance - Base marker offset distance in meters.
   * @param {{data: Object, collect: Function, forEachItem: Function}} doubleTurns - From ja_collect_double_turns().
   * @param {boolean} ja_selected_has_median - True if any selected segment is contained inside a
   *   BigJunction. When true, far-turn markers are suppressed: the regular node-pair logic handles
   *   internal JB segments, and showing far-turn exit markers would be misleading/incorrect.
   * @param {number[]} ja_selected_seg_ids - IDs of the user-selected segments. Passed through to
   *   ja_draw_far_turn_markers so it only draws far turns from the selected entry segment(s).
   *   Empty when a node (not a segment) is selected — in that case all entries are shown.
   * @param {boolean} ja_is_pure_node_selection - True if a node is directly selected (not via segment).
   *   When true, far-turn (Path/JB) markers are suppressed — show only the node's absolute angles.
   * @returns {boolean} True if a data error occurred and the calculation must be retried.
   */
  function ja_draw_node_markers(ja_nodes, ja_label_distance, doubleTurns, ja_selected_has_median, ja_selected_seg_ids, allBigJunctions, ja_is_pure_node_selection) {
    var restart = false;
    //Start looping through selected nodes
    for (var i = 0; i < ja_nodes.length; i++) {
      var node = sdk.DataModel.Nodes.getById({ nodeId: ja_nodes[i] });
      var angles = [];
      var ja_selected_segments_count = 0;
      var ja_selected_angles = [];
      var a;

      if (node == null) {
        //Oh oh.. should not happen? We want to use a node that does not exist
        ja_log('[draw_node_markers] Null node at index ' + i + ' — should not happen', 1);
        continue;
      }
      //check connected segments
      var ja_current_node_segments = node.connectedSegmentIds;
      // EPSG:3857 projected units = cos(lat) × true meters; correct so turf distances match OL originals
      var ja_ld = ja_corrected_ld(ja_label_distance, node.geometry.coordinates);
      ja_log(node, 4);

      //ignore of we have less than 2 segments
      if (ja_current_node_segments.length <= 1) {
        ja_log('Found only ' + ja_current_node_segments.length + ' connected segments at ' + ja_nodes[i] + ', not calculating anything...', 3);
        continue;
      }

      ja_log('Calculating angles for ' + ja_current_node_segments.length + ' segments', 3);
      ja_log(ja_current_node_segments, 4);

      ja_current_node_segments.forEach(function (nodeSegment, j) {
        var s = sdk.DataModel.Segments.getById({ segmentId: nodeSegment });
        if (typeof s === 'undefined') {
          //Meh. Something went wrong, and we lost track of the segment. This needs a proper fix, but for now
          // it should be sufficient to just restart the calculation
          ja_log('Failed to read segment data from model. Restarting calculations.', 1);
          if (ja_last_restart === 0) {
            ja_last_restart = new Date().getTime();
            setTimeout(function () {
              ja_calculate();
            }, 500);
          }
          restart = true;
        }
        a = ja_getAngle(ja_nodes[i], s);
        ja_log('Segment ' + nodeSegment + ' angle: ' + a, 4);
        angles[j] = [a, nodeSegment, s == null ? false : ja_is_segment_selected(nodeSegment)];
        if (s == null ? false : ja_is_segment_selected(nodeSegment)) {
          ja_selected_segments_count++;
        }
      });

      if (restart) {
        return true;
      }

      //make sure we have the selected angles in correct order
      ja_log(ja_current_node_segments, 4);
      getselfeat().forEach(function (selectedSegment) {
        var selectedSegmentId = selectedSegment.id;
        if (ja_current_node_segments.indexOf(selectedSegmentId) >= 0) {
          //find the angle
          for (var j = 0; j < angles.length; j++) {
            if (angles[j][1] === selectedSegmentId) {
              ja_selected_angles.push(angles[j]);
              break;
            }
          }
          ja_log('Selected segment ' + selectedSegmentId + ' found', 4);
        }
      });

      ja_log(angles, 4);

      var ha, point;
      //if we have two connected segments selected, do some magic to get the turn angle only =)
      if (ja_selected_segments_count === 2) {
        a = ja_angle_diff(ja_selected_angles[0][0], ja_selected_angles[1][0], false);

        ha = (parseFloat(ja_selected_angles[0][0]) + parseFloat(ja_selected_angles[1][0])) / 2;
        if (
          Math.abs(ja_selected_angles[0][0]) + Math.abs(ja_selected_angles[1][0]) > 180 &&
          ((ja_selected_angles[0][0] < 0 && ja_selected_angles[1][0] > 0) || (ja_selected_angles[0][0] > 0 && ja_selected_angles[1][0] < 0))
        ) {
          ha += 180;
        }

        var ja_extra_space_multiplier = ja_compute_extra_space(a, ha);

        ja_log('Angle: ' + a + '° at ' + ha + '°', 4);

        //Guess some routing instructions based on segment types, angles etc
        var ja_junction_type = ja_routing_type.TURN; //Default to old behavior

        if (ja_getOption('guess')) {
          ja_log(ja_selected_angles, 4);
          ja_log(angles, 4);
          var s_in_seg = ja_selected_angles[0][1];
          var s_out_seg = ja_selected_angles[1][1];
          ja_junction_type = ja_guess_routing_instruction(node, s_in_seg, s_out_seg, angles);
          ja_log('Guess result: ' + s_in_seg + ' → ' + s_out_seg + ' = ' + ja_junction_type, 2);
        }
        //get the initial marker point
        point = turf.destination(turf.point(node.geometry.coordinates), (ja_extra_space_multiplier * ja_ld) / 1000, ja_math_to_compass(ha)).geometry;
        ja_draw_marker(point, node, ja_ld, a, ha, true, ja_junction_type);

        // Record this local marker for far-turn conflict detection
        // (Far-turn markers drawn later will check this data and adjust their distance if needed)
        if (!ja_local_markers_by_node[node.id]) {
          ja_local_markers_by_node[node.id] = [];
        }
        ja_local_markers_by_node[node.id].push({ bearing: ha, distance: ja_extra_space_multiplier * ja_ld });

        //draw double turn markers
        // If there are double-turn markers at this node+bearing, offset them farther out
        var doubleTurnMultiplier = ja_extra_space_multiplier;
        doubleTurns.forEachItem(ja_selected_angles[0][1], ja_selected_angles[1][1], function (item) {
          if (doubleTurnMultiplier === ja_extra_space_multiplier) {
            // First double-turn found: use larger distance to separate from local marker
            doubleTurnMultiplier = ja_extra_space_multiplier * 1.4;
            ja_log('[DOUBLE-TURN] Offset double-turn markers at node ' + node.id + ' by 1.4x', 2);
          }
          var doubleTurnPoint = turf.destination(turf.point(node.geometry.coordinates), (doubleTurnMultiplier * ja_ld) / 1000, ja_math_to_compass(ha)).geometry;
          ja_draw_marker(doubleTurnPoint, node, ja_ld, item.angle, ha, true, item.turn_type);
        });
      } else {
        //sort angle data (ascending)
        angles.sort(function (a, b) {
          return a[0] - b[0];
        });
        ja_log(angles, 4);
        ja_log(ja_selected_segments_count, 4);

        //get all segment angles
        angles.forEach(function (angle, j) {
          a = (360 + (angles[(j + 1) % angles.length][0] - angle[0])) % 360;
          ha = (360 + (a / 2 + angle[0])) % 360;
          var a_in = angles.filter(function (a) {
            return !!a[2];
          })[0];

          //Show only one angle for nodes with only 2 connected segments and a single selected segment
          // (not on both sides). Skipping the one > 180
          if (ja_selected_segments_count === 1 && angles.length === 2 && a >= 180 && ja_getOption('angleMode') !== 'aDeparture') {
            ja_log('Skipping marker, as we need only one of them', 3);
            return;
          }
          if (ja_getOption('angleMode') === 'aDeparture' && ja_selected_segments_count > 0) {
            if (a_in[1] === angle[1]) {
              ja_log('in == out. skipping.', 3);
              return;
            }
            ja_log('Angle in:', 3);
            ja_log(a_in, 4);
            var depMarkerType = ja_getOption('guess') ? ja_guess_routing_instruction(node, a_in[1], angle[1], angles) : ja_routing_type.TURN;
            ja_log('Guess result: ' + a_in[1] + ' → ' + angle[1] + ' = ' + depMarkerType, 3);

            //FIXME: we might want to try to keep the marker on the segment, instead of just
            //in the direction of the first part
            ha = angle[0];
            a = ja_angle_diff(a_in[0], angles[j][0], false);
            point = turf.destination(turf.point(node.geometry.coordinates), (ja_ld * 2) / 1000, ja_math_to_compass(ha)).geometry;

            // CHECK FOR ENTRY SEGMENT CROSSING INTO JB
            // If entry is outside JB and exit crosses out of JB boundary, move marker to boundary
            var markerAnchor = node;
            var isSquareMarker = false;
            var entryIsInJB = sdk.DataModel.Segments.isContainedInBigJunction({ segmentId: a_in[1] });

            // Only check for boundary crossing if entry is outside JB
            if (!entryIsInJB) {
              var exitSegment = sdk.DataModel.Segments.getById({ segmentId: angle[1] });
              if (exitSegment) {
                // Search for JB that the exit segment crosses OUT of
                for (var bji = 0; bji < allBigJunctions.length; bji++) {
                  var bjPolygon = turf.polygon(allBigJunctions[bji].geometry.coordinates);

                  // Check if current node is inside this JB
                  var nodeIsInside = turf.booleanPointInPolygon(turf.point(node.geometry.coordinates), bjPolygon);

                  if (nodeIsInside) {
                    // Get the far endpoint of exit segment (node that's NOT the current node)
                    var exitCoords = exitSegment.geometry.coordinates;
                    var farEndpoint =
                      Math.abs(exitCoords[0][0] - node.geometry.coordinates[0]) < 0.0001 && Math.abs(exitCoords[0][1] - node.geometry.coordinates[1]) < 0.0001 ? exitCoords[1] : exitCoords[0];

                    var farIsInside = turf.booleanPointInPolygon(turf.point(farEndpoint), bjPolygon);

                    // If node is inside and far endpoint is outside, this exit crosses out of JB
                    if (!farIsInside) {
                      var closestPt = ja_find_closest_bj_intersection(exitSegment, bjPolygon, node);
                      if (closestPt !== null) {
                        markerAnchor = { geometry: closestPt.geometry };
                        isSquareMarker = true;
                        // Recalculate point from boundary anchor
                        // Use larger distance (3.5x) to avoid overlapping with WME's turn restriction arrows at the boundary
                        var boundaryLd = ja_corrected_ld(ja_label_distance, markerAnchor.geometry.coordinates);
                        point = turf.destination(turf.point(markerAnchor.geometry.coordinates), (boundaryLd * 3.5) / 1000, ja_math_to_compass(ha)).geometry;
                        ja_log('[JAI] Marker moved to JB boundary (3.5x distance)', 3);
                        break;
                      }
                    }
                  }
                }
              }
            }
            ja_draw_marker(
              point,
              markerAnchor,
              ja_ld,
              a,
              ha,
              true,
              depMarkerType,
              false,
              isSquareMarker,
            );

            // Record this local marker for far-turn conflict detection
            // (Far-turn markers drawn later will check this data and adjust their distance if needed)
            if (!ja_local_markers_by_node[node.id]) {
              ja_local_markers_by_node[node.id] = [];
            }
            ja_local_markers_by_node[node.id].push({ bearing: ha, distance: 2 * ja_ld });

            //draw double turn markers
            // If there are double-turn markers at this node+bearing, offset them farther out
            // Use larger offset if at JB boundary to avoid WME turn restriction arrows
            var doubleTurnDepartureMult = isSquareMarker ? 4.2 : 2.7;
            doubleTurns.forEachItem(a_in[1], angle[1], function (item) {
              if ((isSquareMarker && doubleTurnDepartureMult === 4.2) || (!isSquareMarker && doubleTurnDepartureMult === 2.7)) {
                // First double-turn found at this anchor (boundary or node)
                doubleTurnDepartureMult = isSquareMarker ? 4.9 : 3.3;
                var boundarySuffix = isSquareMarker ? ' (at JB boundary)' : '';
                ja_log('[DOUBLE-TURN] Offset double-turn markers at node ' + node.id + ' by ' + doubleTurnDepartureMult.toFixed(1) + 'x (departure mode)' + boundarySuffix, 2);
              }
              var doubleTurnDeparturePoint = turf.destination(turf.point(markerAnchor.geometry.coordinates), (doubleTurnDepartureMult * ja_ld) / 1000, ja_math_to_compass(ha)).geometry;
              ja_draw_marker(doubleTurnDeparturePoint, markerAnchor, ja_ld, item.angle, ha, true, item.turn_type, false, isSquareMarker);
            });
          } else {
            ja_log('Angle between ' + angle[1] + ' and ' + angles[(j + 1) % angles.length][1] + ' is ' + a + ' and position for label should be at ' + ha, 3);
            point = turf.destination(turf.point(node.geometry.coordinates), (ja_ld * 1.25) / 1000, ja_math_to_compass(ha)).geometry;
            ja_draw_marker(point, node, ja_ld, a, ha);

            // Record this local marker for far-turn conflict detection
            // (Far-turn markers drawn later will check this data and adjust their distance if needed)
            if (!ja_local_markers_by_node[node.id]) {
              ja_local_markers_by_node[node.id] = [];
            }
            ja_local_markers_by_node[node.id].push({ bearing: ha, distance: 1.25 * ja_ld });
          }
        });
      }

      // ── Far turn markers (experimental) ─────────────────────────────────
      // Draw angle markers for all far turns (Path turns and Junction Box turns)
      // that originate from THIS node. Placed inside the per-node loop so it runs
      // once per node with the correct `node` and `ja_ld` values in scope.
      //
      // These are turns whose entry and exit segments are NOT directly adjacent —
      // they cross intermediate segments via segmentPath[].
      //
      // Placed after the regular segment-pair markers so far turn markers render
      // on top without interfering with the standard angle annotations.
      //
      // DEPARTURE MODE ONLY: Far turns (Paths and JB turns) only make sense in
      // Departure mode where you select an entry segment and see where it goes.
      // In Absolute mode, we only show geometric gaps between adjacent pairs,
      // independent of direction of travel, so far turns should not appear.
      //
      // Far-turn display is controlled by experimental settings:
      // - "Enable JAI for Junction Boxes" (enableFarTurnJB)
      // - "Enable JAI for Paths" (enableFarTurnPath)
      // Both are disabled by default. Actual filtering happens inside ja_draw_far_turn_markers().
      //
      // Suppressed when any selected segment is a JB median: internal segments are handled
      // by the regular node-pair loop above; far-turn exit markers would be misleading.
      // Also suppressed when a pure node is selected: show only absolute angles at that node.
      if (ja_getOption('angleMode') === 'aDeparture' && !ja_selected_has_median && !ja_is_pure_node_selection) {
        ja_draw_far_turn_markers(node, ja_label_distance, ja_selected_seg_ids, allBigJunctions);
      }

      // ── CACHE WRITE-BACK: Push fresh angles to continuous cache ────────────
      // On-demand recalculation has finished for this node. Compute full angle set
      // with routing types and write to ja_continuous_cache so that when you deselect,
      // the continuous scanner renders current data (not stale pre-edit data).
      //
      // This completes the cache architecture: both on-demand and continuous modes
      // share the same cache, and on-demand writes back fresh results on every
      // recalculation (whether for selection changes or turn edits).
      //
      // Note: On-demand rendering calculates routing types only for selected pairs,
      // but continuous rendering needs ALL pairs. So we call ja_calculate_angles_for_node()
      // to generate the full set with complete routing type information.
      if (ja_continuous_mode && ja_is_valid_routing_node(node)) {
        var fullAngles = ja_calculate_angles_for_node(node);
        ja_write_to_cache(node.id, fullAngles);
      }
    }

    // Draw far-exit double-turn markers triggered by arm selection.
    // Placed at the median's far node along the exit arm's bearing — matching the
    // visual position produced when the median itself is selected directly.
    doubleTurns.farExitMarkers.forEach(function (item) {
      var farNode = sdk.DataModel.Nodes.getById({ nodeId: item.farNodeId });
      if (!farNode) return;
      var farLd = ja_corrected_ld(ja_label_distance, farNode.geometry.coordinates);

      // CHECK FOR LOCAL TURN MARKER CONFLICT AT FAR NODE
      // If a local turn at this far node targets the same direction, move this far-exit marker out farther
      var hasFarNodeConflict = ja_local_markers_by_node[item.farNodeId] &&
                               ja_local_markers_by_node[item.farNodeId].some(function(ltMarker) {
                                 return ja_markers_target_same_direction(item.exitBearing, ltMarker.bearing, 30);
                               });
      var farExitDist = hasFarNodeConflict ? 3.2 : 2.0;
      if (hasFarNodeConflict) {
        ja_log('[MARKER-OVERLAP] Far-exit conflict at node ' + item.farNodeId + ': 3.2x distance', 3);
      }

      var pt = turf.destination(turf.point(farNode.geometry.coordinates), (farExitDist * farLd) / 1000, ja_math_to_compass(item.exitBearing)).geometry;
      ja_draw_marker(pt, farNode, farLd, item.angle, item.exitBearing, true, item.turn_type);
    });

    return false;
  }

  // ─── CACHE HELPERS: Reduce duplication in cache operations ────────────────
  /**
   * Store angle data in ja_continuous_cache for both on-demand and continuous modes.
   * Single source of truth for cache writes; prevents duplication and ensures consistency.
   * @param {number} nodeId - Node ID to cache
   * @param {Array} angles - Angle objects array with routing type info
   */
  function ja_write_to_cache(nodeId, angles) {
    ja_continuous_cache[nodeId] = {
      timestamp: Date.now(),
      angles: angles
    };
    ja_log('Cache write: node ' + nodeId + ' (' + angles.length + ' angles)', 4);
  }

  /**
   * Check if cache entry is fresh (within 2-minute TTL).
   * @param {Object} cached - Cache entry (should have timestamp field)
   * @returns {boolean} true if entry exists and is < 2min old, false otherwise
   */
  function ja_is_cache_fresh(cached) {
    if (!cached) return false;
    return (Date.now() - cached.timestamp) < 2 * 60 * 1000;  // 2-min TTL
  }

  /**
   * Filter angle array to PROBLEM type only.
   * @param {Array} angles - Array of angle objects with type field
   * @returns {Array} Subset containing only PROBLEM angles
   */
  function ja_filter_problem_angles(angles) {
    return angles.filter(a => a.type === ja_routing_type.PROBLEM);
  }

  /**
   * Check if node has enough segments for routing analysis (3+ segments).
   * @param {Object} node - Node object with connectedSegmentIds array
   * @returns {boolean} true if node has 3+ segments, false otherwise
   */
  function ja_is_valid_routing_node(node) {
    return node && node.connectedSegmentIds && node.connectedSegmentIds.length >= 3;
  }

  // ─── PHASE 2: CONTINUOUS SCANNING - Batch Processing ─────────────────────
  /**
   * Process cache-miss nodes in batches to add new markers incrementally.
   * Only processes nodes in ja_continuous_needs_batching list (pre-filtered by ja_start_continuous_scan).
   * Calculates angles, caches them, and renders immediately (adds to existing layer, no clearing).
   * Schedules next batch via setTimeout (50ms delay = non-blocking).
   */
  function ja_scan_nodes_batch_from_list(batchSize) {
    if (!ja_continuous_mode) return;
    if (ja_continuous_needs_batching.length === 0) return;

    var batchStartTime = Date.now();
    // Calculate batch end index from the miss list
    var endIndex = Math.min(ja_continuous_batch_index + batchSize, ja_continuous_needs_batching.length);
    ja_log('Processing cache misses: batch ' + Math.ceil(ja_continuous_batch_index / batchSize) + ', nodes ' + ja_continuous_batch_index + '-' + endIndex + ' of ' + ja_continuous_needs_batching.length, 2);

    // Process this batch of cache misses
    for (var i = ja_continuous_batch_index; i < endIndex; i++) {
      var node = ja_continuous_needs_batching[i];
      if (!ja_is_valid_routing_node(node)) continue;

      // Recalculate angles for this cache miss
      ja_log('Calculating angles for new node ' + node.id, 3);
      var angles = ja_calculate_angles_for_node(node);

      // Store in cache
      ja_write_to_cache(node.id, angles);

      // Render new markers immediately (incrementally add to layer)
      var problemAngles = ja_filter_problem_angles(angles);
      for (var j = 0; j < problemAngles.length; j++) {
        var angle = problemAngles[j];
        var markerKey = node.id + '_' + angle.s_in_id + '_' + angle.s_out_id + '_' + Math.round(angle.angle * 10);

        if (!ja_continuous_rendered.has(markerKey)) {
          var labelDistance = ja_compute_label_distance();
          var pt = turf.destination(turf.point(node.geometry.coordinates), (labelDistance) / 1000, ja_math_to_compass(angle.bearing)).geometry;
          ja_draw_marker(pt, node, labelDistance, angle.angle, angle.bearing, true, angle.type, false, false);
          ja_continuous_rendered.add(markerKey);
        }
      }
    }

    var batchEndTime = Date.now();
    ja_continuous_batch_index = endIndex;

    // If more misses to process, schedule next batch
    if (ja_continuous_batch_index < ja_continuous_needs_batching.length) {
      setTimeout(() => ja_scan_nodes_batch_from_list(batchSize), BATCH_PROCESSING_DELAY);
      ja_log('Batch processing took ' + (batchEndTime - batchStartTime) + 'ms. Next batch in ' + BATCH_PROCESSING_DELAY + 'ms...', 2);
    } else {
      var totalCacheSize = Object.keys(ja_continuous_cache).length;
      var totalAnglePairs = 0;
      for (var cId in ja_continuous_cache) {
        if (ja_continuous_cache.hasOwnProperty(cId)) {
          totalAnglePairs += ja_continuous_cache[cId].angles.length;
        }
      }
      ja_log('All cache misses resolved. Cache: ' + totalCacheSize + ' nodes / ' + totalAnglePairs + ' angle pairs. Total batch time: ~' + (batchEndTime - batchStartTime) + 'ms', 2);
      ja_continuous_batch_index = 0;
      ja_continuous_needs_batching = [];
    }
  }

  /**
   * Process a batch of nodes incrementally (20 at a time) to avoid UI freeze.
   * Checks cache first: if angle pair cached and fresh (2-min TTL), skips recalculation.
   * Cache miss: calculates angles via ja_guess_routing_instruction() and caches result.
   * Markers persist on layer until segment edits invalidate cache for that node.
   * Schedules next batch via setTimeout (BATCH_PROCESSING_DELAY ms = non-blocking).
   */
  function ja_scan_nodes_batch(batchSize) {
    if (!ja_continuous_mode) return;  // Bail if continuous mode disabled

    var batchStartTime = Date.now();
    var batchCacheHits = 0;
    var batchCacheMisses = 0;

    // Calculate batch end index
    var endIndex = Math.min(ja_continuous_batch_index + batchSize, ja_nodes_all.length);
    ja_log('Continuous scan batch: nodes ' + ja_continuous_batch_index + '-' + endIndex + ' of ' + ja_nodes_all.length, 2);

    // Process this batch
    for (var i = ja_continuous_batch_index; i < endIndex; i++) {
      var node = ja_nodes_all[i];
      if (!ja_is_valid_routing_node(node)) continue;  // Skip 2-way roads (no routing ambiguity)

      // Check cache (2-min TTL)
      var cached = ja_continuous_cache[node.id];
      if (ja_is_cache_fresh(cached)) {
        batchCacheHits++;
        ja_log('Cache HIT: node ' + node.id, 4);
        continue;  // Use cached, skip recalculation
      }

      // Cache miss or expired: recalculate angles using existing JAI logic
      batchCacheMisses++;
      ja_log('Cache MISS: node ' + node.id, 4);
      var angles = ja_calculate_angles_for_node(node);

      // Store in cache
      ja_write_to_cache(node.id, angles);
    }

    var batchEndTime = Date.now();
    ja_continuous_batch_index = endIndex;

    // If more nodes to process, schedule next batch with BATCH_PROCESSING_DELAY (keeps editor responsive)
    if (ja_continuous_batch_index < ja_nodes_all.length) {
      setTimeout(() => ja_scan_nodes_batch(batchSize), BATCH_PROCESSING_DELAY);
      ja_log('Batch took ' + (batchEndTime - batchStartTime) + 'ms (' + batchCacheHits + ' hits / ' + batchCacheMisses + ' misses). Next batch in ' + BATCH_PROCESSING_DELAY + 'ms...', 2);
    } else {
      var totalCacheSize = Object.keys(ja_continuous_cache).length;
      var totalAnglePairs = 0;
      for (var cId in ja_continuous_cache) {
        if (ja_continuous_cache.hasOwnProperty(cId)) {
          totalAnglePairs += ja_continuous_cache[cId].angles.length;
        }
      }
      ja_log('Complete scan done: ' + totalCacheSize + ' nodes / ' + totalAnglePairs + ' angle pairs. Final batch: ' + (batchEndTime - batchStartTime) + 'ms (' + batchCacheHits + ' hits / ' + batchCacheMisses + ' misses). Rendering PROBLEM markers...', 2);
      ja_render_continuous_problems();
      ja_continuous_batch_index = 0;  // Reset for next scan
    }
  }

  /**
   * Calculate all angles for a single node using existing JAI logic.
   * Returns array of angle objects with type (PROBLEM, TURN, KEEP, BC, U_TURN, etc.)
   * Reuses ja_guess_routing_instruction() which detects gray zones correctly.
   */
  function ja_calculate_angles_for_node(node) {
    var nodeSegmentIds = node.connectedSegmentIds;
    if (nodeSegmentIds.length < 3) return [];  // Skip 2-way roads

    // First pass: build complete angles array of [angle, segmentId] pairs
    // This is what ja_guess_routing_instruction expects
    var angles_raw = [];
    for (var i = 0; i < nodeSegmentIds.length; i++) {
      var seg_i = sdk.DataModel.Segments.getById({ segmentId: nodeSegmentIds[i] });
      if (!seg_i) continue;

      var bearing_i = ja_getAngle(node.id, seg_i);
      if (bearing_i === null) continue;

      angles_raw.push([bearing_i, nodeSegmentIds[i]]);
    }

    if (angles_raw.length < 3) return [];  // Need at least 3 segments for routing ambiguity

    // Second pass: for each pair of segments in both directions, get routing type using ja_guess_routing_instruction
    // Evaluate all ordered pairs (A→B and B→A) to catch PROBLEM angles regardless of segment order
    var angles_with_types = [];
    for (var i = 0; i < angles_raw.length; i++) {
      for (var j = 0; j < angles_raw.length; j++) {
        if (i === j) continue;  // Skip same segment
        var s_in_id = angles_raw[i][1];
        var s_out_id = angles_raw[j][1];
        var angle = ja_angle_diff(angles_raw[i][0], angles_raw[j][0], false);

        // Get routing type using existing function (includes gray zone detection)
        var routingType = ja_guess_routing_instruction(node, s_in_id, s_out_id, angles_raw);
        ja_log('Angle pair: ' + s_in_id + ' → ' + s_out_id + ' = ' + routingType, 4);

        // Compute midpoint bearing for marker placement (handles wrap-around at 0°/360°)
        var b_in = angles_raw[i][0];
        var b_out = angles_raw[j][0];
        var bearing_diff = b_out - b_in;
        if (bearing_diff > 180) bearing_diff -= 360;
        if (bearing_diff < -180) bearing_diff += 360;
        var bearing_mid = b_in + bearing_diff / 2;
        if (bearing_mid < 0) bearing_mid += 360;
        if (bearing_mid >= 360) bearing_mid -= 360;

        angles_with_types.push({
          angle: angle,
          type: routingType,
          s_in_id: s_in_id,
          s_out_id: s_out_id,
          bearing_in: angles_raw[i][0],
          bearing_out: angles_raw[j][0],
          bearing: bearing_mid  // Midpoint for marker positioning (corrected for wrap-around)
        });
      }
    }

    return angles_with_types;
  }

  /**
   * Render only NEW PROBLEM angles from cache to the junction_angles layer.
   * Skips if there's an active selection (on-demand markers take priority).
   * Skips angles already rendered (tracked in ja_continuous_rendered Set).
   * Markers persist on layer until segment edits invalidate their cache entry.
   * Filters to PROBLEM type only (like BJAI's optimization).
   * Reuses ja_draw_marker() for consistent styling.
   */
  function ja_render_continuous_problems() {
    if (!ja_continuous_mode) return;

    // Don't render continuous markers if user has a selection active (on-demand markers take priority)
    if (hasSelection()) {
      ja_log('Skipping continuous marker render (selection active)', 3);
      return;
    }

    var problemCount = 0;
    var nodeCount = 0;

    // Iterate cache
    for (var nodeId in ja_continuous_cache) {
      if (!ja_continuous_cache.hasOwnProperty(nodeId)) continue;

      var entry = ja_continuous_cache[nodeId];
      var node = sdk.DataModel.Nodes.getById({ nodeId: parseInt(nodeId) });
      if (!node) continue;

      nodeCount++;

      // Filter to PROBLEM angles only
      var problemAngles = ja_filter_problem_angles(entry.angles);

      for (var i = 0; i < problemAngles.length; i++) {
        var angle = problemAngles[i];

        // Create unique key for this angle: nodeId + segment pair + rounded angle
        // Prevents duplicate markers if render is called multiple times
        var markerKey = nodeId + '_' + angle.s_in_id + '_' + angle.s_out_id + '_' + Math.round(angle.angle * 10);

        // Skip if already rendered
        if (ja_continuous_rendered.has(markerKey)) {
          ja_log('Skipping duplicate angle marker: ' + markerKey, 3);
          continue;
        }

        var labelDistance = ja_compute_label_distance();

        // Calculate marker position (midpoint bearing, offset from node)
        var pt = turf.destination(turf.point(node.geometry.coordinates), (labelDistance) / 1000, ja_math_to_compass(angle.bearing)).geometry;

        // Draw using existing JAI marker function (reuses styling)
        ja_draw_marker(pt, node, labelDistance, angle.angle, angle.bearing, true, angle.type, false, false);

        // Mark this angle as rendered
        ja_continuous_rendered.add(markerKey);
        problemCount++;
      }
    }

    ja_log('Rendered ' + problemCount + ' PROBLEM markers from cache (' + nodeCount + ' nodes)', 2);
  }

  /**
   * Start a continuous scan: Phase 1 render cached nodes instantly, Phase 2 batch new nodes.
   *
   * PHASE 1 (instant): Loop through viewport, render all cached nodes with CURRENT labelDistance.
   *   - Cache contains all angle types (PROBLEM, TURN, KEEP, BC, U_TURN, etc.)
   *   - Filter to PROBLEM only for continuous mode
   *   - Recalculate marker position with new zoom's labelDistance (only position, not angles)
   *   - Add to layer incrementally
   *
   * PHASE 2 (background): Batch process nodes NOT in cache
   *   - Calculate all angle pairs, cache them
   *   - Render PROBLEM only, add to layer as batches complete
   *
   * Called by pan/zoom/edit event handlers.
   */
  function ja_start_continuous_scan() {
    if (!ja_continuous_mode) return;

    // Skip if zoomed out below minimum level
    if (sdk.Map.getZoomLevel() < MIN_ZOOM_LEVEL) {
      ja_log('Zoom level ' + sdk.Map.getZoomLevel() + ' < ' + MIN_ZOOM_LEVEL + ', skipping continuous scan', 3);
      return;
    }

    // Skip if user has selection active (on-demand is showing, don't interfere)
    if (hasSelection()) {
      ja_log('Selection active, skipping continuous scan', 3);
      return;
    }

    // Clear layer, rendered Set, and feature tracking for fresh collision detection on zoom
    // ja_current_features holds marker positions used by ja_draw_marker() for collision detection.
    // If we don't clear it, collision detection will reference old positions from previous zoom level,
    // causing markers to be nudged further away with each zoom cycle (accumulation drift).
    sdk.Map.removeAllFeaturesFromLayer({ layerName: 'junction_angles' });
    ja_continuous_rendered.clear();
    ja_current_features = [];
    ja_feature_counter = 0;
    ja_log('Cleared layer and feature tracking for fresh marker positioning at new zoom level', 2);

    // Snapshot current viewport nodes using modern SDK API
    var scanStartTime = Date.now();
    ja_nodes_all = sdk.DataModel.Nodes.getAll() || [];
    ja_continuous_batch_index = 0;
    ja_continuous_needs_batching = [];

    // Count current cache state before scan
    var preScanCacheSize = Object.keys(ja_continuous_cache).length;
    var preScanAnglePairs = 0;
    for (var cId in ja_continuous_cache) {
      if (ja_continuous_cache.hasOwnProperty(cId)) {
        preScanAnglePairs += ja_continuous_cache[cId].angles.length;
      }
    }

    var validRouteNodes = 0;
    for (var ni = 0; ni < ja_nodes_all.length; ni++) {
      if (ja_is_valid_routing_node(ja_nodes_all[ni])) validRouteNodes++;
    }

    ja_log('SCAN START: ' + ja_nodes_all.length + ' nodes in viewport, ' + validRouteNodes + ' have 3+ segments. Pre-scan cache: ' + preScanCacheSize + ' nodes / ' + preScanAnglePairs + ' angle pairs', 2);

    var cacheHits = 0;
    var cacheMisses = 0;

    // PHASE 1: Render cached nodes immediately (no calculation needed, just reposition with current labelDistance)
    for (var i = 0; i < ja_nodes_all.length; i++) {
      var node = ja_nodes_all[i];
      if (!ja_is_valid_routing_node(node)) continue;

      var cached = ja_continuous_cache[node.id];

      // Cache hit (fresh): render immediately with current labelDistance, skip batching
      if (ja_is_cache_fresh(cached)) {
        cacheHits++;
        var entry = cached;
        // Render ALL PROBLEM angles from cache (filter continuous mode)
        var problemAngles = ja_filter_problem_angles(entry.angles);
        for (var j = 0; j < problemAngles.length; j++) {
          var angle = problemAngles[j];
          var markerKey = node.id + '_' + angle.s_in_id + '_' + angle.s_out_id + '_' + Math.round(angle.angle * 10);

          var labelDistance = ja_compute_label_distance();  // Current zoom's distance
          var pt = turf.destination(turf.point(node.geometry.coordinates), (labelDistance) / 1000, ja_math_to_compass(angle.bearing)).geometry;
          ja_draw_marker(pt, node, labelDistance, angle.angle, angle.bearing, true, angle.type, false, false);
          ja_continuous_rendered.add(markerKey);
        }
      } else {
        // Cache miss (expired or new): queue for PHASE 2 batching
        cacheMisses++;
        ja_continuous_needs_batching.push(node);
      }
    }

    var phase1EndTime = Date.now();
    ja_log('PHASE 1 complete: ' + cacheHits + ' cache hits / ' + cacheMisses + ' misses (' + (phase1EndTime - scanStartTime) + 'ms). Queuing ' + ja_continuous_needs_batching.length + ' nodes for background batching...', 2);

    // PHASE 2: Batch process cache misses (20-node batches: 6ms calc + 50ms delay = fast & responsive)
    if (ja_continuous_needs_batching.length > 0) {
      ja_scan_nodes_batch_from_list(20);
    } else {
      var finalCacheSize = Object.keys(ja_continuous_cache).length;
      ja_log('SCAN COMPLETE: No cache misses, all nodes fresh. Final cache: ' + finalCacheSize + ' nodes. Total time: ' + (phase1EndTime - scanStartTime) + 'ms', 2);
    }
  }

  /**
   * Debounced continuous scan trigger: prevents redundant scans during rapid events.
   * Cancels pending timeout if new event fires within 100ms, then reschedules.
   * Called by zoom/pan/edit event handlers.
   */
  function ja_debounced_continuous_scan() {
    if (!ja_continuous_mode) return;

    // Skip if zoomed out below minimum level
    if (sdk.Map.getZoomLevel() < MIN_ZOOM_LEVEL) {
      ja_log('Zoom level ' + sdk.Map.getZoomLevel() + ' < ' + MIN_ZOOM_LEVEL + ', skipping debounced scan', 3);
      return;
    }

    // Cancel any pending timeout
    if (ja_continuous_scan_timer) {
      clearTimeout(ja_continuous_scan_timer);
      ja_log('Cancelled pending continuous scan', 3);
    }

    // Schedule new scan CONTINUOUS_SCAN_DEBOUNCE from now (allows batching of rapid events)
    ja_continuous_scan_timer = setTimeout(() => {
      ja_start_continuous_scan();
      ja_continuous_scan_timer = null;
    }, CONTINUOUS_SCAN_DEBOUNCE);
  }

  /**
   * Main entry point for junction angle calculation and marker rendering.
   *
   * Triggered by every selection-change, zoom, or map-move event. Clears the
   * junction_angles layer, collects the selected nodes, computes label distances,
   * identifies roundabouts, draws roundabout markers, identifies double-turn
   * connector segments, then draws per-node angle markers.
   *
   * Selection handling:
   * - 1 feature (segment or node): standard single-selection mode — all connected angles shown.
   * - Exactly 2 segments that share a node: two-segment mode — one turn-angle marker is drawn
   *   at the shared junction node, color-coded with routing instruction prediction.
   * - 2 segments with no shared node, 2 mixed types, or 3+ features: no markers drawn.
   *
   * Exits early if the data model returns stale data that requires a deferred retry.
   */
  function testSelectedItem() {
    // Always clear the layer first to stay in sync with selection state
    sdk.Map.removeAllFeaturesFromLayer({ layerName: 'junction_angles' });
    ja_roundabout_points = [];
    ja_current_features = [];
    ja_feature_counter = 0;

    // If continuous scanning is active, mark all rendered markers as cleared (so they'll be re-rendered after on-demand markers)
    if (ja_continuous_mode) {
      ja_continuous_rendered.clear();
      ja_log('Cleared rendered marker tracking for on-demand refresh', 3);
    }

    // Clear marker position tracking maps so local/far-turn conflict detection starts fresh
    ja_local_markers_by_node = {};
    ja_far_turn_bearings_by_node = {};

    // Cache traffic handedness for this render pass (used in ja_guess_routing_instruction and ja_draw_roundabout_entry_exits)
    ja_is_left_hand_traffic = (sdk.DataModel.Countries.getAll()[0] || {}).isLeftHandTraffic || false;

    // Early exit if nothing is selected (after cleanup)
    var ja_selfeat = getselfeat();
    if (ja_selfeat.length === 0 || ja_selfeat.length > 2) {
      // Nothing selected — re-render continuous markers if enabled and zoom is sufficient
      if (ja_continuous_mode && sdk.Map.getZoomLevel() >= MIN_ZOOM_LEVEL) {
        ja_log('No selection active. Re-rendering continuous markers (deselect/no-op call)...', 2);
        ja_render_continuous_problems();
      }
      return;
    }

    if (sdk.Map.getZoomLevel() < MIN_ZOOM_LEVEL) {
      return;
    }
    if (ja_getOption('roundaboutOverlayDisplay') === 'rOverAlways') {
      ja_draw_roundabout_overlay();
    }

    var ja_start_time = Date.now();
    var ja_nodes = [];

    if (ja_selfeat.length === 2) {
      // Two-segment mode: only supported when both selected features are segments
      if (ja_selfeat[0].type !== 'segment' || ja_selfeat[1].type !== 'segment') {
        return;
      }
      var seg0 = sdk.DataModel.Segments.getById({ segmentId: ja_selfeat[0].id });
      var seg1 = sdk.DataModel.Segments.getById({ segmentId: ja_selfeat[1].id });
      if (!seg0 || !seg1) {
        return;
      }
      // Find the node shared by both segments — this is the junction to measure
      var seg0Nodes = [seg0.fromNodeId, seg0.toNodeId];
      var seg1Nodes = [seg1.fromNodeId, seg1.toNodeId];
      var sharedNodeId = seg0Nodes.filter(function (id) {
        return seg1Nodes.indexOf(id) >= 0;
      })[0];
      if (sharedNodeId == null) {
        return;
      } // segments not connected — nothing to show
      ja_nodes.push(sharedNodeId);
    } else {
      // Single feature — extract endpoint nodes from segment, or push node id directly
      ja_selfeat.forEach(function (element) {
        if (element.type === 'node') {
          ja_nodes.push(element.id);
        } else if (element.type === 'segment') {
          var seg = sdk.DataModel.Segments.getById({ segmentId: element.id });
          if (seg && seg.fromNodeId != null && ja_nodes.indexOf(seg.fromNodeId) === -1) {
            ja_nodes.push(seg.fromNodeId);
          }
          if (seg && seg.toNodeId != null && ja_nodes.indexOf(seg.toNodeId) === -1) {
            ja_nodes.push(seg.toNodeId);
          }
        }
        ja_log(ja_nodes, 4);
      });
    }

    var ja_label_distance = ja_compute_label_distance();
    // Cache BigJunctions for the entire render pass — eliminates 5 separate .getAll() calls
    var allBigJunctions = sdk.DataModel.BigJunctions.getAll();

    // When a node is directly selected (not via segment selection), suppress roundabout/path/junction
    // markers — show only the node's absolute angle markers (angles at that specific node).
    var ja_is_pure_node_selection = ja_selfeat.length === 1 && ja_selfeat[0].type === 'node';

    // Only draw roundabout and path/junction markers if NOT a pure node selection
    if (!ja_is_pure_node_selection) {
      var ja_selected_roundabouts = ja_find_roundabouts(ja_nodes);
      ja_draw_roundabout_markers(ja_selected_roundabouts, ja_label_distance);
    }

    // When a single roundabout arc is selected, ja_draw_roundabout_entry_exits already shows
    // all exit info relative to the arc's fromNode. Suppress ja_draw_node_markers so the
    // regular intersection-style angles don't fire on the roundabout's own nodes.
    var ja_marker_nodes = ja_nodes;
    if (ja_selfeat.length === 1 && ja_selfeat[0].type === 'segment') {
      var _arcSeg = sdk.DataModel.Segments.getById({ segmentId: ja_selfeat[0].id });
      if (_arcSeg && _arcSeg.junctionId !== null) {
        ja_marker_nodes = [];
      }
    }

    var doubleTurns = ja_collect_double_turns(ja_marker_nodes, allBigJunctions);

    // True if any selected segment is contained inside a BigJunction (a median/intermediate
    // segment). In that case ja_draw_node_markers suppresses far-turn markers: the regular
    // node-pair loop handles internal angles, and far-turn exit markers would be misleading.
    var ja_selected_has_median = ja_selfeat.some(function (feat) {
      return feat.type === 'segment' && sdk.DataModel.Segments.isContainedInBigJunction({ segmentId: feat.id });
    });

    // IDs of the segments explicitly selected by the user. Used to restrict far-turn marker
    // drawing to only the selected entry segment(s), matching how regular departure-mode
    // markers work (angles shown FROM the selected segment, not FROM all segments at the node).
    //
    // When a node is selected directly (no segment in ja_selfeat), this array is empty — in
    // that case ja_draw_far_turn_markers shows far turns from ALL connected non-median segments.
    var ja_selected_seg_ids = ja_selfeat
      .filter(function (feat) {
        return feat.type === 'segment';
      })
      .map(function (feat) {
        return feat.id;
      });

    if (ja_draw_node_markers(ja_marker_nodes, ja_label_distance, doubleTurns, ja_selected_has_median, ja_selected_seg_ids, allBigJunctions, ja_is_pure_node_selection)) {
      return;
    }

    ja_last_restart = 0;
    var ja_end_time = Date.now();
    ja_log('Calculation took ' + String(ja_end_time - ja_start_time) + ' ms', 3);

    // After on-demand rendering completes, only re-render continuous markers if NO selection active
    // When user has something selected, on-demand markers take priority (continuous would clutter the view)
    // When user deselects, continuous markers reappear to show background problems
    if (ja_continuous_mode && !hasSelection()) {
      ja_log('No selection active. Re-rendering continuous markers...', 2);
      ja_render_continuous_problems();
    }
  }

  /**
   * Alias for testSelectedItem used by deferred callers (ja_apply, ja_calculation_timer).
   *
   * Some paths need to schedule a recalculation after an async delay (e.g. after
   * settings change). They call ja_calculate_real() by reference captured before
   * the alias is established. Both names refer to the same function.
   */
  // Alias so ja_calculate_real() calls (from timer/ja_apply) work
  var ja_calculate_real = testSelectedItem;

  /*
   * Drawing functions
   */

  /**
   * Predicts the Waze routing instruction for a turn from one segment to another.
   *
   * Implements a multi-step decision tree:
   * 1. Checks for a manually overridden turn instruction (instructionOpCode).
   * 2. Classifies by angle: BC (best continuation / no instruction), Keep Left/Right,
   *    Turn Left/Right, U-Turn, or gray-zone PROBLEM.
   * 3. Refines Keep vs BC using street name continuity, road type hierarchy, and
   *    segment count to determine which exit is the "straight-through" road.
   * 4. Handles ramp and exit-road special cases.
   *
   * @param {Object} node - SDK Node object at the junction.
   * @param {number} s_in_id - Segment ID of the incoming road.
   * @param {number} s_out_id - Segment ID of the candidate exit.
   * @param {Array} angles - All bearing/segmentId/isSelected triples at this node.
   * @param {boolean} [logRestrictions=true] - If false, suppresses logging of turn blocks (used to reduce redundant logs during batch processing and far-turn evaluations).
   * @returns {string} A ja_routing_type value string (e.g., 'junction_turn', 'junction_keep_left', 'junction_problem').
   */
  function ja_guess_routing_instruction(node, s_in_a, s_out_a, angles, logRestrictions) {
    if (typeof logRestrictions === 'undefined') logRestrictions = true;  // Default: log turn blocks
    var s_n = {},
      s_in = null,
      s_out = {},
      street_n = {},
      street_in = null,
      angle;
    var s_in_id = s_in_a;
    var s_out_id = s_out_a;

    s_in_a = angles.filter(function (element) {
      return element[1] === s_in_a;
    });
    s_out_a = angles.filter(function (element) {
      return element[1] === s_out_a;
    });

    node.connectedSegmentIds.forEach(function (element) {
      if (element === s_in_id) {
        s_in = sdk.DataModel.Segments.getById({ segmentId: element });
        street_in = ja_get_streets(element);
        if (street_in.primary == null) {
          street_in.primary = { name: '' };
        } else if (street_in.primary.name == null) {
          street_in.primary.name = '';
        }
      } else {
        if (element === s_out_id) {
          s_out[element] = sdk.DataModel.Segments.getById({ segmentId: element });
          if (typeof s_out[element].primary === 'undefined') {
            s_out[element].primary = { name: '' };
          }
        }
        s_n[element] = sdk.DataModel.Segments.getById({ segmentId: element });
        street_n[element] = ja_get_streets(element);
        if (street_n[element].primary == null) {
          street_n[element].primary = { name: '' };
        }
      }
    });

    if (s_in === null || street_in === null) {
      return ja_routing_type.PROBLEM;
    }

    angle = ja_angle_diff(s_in_a[0], s_out_a[0], false);
    ja_log('Angle: ' + angle.toFixed(1) + '°', 4);

    if (!ja_is_turn_allowed(s_in, node, s_out[s_out_id])) {
      if (logRestrictions) {
        ja_log('Turn blocked: ' + s_in_id + ' → ' + s_out_id + ' via ' + node.id, 2);
      }
      return ja_routing_type.NO_TURN;
    }

    //seb-d59: Check override instruction
    if (ja_getOption('override')) {
      var turns = sdk.DataModel.Turns.getTurnsThroughNode({ nodeId: node.id });
      var overrideTurn = turns.find(function (t) {
        return t.fromSegmentId === s_in_id && t.toSegmentId === s_out_id;
      });
      var opcode = overrideTurn ? overrideTurn.instructionOpCode : null;
      if (opcode !== null) {
        var opcodeType = ja_opcode_to_routing_type(opcode, true);
        if (opcodeType !== null) {
          return opcodeType;
        }
      }
    }

    //Roundabout - no true instruction guessing here!
    if (s_in.junctionId) {
      if (s_out[s_out_id].junctionId) {
        ja_log('Roundabout continuation (BC)', 3);
        return ja_routing_type.BC;
      } else {
        ja_log('Roundabout exit', 3);
        return ja_routing_type.ROUNDABOUT_EXIT;
      }
    } else if (s_out[s_out_id].junctionId) {
      ja_log('Roundabout entry (BC)', 3);
      return ja_routing_type.BC;
    }

    if (Math.abs(angle) > U_TURN_ANGLE + GRAY_ZONE) {
      ja_log('U-TURN: ' + s_in_id + ' → ' + s_out_id + ' (' + angle.toFixed(1) + '°)', 2);
      return ja_routing_type.U_TURN;
    } else if (Math.abs(angle) > U_TURN_ANGLE - GRAY_ZONE) {
      ja_log('U-TURN gray zone: ' + s_in_id + ' → ' + s_out_id + ' (' + angle.toFixed(1) + '°)', 3);
      return ja_routing_type.PROBLEM;
    }

    if (node.connectedSegmentIds.length <= 2) {
      ja_log('Only 2 segments at node ' + node.id + ': BC (no instruction)', 2);
      return ja_routing_type.BC;
    }

    if (Math.abs(angle) < TURN_ANGLE - GRAY_ZONE) {
      ja_log('Angle < 44° (BC eval zone)', 4);

      angles = angles.filter(function (a) {
        if (s_out_id === a[1] || (typeof s_n[a[1]] !== 'undefined' && ja_is_turn_allowed(s_in, node, s_n[a[1]]) && Math.abs(ja_angle_diff(s_in_a, a[0], false)) < TURN_ANGLE)) {
          return true;
        } else {
          if (street_n[a[1]]) {
            delete s_n[a[1]];
            delete street_n[a[1]];
          }
          return false;
        }
      });

      if (angles.length <= 1) {
        return ja_routing_type.BC;
      }

      var bc_matches = {},
        bc_prio = 0,
        bc_count = 0;
      /**
       * Accumulates a BC candidate into `bc_matches` using a priority-wins strategy.
       *
       * If `prio` is higher than the current best priority, the candidate set is reset and
       * only this candidate is kept.  Candidates at the same priority are added alongside
       * existing ones.  Lower-priority candidates are silently ignored.  The final `bc_matches`
       * map contains only the highest-priority candidates, used to decide whether to assign a
       * BC instruction or fall through to Keep/Turn classification.
       *
       * @param {Array} a - Candidate angle tuple (format: `[angle, segmentId, ...]`).
       * @param {number} prio - Priority level; higher values win (e.g. name-match > road-type).
       */
      var bc_collect = function (a, prio) {
        if (prio > bc_prio) {
          bc_matches = {};
          bc_prio = prio;
          bc_count = 0;
        }
        if (prio === bc_prio) {
          bc_matches[a[1]] = a;
          bc_count++;
        }
      };

      for (var k = 0; k < angles.length; k++) {
        var a = angles[k];
        var tmp_s_out = {};
        tmp_s_out[a[1]] = s_n[a[1]];
        var tmp_street_out = {};
        tmp_street_out[a[1]] = street_n[a[1]];
        var name_match = ja_primary_name_match(street_in, tmp_street_out) || ja_alt_name_match(street_in, tmp_street_out) || ja_cross_name_match(street_in, tmp_street_out);
        if (name_match && ja_segment_type_match(s_in, tmp_s_out)) {
          bc_collect(a, 3);
        } else if (name_match) {
          bc_collect(a, 2);
        } else if (ja_segment_type_match(s_in, tmp_s_out)) {
          bc_collect(a, 1);
        }
      }

      if (bc_matches[s_out_id] !== undefined && bc_count === 1) {
        ja_log('BC found: ' + s_in_id + ' → ' + s_out_id + ' via ' + node.id + ' (straight)', 2);
        return ja_routing_type.BC;
      }

      angles.sort(function (a, b) {
        return ja_angle_dist(a[0], s_in_a[0][0]) - ja_angle_dist(b[0], s_in_a[0][0]);
      });

      if (!ja_is_left_hand_traffic) {
        //RHT
        if (angles[0][1] === s_out_id && !ja_overlapping_angles(angles[0][0], angles[1][0])) {
          ja_log('BC → KEEP_LEFT (leftmost <45°)', 3);
          return ja_routing_type.KEEP_LEFT;
        }
      } else {
        //LHT
        if (angles[angles.length - 1][1] === s_out_id && !ja_overlapping_angles(angles[angles.length - 1][0], angles[angles.length - 2][0])) {
          ja_log('BC → KEEP_RIGHT (rightmost <45°)', 3);
          return ja_routing_type.KEEP_RIGHT;
        }
      }

      var overlap_i = 1;
      while (overlap_i < angles.length && ja_overlapping_angles(angles[0][0], angles[overlap_i][0])) {
        ++overlap_i;
      }
      if (overlap_i > 1 && overlap_i === angles.length) {
        return ja_routing_type.BC;
      }

      if (ja_is_primary_road(s_in) && !ja_is_primary_road(s_out[s_out_id])) {
        ja_log('Primary→non-primary = EXIT', 3);
        return ja_is_left_hand_traffic ? ja_routing_type.EXIT_LEFT : ja_routing_type.EXIT_RIGHT;
      }
      if (ja_is_ramp(s_in) && !ja_is_primary_road(s_out[s_out_id]) && !ja_is_ramp(s_out[s_out_id])) {
        ja_log('Ramp→non-primary = EXIT', 3);
        return ja_is_left_hand_traffic ? ja_routing_type.EXIT_LEFT : ja_routing_type.EXIT_RIGHT;
      }

      return ja_is_left_hand_traffic ? ja_routing_type.KEEP_LEFT : ja_routing_type.KEEP_RIGHT;
    } else if (Math.abs(angle) < TURN_ANGLE + GRAY_ZONE) {
      ja_log('PROBLEM gray zone: ' + s_in_id + ' → ' + s_out_id + ' via ' + node.id + ' (' + angle.toFixed(1) + '°)', 2);
      return ja_routing_type.PROBLEM;
    } else {
      // Use centralized angle classification for consistency
      return ja_classify_turn_angle(angle);
    }
  }
  /**
   * Places a single angle marker feature on the junction_angles layer.
   *
   * Handles label text formatting for both Fancy (arrow on own line) and Simple
   * (arrow inline) display styles, then checks for overlap with already-placed
   * markers (ja_current_features). If the candidate point overlaps an existing
   * marker, nudges it outward in the direction of ha until clear. Adds the feature
   * via sdk.Map.addFeatureToLayer with properties that drive the styleRules.
   *
   * @param {GeoJSON.Point} point - Initial candidate geometry for the marker.
   * @param {Object} node - SDK Node object at the junction.
   * @param {number} ja_label_distance - Base offset distance in meters.
   * @param {number} a - Signed turn angle in degrees to display.
   * @param {number} ha - Bearing from the node to the marker (for nudge direction).
   * @param {boolean} [withRouting=false] - True: include routing instruction type and arrow.
   * @param {string} [ja_junction_type] - ja_routing_type value; required when withRouting is true.
   * @param {boolean} [isFarTurn=false] - True for far-turn markers (path/JB breadcrumbs).
   * @param {boolean} [isSquareMarker=false] - True to render as square; false for round (intermediate breadcrumb).
   */
  function ja_draw_marker(point, node, ja_label_distance, a, ha, withRouting, ja_junction_type, isFarTurn, isSquareMarker) {
    //Try to estimate of the point is "too close" to another point
    //(or maybe something else in the future; like turn restriction arrows or something)
    //FZ69617: Exctract initial label distance from point
    var ja_tmp_distance = turf.distance(turf.point(node.geometry.coordinates), turf.point(point.coordinates)) * 1000; // meters
    ja_log('Starting distance estimation', 3);
    while (
      ja_current_features.some(function (feat) {
        if (feat.ja_type !== 'roundaboutOverlay') {
          var dist = turf.distance(turf.point(feat.coordinates), turf.point(point.coordinates)) * 1000;
          if (ja_label_distance / 1.4 > dist) {
            ja_log(ja_label_distance / 1.5 > dist + ' is kinda close..', 3);
            return true;
          }
        }
        return false;
      })
    ) {
      //add 1/4 of the original distance and hope for the best =)
      ja_tmp_distance += ja_label_distance / 4;
      ja_log('setting distance to ' + ja_tmp_distance, 3);
      point = turf.destination(turf.point(node.geometry.coordinates), ja_tmp_distance / 1000, ja_math_to_compass(ha)).geometry;
    }
    ja_log('Distance estimation done', 3);

    var angleString = ja_round(Math.abs(a)) + '°';

    //FZ69617: Add direction arrows for turn instructions only
    if (ja_getOption('angleDisplay') === 'displaySimple') {
      switch (ja_junction_type) {
        case ja_routing_type.TURN:
          angleString = a > 0 ? ja_arrow.left() + angleString : angleString + ja_arrow.right();
          break;
        case ja_routing_type.TURN_LEFT:
          angleString = ja_arrow.left() + angleString;
          break;
        case ja_routing_type.TURN_RIGHT:
          angleString = angleString + ja_arrow.right();
          break;
        case ja_routing_type.EXIT:
        case ja_routing_type.KEEP:
          angleString = a > 0 ? ja_arrow.left_up() + angleString : angleString + ja_arrow.right_up();
          break;
        case ja_routing_type.EXIT_LEFT:
        case ja_routing_type.KEEP_LEFT:
          angleString = ja_arrow.left_up() + angleString;
          break;
        case ja_routing_type.EXIT_RIGHT:
        case ja_routing_type.KEEP_RIGHT:
          angleString += ja_arrow.right_up();
          break;
        //Override
        case ja_routing_type.OverrideBC:
          angleString = ja_getOption('overrideAngles') ? angleString : '';
          break;
        case ja_routing_type.OverrideCONTINUE:
          angleString = ja_arrow.up() + (ja_getOption('overrideAngles') ? angleString : '');
          break;
        case ja_routing_type.OverrideTURN_LEFT:
          angleString = ja_arrow.left() + (ja_getOption('overrideAngles') ? angleString : '');
          break;
        case ja_routing_type.OverrideTURN_RIGHT:
          angleString = (ja_getOption('overrideAngles') ? angleString : '') + ja_arrow.right();
          break;
        case ja_routing_type.OverrideEXIT_LEFT:
        case ja_routing_type.OverrideKEEP_LEFT:
          angleString = ja_arrow.left_up() + (ja_getOption('overrideAngles') ? angleString : '');
          break;
        case ja_routing_type.OverrideEXIT_RIGHT:
        case ja_routing_type.OverrideKEEP_RIGHT:
          angleString = (ja_getOption('overrideAngles') ? angleString : '') + ja_arrow.right_up();
        default:
          ja_log('No extra format for junction type: ' + ja_junction_type, 3);
      }
    } else {
      switch (ja_junction_type) {
        case ja_routing_type.TURN:
          angleString = (a > 0 ? ja_arrow.left() : ja_arrow.right()) + '\n' + angleString;
          break;
        case ja_routing_type.TURN_LEFT:
          angleString = ja_arrow.left() + '\n' + angleString;
          break;
        case ja_routing_type.TURN_RIGHT:
          angleString = ja_arrow.right() + '\n' + angleString;
          break;
        case ja_routing_type.EXIT:
        case ja_routing_type.KEEP:
          angleString = (a > 0 ? ja_arrow.left_up() : ja_arrow.right_up()) + '\n' + angleString;
          break;
        case ja_routing_type.EXIT_LEFT:
        case ja_routing_type.KEEP_LEFT:
          angleString = ja_arrow.left_up() + '\n' + angleString;
          break;
        case ja_routing_type.EXIT_RIGHT:
        case ja_routing_type.KEEP_RIGHT:
          angleString = ja_arrow.right_up() + '\n' + angleString;
          break;
        case ja_routing_type.PROBLEM:
          angleString = '?\n' + angleString;
          break;
        //Override
        case ja_routing_type.OverrideBC:
          angleString = ja_getOption('overrideAngles') ? angleString : '';
          break;
        case ja_routing_type.OverrideCONTINUE:
          angleString = ja_arrow.up() + (ja_getOption('overrideAngles') ? '\n' + angleString : '');
          break;
        case ja_routing_type.OverrideTURN_LEFT:
          angleString = ja_arrow.left() + (ja_getOption('overrideAngles') ? '\n' + angleString : '');
          break;
        case ja_routing_type.OverrideTURN_RIGHT:
          angleString = ja_arrow.right() + (ja_getOption('overrideAngles') ? '\n' + angleString : '');
          break;
        case ja_routing_type.OverrideEXIT_LEFT:
        case ja_routing_type.OverrideKEEP_LEFT:
          angleString = ja_arrow.left_up() + (ja_getOption('overrideAngles') ? '\n' + angleString : '');
          break;
        case ja_routing_type.OverrideEXIT_RIGHT:
        case ja_routing_type.OverrideKEEP_RIGHT:
          angleString = ja_arrow.right_up() + (ja_getOption('overrideAngles') ? '\n' + angleString : '');
          break;
        default:
          ja_log('No extra format for junction type: ' + ja_junction_type, 3);
      }
    }

    var angleProps = withRouting
      ? { angle: angleString, ja_type: ja_junction_type, ja_is_far_turn: !!isFarTurn, ja_is_square_marker: !!isSquareMarker }
      : { angle: ja_round(a) + '°', ja_type: 'generic', ja_is_square_marker: !!isSquareMarker };
    ja_log(angleProps, 4);

    //Don't paint points inside an overlaid roundabout
    if (
      ja_roundabout_points.some(function (roundaboutPolygon) {
        return turf.booleanPointInPolygon(turf.point(point.coordinates), roundaboutPolygon);
      })
    ) {
      return;
    }

    //Draw a line to the point
    ja_add_feature({ type: 'LineString', coordinates: [node.geometry.coordinates, point.coordinates] }, { ja_type: 'arrow_line' });

    //push the angle point
    ja_current_features.push({ coordinates: point.coordinates, ja_type: angleProps.ja_type });
    ja_add_feature(point, angleProps);
  }

  /**
   * Draws a circle polygon overlay on the junction_angles layer for one or all roundabouts.
   *
   * When called with a junctionId, draws a single circle for that junction.
   * When called without arguments, draws circles for every junction in the data model
   * (used for the 'rOverAlways' display mode).
   *
   * The circle radius is the mean distance from the WME junction center point to all
   * of its arc nodes, giving an approximate fit to the physical road ring. Drawn as a
   * 40-step Turf.js polygon feature styled by the roundaboutOverlayColor setting.
   *
   * @param {number} [junctionId] - Optional. If provided, draws only this junction's circle.
   */
  function ja_draw_roundabout_overlay(junctionId) {
    (junctionId === undefined
      ? sdk.DataModel.Junctions.getAll()
      : (function (junction) {
          return junction === undefined ? [] : [junction];
        })(sdk.DataModel.Junctions.getById({ junctionId: junctionId }))
    ).forEach(function (element) {
      ja_log(element, 3);
      var nodes = {};
      element.segmentIds.forEach(function (s) {
        var seg = sdk.DataModel.Segments.getById({ segmentId: s });
        ja_log(seg, 3);
        // Guard against null node IDs (can happen with unsaved segments)
        if (seg.fromNodeId) nodes[seg.fromNodeId] = sdk.DataModel.Nodes.getById({ nodeId: seg.fromNodeId });
        if (seg.toNodeId) nodes[seg.toNodeId] = sdk.DataModel.Nodes.getById({ nodeId: seg.toNodeId });
      });

      ja_log(nodes, 3);
      var center = element.geometry; // GeoJSON Point
      ja_log(center, 3);
      var distances = [];
      Object.getOwnPropertyNames(nodes).forEach(function (name) {
        ja_log('Checking ' + name + ' distance', 3);
        // Skip if node is null or doesn't have geometry (unsaved segments can cause this)
        if (nodes[name] && nodes[name].geometry) {
          var dist = turf.distance(turf.point(nodes[name].geometry.coordinates), turf.point(center.coordinates)) * 1000;
          distances.push(dist);
        }
      });
      ja_log(distances, 3);
      // Skip circle drawing if no valid nodes (all were null due to unsaved segments)
      if (distances.length === 0) {
        ja_log('No valid nodes for roundabout overlay (unsaved segments?)', 2);
        return;
      }
      var meanDistM =
        distances.reduce(function (a, b) {
          return a + b;
        }) / distances.length;
      ja_log('Mean distance is ' + meanDistM, 3);

      var circleGeom = turf.circle(turf.point(center.coordinates), meanDistM / 1000, { steps: 40 }).geometry;
      ja_roundabout_points.push(circleGeom);
      ja_add_feature(circleGeom, { ja_type: 'roundaboutOverlay' });
    });
  }

  /*
   * Segment and routing helpers
   */

  /**
   * Returns true if the road type of segment_in matches any road type in the segments array.
   *
   * Used by ja_guess_routing_instruction() to compare incoming and outgoing road type
   * hierarchies when deciding between Keep and BC (best continuation) instructions.
   *
   * @param {Object} segment_in - SDK Segment object whose road type to test.
   * @param {Object[]} segments - Array of SDK Segment objects to match against.
   * @returns {boolean} True if any segment in the array shares segment_in's road type.
   */
  function ja_segment_type_match(segment_in, segments) {
    ja_log(segment_in, 4);
    ja_log(segments, 4);

    return Object.getOwnPropertyNames(segments).some(function (segment_n_id, index) {
      var segment_n = segments[segment_n_id];
      ja_log('[segment_type_match] Checking element ' + index, 3);
      ja_log(segment_n, 4);
      if (segment_n.id === segment_in.id) {
        return false;
      }
      ja_log('[segment_type_match] roadType ' + segment_n.roadType + ' vs ' + segment_in.roadType, 3);
      return segment_n.roadType === segment_in.roadType;
    });
  }

  /**
   * Returns true if the segment is a primary street or major/minor highway.
   *
   * @param {Object} seg - SDK Segment object.
   * @returns {boolean}
   */
  function ja_is_primary_road(seg) {
    var t = seg.roadType;
    return t === ja_road_type.FREEWAY || t === ja_road_type.MAJOR_HIGHWAY || t === ja_road_type.MINOR_HIGHWAY;
  }

  /**
   * Returns true if the segment qualifies for double U-turn detection.
   * Primary Street and above are always included. Street, Parking Lot Road, and
   * Private Road are opt-in via the U-Turn detection settings.
   * @param {Object} seg - SDK Segment object.
   * @returns {boolean}
   */
  function ja_is_uturn_qualifying_road(seg) {
    var t = seg.roadType;
    if (t === ja_road_type.FREEWAY || t === ja_road_type.RAMP || t === ja_road_type.MAJOR_HIGHWAY || t === ja_road_type.MINOR_HIGHWAY || t === ja_road_type.PRIMARY_STREET) return true;
    if (t === ja_road_type.STREET && ja_getOption('uTurnIncludeStreet')) return true;
    if (t === ja_road_type.PARKING_LOT_ROAD && ja_getOption('uTurnIncludeParkingLot')) return true;
    if (t === ja_road_type.PRIVATE_ROAD && ja_getOption('uTurnIncludePrivateRoad')) return true;
    return false;
  }

  /**
   * Returns true if the segment is classified as a ramp.
   *
   * @param {Object} seg - SDK Segment object.
   * @returns {boolean}
   */
  function ja_is_ramp(seg) {
    var t = seg.roadType;
    return t === ja_road_type.RAMP;
  }

  /**
   * Returns true if the turn from segmentId at nodeId toward toSegmentId has
   * lane guidance configured (turn.lanes !== null).
   *
   * Per the Waze U-turn spec, a median 31–49 m long qualifies for double-turn
   * detection only when the incoming segment has lane guidance set up on its
   * approach to the median junction node.
   *
   * @param {number} segmentId - ID of the incoming segment.
   * @param {number} nodeId - ID of the junction node shared with the median.
   * @param {number} toSegmentId - ID of the median segment.
   * @returns {boolean}
   */
  function ja_segment_has_lane_guidance(segmentId, nodeId, toSegmentId) {
    var turns = sdk.DataModel.Turns.getTurnsFromSegment({ segmentId: segmentId, nodeId: nodeId });
    if (!turns) return false;
    for (var i = 0; i < turns.length; i++) {
      if (turns[i].toSegmentId === toSegmentId && turns[i].lanes !== null) {
        return true;
      }
    }
    return false;
  }

  /**
   * Checks if two segments (A and C) are within a specified parallelism tolerance.
   *
   * Computes the bearing of segment A at its exit node and the bearing of segment C
   * at its entry node, then calculates the absolute angular difference. Returns true
   * if the difference is within the specified tolerance.
   *
   * @param {Object} segA - SDK Segment object for the incoming segment (A).
   * @param {Object} segC - SDK Segment object for the outgoing segment (C).
   * @param {number} nodeA_exit - Node ID at the exit of segment A (where A connects to B).
   * @param {number} nodeC_entry - Node ID at the entry of segment C (where B connects to C).
   * @param {number} toleranceDegrees - Maximum angular difference in degrees to consider parallel.
   * @returns {boolean} True if segments are within tolerance; false otherwise.
   */
  function ja_is_segments_parallel(segA, segC, nodeA_exit, nodeC_entry, toleranceDegrees) {
    var bearingA = ja_getAngle(nodeA_exit, segA);
    var bearingC = ja_getAngle(nodeC_entry, segC);
    if (bearingA === null || bearingC === null) {
      return false;
    }
    var diff = Math.abs(ja_angle_diff(bearingA, bearingC, true));
    ja_log('Parallelism check: A=' + ja_round(bearingA) + '° C=' + ja_round(bearingC) + '° diff=' + ja_round(diff) + '° tolerance=' + toleranceDegrees + '°', 3);
    return diff <= toleranceDegrees;
  }

  /**
   * Adds a GeoJSON feature to the junction_angles layer with an auto-incremented ID.
   * @param {Object} geometry - GeoJSON geometry object.
   * @param {Object} properties - Feature properties (must include ja_type).
   */
  function ja_add_feature(geometry, properties) {
    sdk.Map.addFeatureToLayer({
      layerName: 'junction_angles',
      feature: {
        id: 'ja_' + ++ja_feature_counter,
        type: 'Feature',
        geometry: geometry,
        properties: properties,
      },
    });
  }

  /**
   * Checks if a roundabout angle is "normal" per Waze normality criterion.
   * An angle is normal if it's within ±PERPENDICULAR_TOLERANCE° of a 90° multiple (0°, 90°, 180°, 270°).
   *
   * @param {number} angle - Triangle angle in degrees (0–180°).
   * @returns {boolean}
   */
  function ja_is_angle_normal(angle) {
    var mod = Math.abs(angle % 90);
    return mod <= PERPENDICULAR_TOLERANCE || mod >= 90 - PERPENDICULAR_TOLERANCE;
  }

  /**
   * Corrects a base label distance for EPSG:3857 latitude distortion.
   * EPSG:3857 projected units equal true meters only at the equator; at latitude φ
   * they are stretched by 1/cos(φ). Multiplying by cos(φ) converts back to true meters
   * so that turf.destination offsets match the original OpenLayers distances.
   * @param {number} labelDistance - Base label offset in meters.
   * @param {number[]} coordinates - GeoJSON [lon, lat] coordinate pair.
   * @returns {number} Latitude-corrected label distance in meters.
   */
  function ja_corrected_ld(labelDistance, coordinates) {
    return labelDistance * Math.cos((coordinates[1] * Math.PI) / 180);
  }

  /**
   * Checks if a segment crosses any BigJunction boundary (one endpoint inside, one outside).
   *
   * Centralizes the duplicated boundary-crossing detection logic that appears in:
   * - ja_collect_double_turns() (2 passes for median & arm segments)
   * - ja_draw_node_markers() (exit marker relocation)
   * - ja_draw_far_turn_markers() (fallback JB detection)
   *
   * @param {Object} seg - SDK Segment object to check.
   * @param {Array} allBigJunctions - Array from sdk.DataModel.BigJunctions.getAll().
   * @returns {Object|null} If segment crosses a boundary, returns
   *   `{ crosses: true, insideNodeId: number, outsideNodeId: number, bjPolygon: object }`.
   *   If no crossing, returns `{ crosses: false, insideNodeId: null, outsideNodeId: null, bjPolygon: null }`.
   */
  function ja_segment_crosses_bj_boundary(seg, allBigJunctions) {
    if (!seg || !allBigJunctions || allBigJunctions.length === 0) {
      return { crosses: false, insideNodeId: null, outsideNodeId: null, bjPolygon: null };
    }

    var fromCoords = { id: seg.fromNodeId, coordinates: seg.geometry.coordinates[0] };
    var toCoords = { id: seg.toNodeId, coordinates: seg.geometry.coordinates[seg.geometry.coordinates.length - 1] };

    for (var bji = 0; bji < allBigJunctions.length; bji++) {
      var bjPolygon = turf.polygon(allBigJunctions[bji].geometry.coordinates);
      var fromInside = turf.booleanPointInPolygon(turf.point(fromCoords.coordinates), bjPolygon);
      var toInside = turf.booleanPointInPolygon(turf.point(toCoords.coordinates), bjPolygon);

      // If one endpoint inside and one outside, segment crosses JB boundary
      if ((fromInside && !toInside) || (!fromInside && toInside)) {
        return {
          crosses: true,
          insideNodeId: fromInside ? seg.fromNodeId : seg.toNodeId,
          outsideNodeId: toInside ? seg.fromNodeId : seg.toNodeId,
          bjPolygon: bjPolygon,
        };
      }
    }

    return { crosses: false, insideNodeId: null, outsideNodeId: null, bjPolygon: null };
  }

  /**
   * Returns true if a legal turn exists from s_from through via_node onto s_to.
   *
   * Delegates to sdk.DataModel.Turns.isTurnAllowed() for the base restriction check,
   * then applies additional filtering for time- and vehicle-restricted turns (date
   * ranges, vehicle type bitmasks) to reflect real-world drivability rather than
   * just the static restriction flag.
   *
   * @param {Object} s_from - SDK Segment object for the incoming road.
   * @param {Object} via_node - SDK Node object at the junction.
   * @param {Object} s_to - SDK Segment object for the outgoing road.
   * @returns {boolean} True if the turn is allowed for a typical passenger vehicle.
   */
  function ja_is_turn_allowed(s_from, via_node, s_to) {
    if (!sdk.DataModel.Turns.isTurnAllowedBySegmentDirections({ fromSegmentId: s_from.id, nodeId: via_node.id, toSegmentId: s_to.id })) {
      ja_log('Turn ' + s_from.id + ' → ' + s_to.id + ' via ' + via_node.id + ': blocked (direction)', 3);
      return false;
    }

    var allowed = sdk.DataModel.Turns.isTurnAllowed({ fromSegmentId: s_from.id, nodeId: via_node.id, toSegmentId: s_to.id });
    ja_log('Turn ' + s_from.id + ' → ' + s_to.id + ' via ' + via_node.id + ': ' + (allowed ? 'allowed' : 'blocked'), 3);
    return allowed;
  }

  /**
   * From wiki:
   * A Cross-match is when the primary name of one segment is identical to the alternate name of an adjacent segment.
   * It had the same priory as a Primary name match. In order for a Cross match to work there must be at least one
   * alt name on both involved segments (even though they don't necessarily match each other). It will work even if
   * the are no Primary names on those segments. It will not work if all three segments at a split have a matching
   * Primary name or a matching Alternate name.
   * @param street_in
   * @param streets
   * @returns {boolean}
   */
  function ja_cross_name_match(street_in, streets) {
    ja_log('[cross_name_match] checking exit streets', 3);
    ja_log(street_in, 4);
    ja_log(streets, 4);
    return Object.getOwnPropertyNames(streets).some(function (street_n_id, index) {
      var street_n_element = streets[street_n_id];
      ja_log('[cross_name_match] Checking element ' + index, 3);
      ja_log(street_n_element, 4);
      return (
        street_in.secondary.some(function (street_in_secondary) {
          ja_log('CN2a: checking n.p: ' + street_n_element.primary.name + ' vs in.s: ' + street_in_secondary.name, 3);

          //wlodek76: CROSS-MATCH works when two compared segments contain at least one ALT NAME
          //when alt name is empty cross-match does not work
          //FZ69617: This no longer seems to be needed
          //if (street_n_element.secondary.length === 0) { return false; }

          return street_n_element.primary.name === street_in_secondary.name;
        }) ||
        street_n_element.secondary.some(function (street_n_secondary) {
          ja_log('CN2b: checking in.p: ' + street_in.primary.name + ' vs n.s: ' + street_n_secondary.name, 3);

          //wlodek76: CROSS-MATCH works when two compared segments contain at least one ALT NAME
          //when alt name is empty cross-match does not work
          //FZ69617: This no longer seems to be needed
          //if (street_in.secondary.length === 0) { return false; }

          //wlodek76: missing return from checking primary name with alternate names
          return street_in.primary.name === street_n_secondary.name;
        })
      );
    });
  }

  /**
   * Returns true if any segment in `streets` shares at least one alternate (secondary) name
   * with `street_in`.
   *
   * Both sides must have at least one alternate name — if either is empty the check short-circuits
   * to false, matching the WME routing behaviour (cross-match only works when both sides carry an
   * alt name).
   *
   * @param {{ primary: object, secondary: Array }} street_in - Street info of the inbound segment.
   * @param {Object.<string, { primary: object, secondary: Array }>} streets - Street info keyed by
   *   segment id for all candidate exit segments at the junction.
   * @returns {boolean} True if an alt-name match is found.
   */
  function ja_alt_name_match(street_in, streets) {
    return Object.getOwnPropertyNames(streets).some(function (street_n_id, index) {
      var street_n_element = streets[street_n_id];
      ja_log('[alt_name_match] Checking element ' + index, 3);
      ja_log(street_n_element, 4);

      if (street_in.secondary.length === 0) {
        return false;
      }
      if (street_n_element.secondary.length === 0) {
        return false;
      }

      return street_in.secondary.some(function (street_in_secondary, index2) {
        ja_log('[alt_name_match] Nested check element ' + index2, 3);
        ja_log(street_in_secondary, 4);
        return street_n_element.secondary.some(function (street_n_secondary_element, index3) {
          ja_log('[alt_name_match] in.secondary: ' + street_in_secondary.name + ' vs n.secondary[' + index3 + ']: ' + street_n_secondary_element.name, 3);
          return street_in_secondary.name === street_n_secondary_element.name;
        });
      });
    });
  }

  /**
   * Returns true if any segment in `streets` has the same primary name as `street_in`.
   *
   * Used as the first-pass name check in BC (best-continuation) routing logic: if the
   * incoming road name continues on an exit, that exit is a strong BC candidate.
   *
   * @param {{ primary: { name: string }, secondary: Array }} street_in - Street info of the
   *   inbound segment.
   * @param {Object.<string, { primary: { name: string }, secondary: Array }>} streets - Street
   *   info keyed by segment id for all candidate exit segments at the junction.
   * @returns {boolean} True if a primary-name match is found.
   */
  function ja_primary_name_match(street_in, streets) {
    ja_log('[primary_name_match] checking candidates', 3);
    ja_log(street_in, 4);
    ja_log(streets, 4);
    return Object.getOwnPropertyNames(streets).some(function (id, index, array) {
      var element = streets[id];
      ja_log('[primary_name_match] Checking element ' + index + ' of ' + array.length, 3);
      ja_log(element, 4);
      return element.primary.name === street_in.primary.name;
    });
  }

  /**
   * Returns the primary and alternate street names for a segment.
   *
   * Looks up the segment's `primaryStreetId` and `alternateStreetIds` via the SDK Streets model
   * and returns them in a shape that `ja_primary_name_match`, `ja_cross_name_match`, and
   * `ja_alt_name_match` can consume directly.
   *
   * @param {number} segmentId - WME segment id.
   * @returns {{ primary: object|undefined, secondary: Array }} Object with `primary` set to the
   *   SDK Street object (or undefined if none) and `secondary` as an array of SDK Street objects
   *   for each alternate street.
   */
  function ja_get_streets(segmentId) {
    var segment = sdk.DataModel.Segments.getById({ segmentId: segmentId });
    var primary = segment && segment.primaryStreetId != null ? sdk.DataModel.Streets.getById({ streetId: segment.primaryStreetId }) : undefined;
    var secondary = [];
    if (segment) {
      segment.alternateStreetIds.forEach(function (element) {
        var street = sdk.DataModel.Streets.getById({ streetId: element });
        if (street != null) {
          secondary.push(street);
        }
      });
    }
    ja_log(primary, 3);
    ja_log(secondary, 3);
    return { primary: primary, secondary: secondary };
  }

  /**
   * Computes segment's length in meters
   * @param segment Segment to compute the length of
   * @returns {number}
   */
  function ja_segment_length(segment) {
    ja_log('segment: ' + segment.id + ' len: ' + segment.length, 3);
    return segment.length;
  }

  /**
   * Checks whether the two segments (connected at the same node) overlap each other.
   * @param a1 Angle of the 1st segment
   * @param a2 Angle of the 2nd segment
   */
  function ja_overlapping_angles(a1, a2) {
    // If two angles are close < 2 degree they are overlapped.
    // Method of recognizing overlapped segment by server is unknown for me yet, I took this from WME Validator
    // information about this.
    // TODO: verify overlapping check on the side of routing server.
    return Math.abs(ja_angle_diff(a1, a2, true)) < OVERLAPPING_ANGLE;
  }

  /*
   * Misc math and map element functions
   */

  /**
   *
   * @param p0 From point
   * @param p1 Center point
   * @param p2 To point
   * @returns {number}
   */
  function ja_angle_between_points(p0, p1, p2) {
    ja_log('p0 ' + p0, 3);
    ja_log('p1 ' + p1, 3);
    ja_log('p2 ' + p2, 3);
    // Uses turf.distance for accurate WGS84 distances (inputs are GeoJSON Point geometries)
    var a2 = Math.pow(turf.distance(turf.point(p1.coordinates), turf.point(p0.coordinates)) * 1000, 2);
    var b2 = Math.pow(turf.distance(turf.point(p1.coordinates), turf.point(p2.coordinates)) * 1000, 2);
    var cc2 = Math.pow(turf.distance(turf.point(p2.coordinates), turf.point(p0.coordinates)) * 1000, 2);
    var angle = Math.acos((a2 + b2 - cc2) / Math.sqrt(4 * a2 * b2)) / (Math.PI / 180);
    ja_log('angle is ' + angle, 3);
    return angle;
  }

  /**
   * get absolute (or turn) angle between 2 inputs.
   * 0,90,true	-> 90	0,90,false	-> -90
   * 0,170,true	-> 170	0,170,false	-> -10
   * @param aIn absolute s_in angle (from node)
   * @param aOut absolute s_out angle (from node)
   * @param absolute return absolute or turn angle?
   * @returns {number}
   */
  function ja_angle_diff(aIn, aOut, absolute) {
    var a = parseFloat(aOut) - parseFloat(aIn);
    if (a > 180) {
      a -= 360;
    }
    if (a < -180) {
      a += 360;
    }
    return absolute ? a : a > 0 ? a - 180 : a + 180;
  }

  /**
   * Returns the clockwise angular distance from `a` to `s_in_angle` (0–360°).
   *
   * Used to sort exit segments by how far they are from the incoming road's direction.
   * Wraps negative differences by adding 360 so the result is always non-negative.
   *
   * @param {number} a - The outbound segment angle (math degrees, 0=East CCW).
   * @param {number} s_in_angle - The inbound segment angle (math degrees, 0=East CCW).
   * @returns {number} Clockwise distance in degrees [0, 360).
   */
  function ja_angle_dist(a, s_in_angle) {
    ja_log('Computing out-angle ' + a + ' distance to in-angle ' + s_in_angle, 4);
    var diff = ja_angle_diff(a, s_in_angle, true);
    ja_log('Diff is ' + diff + ', returning: ' + (diff < 0 ? diff + 360 : diff), 4);
    return diff < 0 ? diff + 360 : diff;
  }

  /**
   * Checks whether every exit of a roundabout is within ±PERPENDICULAR_TOLERANCE° of perpendicular (i.e. "normal").
   *
   * For each valid exit node (a `toNodeId` of a junction arc that has at least one drivable
   * outbound non-junction segment), the triangle angle `n_in → roundabout-center → exit-node`
   * is computed.  An exit is non-normal when `angle % 90` falls outside the [0°, PERPENDICULAR_TOLERANCE°] and
   * [90-PERPENDICULAR_TOLERANCE°, 90°] bands.  For every non-normal exit a `±N°` deviation marker is placed just
   * outside the roundabout ring at that node.
   *
   * Side-effect: adds GeoJSON Point features to the `junction_angles` layer for each non-normal
   * exit.
   *
   * @param {number} junctionID - WME Junction id.
   * @param {number} n_in - Node id of the roundabout entry node (excluded from comparison so
   *   the entry road is not mistaken for an exit).
   * @param {number} label_distance - Current label-distance in meters (used to offset the
   *   deviation marker beyond the ring node).
   * @returns {boolean} True if all exits are within ±PERPENDICULAR_TOLERANCE° of perpendicular; false otherwise.
   */
  function ja_is_roundabout_normal(junctionID, n_in, label_distance) {
    ja_log('Check normal roundabout', 3);
    var junction = sdk.DataModel.Junctions.getById({ junctionId: junctionID });
    var nodes = {};
    var numValidExits = 0;
    junction.segmentIds.forEach(function (element, index) {
      var s = sdk.DataModel.Segments.getById({ segmentId: element });
      ja_log('index: ' + index, 3);
      //ja_log(s, 3);
      if (!nodes.hasOwnProperty(s.toNodeId)) {
        ja_log('Adding node id: ' + s.toNodeId, 3);
        //Check if node has allowed exits
        var allowed = false;
        var currNode = sdk.DataModel.Nodes.getById({ nodeId: s.toNodeId });
        ja_log(currNode, 3);
        currNode.connectedSegmentIds.forEach(function (element2) {
          var s_exit = sdk.DataModel.Segments.getById({ segmentId: element2 });
          ja_log(s_exit, 3);
          if (s_exit.junctionId === null) {
            ja_log('Checking: ' + s_exit.id, 3);
            if (sdk.DataModel.Turns.isTurnAllowedBySegmentDirections({ fromSegmentId: s.id, nodeId: currNode.id, toSegmentId: s_exit.id })) {
              //Exit possibly allowed
              ja_log('Exit allowed', 3);
              allowed = true;
            } else {
              ja_log('Exit not allowed', 3);
            }
          } else {
            //part of the junction.. Ignoring
            ja_log(s_exit.id + ' is in the roundabout. ignoring', 3);
          }
        });
        if (allowed) {
          numValidExits++;
          nodes[s.toNodeId] = sdk.DataModel.Nodes.getById({ nodeId: s.toNodeId });
        }
      }
    });

    var is_normal = true;
    ja_log(n_in, 3);
    ja_log(junction, 3);
    ja_log(nodes, 3);

    for (var n in nodes) {
      if (nodes.hasOwnProperty(n)) {
        // for...in always yields string keys; SDK requires a number type
        var n_id = parseInt(n, 10);
        ja_log('Checking ' + n_id, 3);
        if (String(n) === String(n_in)) {
          ja_log('Not comparing to n_in ;)', 3);
        } else {
          var angle = ja_angle_between_points(
            sdk.DataModel.Nodes.getById({ nodeId: n_in }).geometry,
            ja_coordinates_to_point(junction.geometry.coordinates),
            sdk.DataModel.Nodes.getById({ nodeId: n_id }).geometry,
          );
          ja_log('Angle is: ' + angle, 3);
          ja_log('Normalized angle is: ' + (angle % 90), 3);
          ja_log('Angle is: ' + angle, 3);
          // 90 +/- 15 is considered "normal"
          if (ja_is_angle_normal(angle)) {
            ja_log('turn is normal', 3);
          } else {
            ja_log('turn is NOT normal', 3);
            is_normal = false;
            //Push a marker outside the ring to show which exit is "not normal".
            //Offset 1× label_distance beyond the node along the radial (center→node) bearing.
            //Departure markers on the same exit road sit at 2× label_distance from the node,
            //so this leaves a full label_distance gap between the two markers.
            var n_geom = nodes[n].geometry;
            var center_coord = junction.geometry.coordinates;
            var bearing_out = turf.bearing(turf.point(center_coord), turf.point(n_geom.coordinates));
            var ring_radius_km = turf.distance(turf.point(center_coord), turf.point(n_geom.coordinates));
            var ja_ld = ja_corrected_ld(label_distance, n_geom.coordinates);
            var angleMod = Math.abs(angle % 90);
            var marker_geom = turf.destination(turf.point(center_coord), ring_radius_km + ja_ld / 1000, bearing_out).geometry;
            ja_add_feature(marker_geom, {
              angle: '±' + ja_round(Math.min(angleMod, 90 - angleMod)),
              ja_type: ja_routing_type.ROUNDABOUT,
            });
          }
        }
      }
    }
    return is_normal;
  }

  /**
   * Draws exit-angle markers for all valid exits of a roundabout when only the entry segment
   * is selected (no specific exit node in the selection).
   *
   * Applies the full Waze Normal / Non-Normal roundabout instruction rules:
   *
   * Normal (all three criteria must be met for this entry):
   *   1. All exit angles within ±PERPENDICULAR_TOLERANCE° of a multiple of 90° (perpendicular exits)
   *   2. Total junction node count is 2–4
   *   3. Roundabout radius ≤ 25 m
   *   → Each exit is classified as Turn Right / Continue Straight / Turn Left / U-Turn
   *     based on the counterclockwise angle from the entry bearing to the exit bearing
   *     (measured at the roundabout center).  Colors use the matching ja_routing_type.
   *
   * Non-Normal (any criterion fails):
   *   → All exits are labeled "1st", "2nd", "3rd" … in the order they are encountered
   *     when travelling counterclockwise from the entry, using ja_routing_type.ROUNDABOUT (the
   *     "Non-Normal Exit Color" setting — roundaboutColor).
   *
   * CCW angle (degrees, 0–360) from entry to exit, measured at center:
   *   ~90°  → Turn Right (first exit encountered going CCW in right-hand traffic)
   *   ~180° → Continue Straight
   *   ~270° → Turn Left
   *   ~0°/360° → U-Turn
   *
   * Also draws triangle-leg LineStrings (entry→center and center→each exit) for context.
   *
   * @param {number} junctionId     - WME Junction id.
   * @param {number} entryNodeId    - Node id where the selected entry segment meets the roundabout.
   * @param {number} label_distance - Current label-distance in meters (used to offset markers).
   */
  function ja_draw_roundabout_entry_exits(junctionId, entryNodeId, label_distance) {
    ja_log('[RA-ENTRY] Called with junctionId=' + junctionId + ', entryNodeId=' + entryNodeId, 2);
    var junction = sdk.DataModel.Junctions.getById({ junctionId: junctionId });
    if (!junction) {
      ja_log('[RA-ENTRY] Junction not found!', 2);
      return;
    }
    // Skip if entry node ID is null (can happen with unsaved segments)
    if (!entryNodeId) {
      ja_log('[RA-ENTRY] Entry node ID is null (unsaved segment?)', 2);
      return;
    }
    var entryNode = sdk.DataModel.Nodes.getById({ nodeId: entryNodeId });
    if (!entryNode) {
      ja_log('[RA-ENTRY] Entry node not found!', 2);
      return;
    }
    var center = ja_coordinates_to_point(junction.geometry.coordinates);
    var centerPt = turf.point(center.coordinates);
    var entryPt = turf.point(entryNode.geometry.coordinates);

    // In LHT countries roundabouts flow clockwise (CW); RHT countries flow CCW.
    // This affects the order exits are encountered (and therefore ordinal numbering)
    // but NOT the instruction classification, which is purely geometric.

    // Compass bearing from roundabout center to the entry node.
    // Used to compute the CCW angle from entry to each exit.
    var bearingToEntry = turf.bearing(centerPt, entryPt);

    // Helper: English ordinal suffix for exit numbering ("1st", "2nd", "3rd", …)
    function ordinal(n) {
      var s = ['th', 'st', 'nd', 'rd'];
      var v = n % 100;
      return n + (s[(v - 20) % 10] || s[v] || s[0]);
    }

    // Draw circle overlay if configured
    if (ja_getOption('roundaboutOverlayDisplay') === 'rOverSelected') {
      ja_draw_roundabout_overlay(junctionId);
    }

    // Draw entry-leg: entry node → roundabout center
    ja_add_feature({ type: 'LineString', coordinates: [entryNode.geometry.coordinates, center.coordinates] }, { ja_type: 'arrow_line' });

    // ── Step 1: Collect all valid exits ──────────────────────────────────────
    var processedNodes = {};
    var exits = [];

    ja_log('[RA-ENTRY] Roundabout has ' + junction.segmentIds.length + ' segments', 2);
    junction.segmentIds.forEach(function (segId) {
      ja_log('[RA-ENTRY] Processing segment ' + segId, 3);
      var juncSeg = sdk.DataModel.Segments.getById({ segmentId: segId });
      if (!juncSeg) return;

      var exitNodeId = juncSeg.toNodeId;
      // Skip segments with null node IDs (can happen with unsaved segments)
      if (!exitNodeId) return;

      // Allow the entry node once (U-turn arc) but deduplicate everything else.
      // We track with a count so a second arc returning to the same node is still skipped.
      if (processedNodes.hasOwnProperty(String(exitNodeId))) return;
      processedNodes[String(exitNodeId)] = true;

      var exitNode = sdk.DataModel.Nodes.getById({ nodeId: exitNodeId });
      if (!exitNode) return;

      // Find the first exit segment at this node (not part of THIS roundabout, but may be part of another junction).
      // Validate that traffic can actually LEAVE the roundabout node (not one-way INTO it).
      var exitSeg = null;
      exitNode.connectedSegmentIds.forEach(function (connSegId) {
        if (exitSeg) return;
        var s = sdk.DataModel.Segments.getById({ segmentId: connSegId });
        if (!s) return;
        // Skip segments that are part of THIS roundabout. Allow segments in other junctions or standalone.
        if (s.junctionId === junctionId) {
          ja_log('[RA-ENTRY]   Skipping ' + connSegId + ' (part of this RA)', 3);
          return;
        }
        // Accept exit if it can leave this node. Reject only if it's explicitly one-way INTO the node.
        // Two-way segments (isAtoB !== isBtoA is false, meaning both true or both false) are always valid.
        // One-way FROM this node: (isAtoB && fromNodeId=this) OR (isBtoA && toNodeId=this)
        // One-way INTO this node (reject): (isAtoB && toNodeId=this) OR (isBtoA && fromNodeId=this)
        var isOneWayInto = (s.isAtoB && s.toNodeId === exitNodeId && !s.isBtoA) || (s.isBtoA && s.fromNodeId === exitNodeId && !s.isAtoB);
        if (isOneWayInto) {
          ja_log('[RA-ENTRY]   Skipping ' + connSegId + ' (one-way INTO roundabout only)', 2);
          return;
        }

        // Check for locally-set turn restrictions at this exit.
        // Query all turns ending at the exit segment — if any are marked as not allowed, mark the exit as restricted.
        var arcPath = ja_get_roundabout_arc_path(junctionId, entryNodeId, exitNodeId);
        var isRestricted = false;
        if (arcPath) {
          isRestricted = ja_check_roundabout_arc_restricted(arcPath, s);
        }

        if (isRestricted) {
          ja_log('[RA-ENTRY]   Exit ' + connSegId + ' has turn restriction, will display as NO_TURN', 2);
          s._isRestricted = true;
        }

        ja_log('[RA-ENTRY]   Accepting exit ' + connSegId + (isRestricted ? ' (RESTRICTED)' : ''), 3);
        exitSeg = s;
      });
      if (!exitSeg) {
        var debugSegs = 'connected segs: ';
        exitNode.connectedSegmentIds.forEach(function (cid) {
          var cs = sdk.DataModel.Segments.getById({ segmentId: cid });
          if (cs) {
            debugSegs += cid + '(jID=' + (cs.junctionId || 'null') + ') ';
          }
        });
        ja_log('[RA-ENTRY] Node ' + exitNodeId + ' has no valid exit. ' + debugSegs, 2);
        return;
      }

      // CCW angle (0–360°) from entry to this exit, measured at the roundabout center.
      // In right-hand traffic (CCW roundabout): ~90° = Turn Right, ~180° = Straight, ~270° = Turn Left.
      // U-turn arc returns to the entry node — assign 360 so it sorts last and falls in the ≥315 U-turn bucket.
      var ccwAngle;
      if (exitNodeId === entryNodeId) {
        ccwAngle = 360;
      } else {
        var bearingToExit = turf.bearing(centerPt, turf.point(exitNode.geometry.coordinates));
        ccwAngle = (bearingToEntry - bearingToExit + 360) % 360;
        ja_log(
          '[RA-DEBUG] exitNodeId=' +
            exitNodeId +
            ', bearingToEntry=' +
            ja_round(bearingToEntry) +
            '°, bearingToExit=' +
            ja_round(bearingToExit) +
            '°, ccwAngle=' +
            ja_round(ccwAngle) +
            '° (LHT=' +
            ja_is_left_hand_traffic +
            ')',
          2,
        );
      }

      // Triangle angle at center: entry_node → center → exit_node (always 0–180°).
      // U-turn arc: entry and exit are the same node so the triangle is degenerate (0°).
      // Use 180° instead — the driver exits back along the same road in the opposite direction.
      var triAngle = exitNodeId === entryNodeId ? 180 : ja_angle_between_points(entryNode.geometry, center, exitNode.geometry);
      var isExitAngleNormal = ja_is_angle_normal(triAngle);

      // Skip exits with invalid angles (can happen with unsaved segments)
      if (isNaN(ccwAngle) || isNaN(triAngle)) {
        ja_log('[RA-ENTRY] Skipping exit with NaN angle: ccwAngle=' + ccwAngle + ', triAngle=' + triAngle, 2);
        return;
      }

      exits.push({
        exitNodeId: exitNodeId,
        exitNode: exitNode,
        exitSeg: exitSeg,
        juncSeg: juncSeg,
        ccwAngle: ccwAngle,
        triAngle: triAngle,
        isExitAngleNormal: isExitAngleNormal,
      });
    });

    ja_log('[RA-ENTRY] Collected ' + exits.length + ' exits', 2);
    if (exits.length === 0) {
      ja_log('[RA-ENTRY] No exits found, returning', 2);
      return;
    }

    // ── Step 2: Determine overall roundabout normality for this entry ─────────

    // Criterion 1 – All exits within ±PERPENDICULAR_TOLERANCE° of a 90° multiple
    var allAnglesNormal = exits.every(function (e) {
      return e.isExitAngleNormal;
    });

    // Criterion 2 – Total junction node count 2–4
    var junctionNodeSet = {};
    junction.segmentIds.forEach(function (segId) {
      var s = sdk.DataModel.Segments.getById({ segmentId: segId });
      // Skip segments with null node IDs (unsaved segments)
      if (s && s.toNodeId) junctionNodeSet[String(s.toNodeId)] = true;
    });
    var totalJunctionNodes = Object.keys(junctionNodeSet).length;
    var isNodeCountNormal = totalJunctionNodes >= 2 && totalJunctionNodes <= 4;

    // Criterion 3 – Maximum distance from center to any junction node ≤ 25 m
    var maxRadius = 0;
    Object.keys(junctionNodeSet).forEach(function (nid) {
      var n = sdk.DataModel.Nodes.getById({ nodeId: parseInt(nid, 10) });
      if (!n) return;
      var r = turf.distance(centerPt, turf.point(n.geometry.coordinates)) * 1000; // km→m
      if (r > maxRadius) maxRadius = r;
    });
    var isRadiusNormal = maxRadius <= 25;

    var isRoundaboutNormal = allAnglesNormal && isNodeCountNormal && isRadiusNormal;
    ja_log('Roundabout normal: ' + isRoundaboutNormal + ' (angles:' + allAnglesNormal + ' nodes:' + totalJunctionNodes + ' radius:' + ja_round(maxRadius) + 'm)', 2);
    ja_log('[RA-DEBUG] All exits before sorting:', 4);
    exits.forEach(function (e, idx) {
      ja_log('[RA-DEBUG] Exit ' + idx + ': nodeId=' + e.exitNodeId + ', ccwAngle=' + ja_round(e.ccwAngle) + '°, triAngle=' + ja_round(e.triAngle) + '°', 2);
    });

    // ── Center diameter marker ─────────────────────────────────────────────────
    // Shows the roundabout's diameter (maxRadius × 2) at the center point.
    // White = radius ≤ 25 m (Normal criterion met); Orange = radius > 25 m (Non-Normal).
    var diameterM = ja_round(maxRadius * 2);
    var diameterLabel = ja_getOption('angleDisplay') === 'displaySimple' ? '\u00d8' + diameterM + 'm' : '\u00d8\n' + diameterM + 'm';
    ja_add_feature(center, {
      angle: diameterLabel,
      ja_type: isRadiusNormal ? ja_routing_type.BC : ja_routing_type.ROUNDABOUT,
    });

    // ── Step 3: Sort exits by CCW angle (order encountered going CCW from entry) ──
    // RHT (CCW roundabout): first exit encountered has smallest ccwAngle → sort ascending.
    // LHT (CW roundabout):  first exit encountered has largest ccwAngle  → sort descending.
    exits.sort(
      ja_is_left_hand_traffic
        ? function (a, b) {
            return b.ccwAngle - a.ccwAngle;
          }
        : function (a, b) {
            return a.ccwAngle - b.ccwAngle;
          },
    );

    // For LHT, after descending sort, the U-turn (ccwAngle=360) sorts first but should be last.
    // Move it to the end if it's at index 0.
    if (ja_is_left_hand_traffic && exits.length > 0 && exits[0].ccwAngle === 360) {
      var uTurnExit = exits.shift(); // Remove from front
      exits.push(uTurnExit); // Add to back
      ja_log('[RA-DEBUG] Moved U-turn from index 0 to end (LHT handling)', 2);
    }

    ja_log('[RA-DEBUG] After sort (LHT=' + ja_is_left_hand_traffic + '):', 2);
    exits.forEach(function (e, idx) {
      ja_log('[RA-DEBUG] Exit ' + idx + ': nodeId=' + e.exitNodeId + ', ccwAngle=' + ja_round(e.ccwAngle) + '°', 2);
    });

    // ── Step 4: Draw exit legs and markers ───────────────────────────────────
    var validExitOrdinal = 0; // Counter for non-normal roundabouts (skips restricted exits)
    exits.forEach(function (exit, index) {
      // Draw exit leg: roundabout center → exit node
      ja_add_feature({ type: 'LineString', coordinates: [center.coordinates, exit.exitNode.geometry.coordinates] }, { ja_type: 'arrow_line' });

      var exitBearing = ja_getAngle(exit.exitNodeId, exit.exitSeg);
      var exitLd = ja_corrected_ld(label_distance, exit.exitNode.geometry.coordinates);
      var point = turf.destination(turf.point(exit.exitNode.geometry.coordinates), (exitLd * 2) / 1000, ja_math_to_compass(exitBearing)).geometry;

      // Check if this exit has a turn restriction
      var isRestricted = exit.exitSeg._isRestricted === true;

      var markerType;
      if (isRestricted) {
        // Restricted exit: always display as NO_TURN (gray)
        markerType = ja_routing_type.NO_TURN;
        ja_log('[RA-DEBUG] Drawing restricted exit ' + index + ': nodeId=' + exit.exitNodeId + ', markerType=NO_TURN', 2);
        ja_draw_marker(point, exit.exitNode, exitLd, exit.triAngle, exitBearing, true, markerType);
      } else if (isRoundaboutNormal) {
        // Normal roundabout with allowed exit: classify by CCW angle from entry
        //   ~90°  → Turn Right   (first exit going CCW)
        //   ~180° → Continue Straight
        //   ~270° → Turn Left    (last exit before U-turn)
        //   ~0°/360° → U-Turn
        var ccw = exit.ccwAngle;
        if (ccw < ROUNDABOUT_INSTRUCTION_UTURN_THRESHOLD || ccw >= ROUNDABOUT_INSTRUCTION_TURN_LEFT_THRESHOLD) {
          markerType = ja_routing_type.U_TURN;
        } else if (ccw < ROUNDABOUT_INSTRUCTION_TURN_RIGHT_THRESHOLD) {
          markerType = ja_routing_type.TURN_RIGHT;
        } else if (ccw < ROUNDABOUT_INSTRUCTION_CONTINUE_THRESHOLD) {
          markerType = ja_routing_type.BC; // Continue Straight
        } else {
          markerType = ja_routing_type.TURN_LEFT;
        }
        ja_log('[RA-DEBUG] Drawing exit ' + index + ': nodeId=' + exit.exitNodeId + ', ccw=' + ja_round(ccw) + '°, markerType=' + markerType, 2);
        // ja_draw_marker handles arrow characters and display-mode formatting,
        // exactly as it does for regular turn markers at intersections.
        ja_draw_marker(point, exit.exitNode, exitLd, exit.triAngle, exitBearing, true, markerType);
      } else {
        // Non-normal roundabout: ordinal exit numbers in encounter order (skipping restricted exits)
        // Only increment counter for allowed exits; restricted exits get NO_TURN marker
        validExitOrdinal++;
        ja_log('[RA-DEBUG] Drawing non-normal exit ' + index + ': nodeId=' + exit.exitNodeId + ', ordinal=' + validExitOrdinal, 2);
        // Direct feature add — ordinal string can't pass through ja_draw_marker's numeric 'a'.
        ja_add_feature(point, {
          angle: ja_getOption('angleDisplay') === 'displaySimple' ? ordinal(validExitOrdinal) + ' ' + ja_round(exit.triAngle) + '°' : ordinal(validExitOrdinal) + '\n' + ja_round(exit.triAngle) + '°',
          ja_type: ja_routing_type.ROUNDABOUT, // "Non-Normal Exit Color" (roundaboutColor) setting
        });
      }
    });
  }

  /**
   * Constructs the sequence of roundabout arc segments from entry node to exit node.
   *
   * Walks through the junction's arc segments starting at entryNodeId and following
   * connected nodes until reaching exitNodeId. Returns the ordered list of segments
   * that comprise the arc path (used for path traversal restriction checking).
   *
   * @param {number} junctionId - WME Junction ID (roundabout).
   * @param {number} entryNodeId - Starting node ID.
   * @param {number} exitNodeId - Ending node ID.
   * @returns {Object[]|null} Array of SDK Segment objects forming the path, or null if path cannot be constructed.
   */
  function ja_get_roundabout_arc_path(junctionId, entryNodeId, exitNodeId) {
    var junction = sdk.DataModel.Junctions.getById({ junctionId: junctionId });
    if (!junction) return null;

    var path = [];
    var currentNodeId = entryNodeId;
    var visited = {};
    var maxSteps = junction.segmentIds.length + 1; // Prevent infinite loops

    for (var step = 0; step < maxSteps && currentNodeId !== exitNodeId; step++) {
      var nextSeg = null;

      // Find the next unvisited segment connected to currentNodeId
      for (var i = 0; i < junction.segmentIds.length; i++) {
        var segId = junction.segmentIds[i];
        if (visited[String(segId)]) continue;

        var seg = sdk.DataModel.Segments.getById({ segmentId: segId });
        if (!seg) continue;

        if (seg.fromNodeId === currentNodeId) {
          nextSeg = seg;
          currentNodeId = seg.toNodeId;
          break;
        } else if (seg.toNodeId === currentNodeId) {
          nextSeg = seg;
          currentNodeId = seg.fromNodeId;
          break;
        }
      }

      if (!nextSeg) break;

      visited[String(nextSeg.id)] = true;
      path.push(nextSeg);
    }

    return currentNodeId === exitNodeId ? path : null;
  }

  /**
   * Checks for explicit local turn restrictions at a junction node.
   *
   * For roundabout arc segments, we check ONLY for explicit turn restrictions
   * without performing direction calculation (which has LHT quirks for arc segments).
   * This checks the Turn object's restrictions array to see if an editor set a local TR.
   *
   * @param {Object} s_from - SDK Segment object for the incoming road.
   * @param {Object} via_node - SDK Node object at the junction.
   * @param {Object} s_to - SDK Segment object for the outgoing road.
   * @returns {boolean} True if a turn restriction explicitly blocks this turn.
   */
  function ja_is_turn_restricted(s_to) {
    try {
      // Query all turns ending at the exit segment and check if ANY are restricted.
      // This avoids depending on exact arc path construction — we just need to know
      // if there's a restricted turn to this exit from ANY arc segment.
      var turnsTo = sdk.DataModel.Turns.getTurnsToSegment({ segmentId: s_to.id });

      if (turnsTo && turnsTo.length > 0) {
        for (var j = 0; j < turnsTo.length; j++) {
          var turn = turnsTo[j];
          // If ANY turn to this exit is restricted (isAllowed=false or has restrictions), mark the whole exit as restricted
          if (!turn.isAllowed || (turn.restrictions && turn.restrictions.length > 0)) {
            ja_log('[RA-PATH-RESTRICT] Exit ' + s_to.id + ' is restricted (turn ' + turn.id + ')', 3);
            return true;
          }
        }
      }

      return false; // No restricted turns found
    } catch (e) {
      ja_log('[RA-PATH-RESTRICT] Error checking exit ' + s_to.id + ': ' + e.message, 2);
      return false;
    }
  }

  /**
   * Validates turn restrictions along a roundabout arc path by checking each intermediate node.
   *
   * Replicates the far-turn path validation pattern: walks through arc segments and validates
   * that a turn is allowed at each connecting node. If ANY intermediate turn is restricted,
   * the entire arc path is marked as restricted.
   *
   * @param {Object[]} arcPath - Array of arc segments (from ja_get_roundabout_arc_path).
   * @param {Object} exitSegment - The exit segment (where traffic leaves the roundabout).
   * @returns {boolean} true if ANY step in the path is restricted, false if all steps allowed.
   */
  function ja_check_roundabout_arc_restricted(arcPath, exitSegment) {
    if (!arcPath || arcPath.length === 0 || !exitSegment) {
      ja_log('[RA-PATH-CHECK] Invalid input: arcPath=' + (arcPath ? arcPath.length : 'null') + ', exitSeg=' + (exitSegment ? 'yes' : 'null'), 2);
      return false;
    }

    // Build the full sequence: [arcSeg1, ..., arcSegN, exitSeg]
    var fullPath = arcPath.concat([exitSegment]);

    // For roundabout arcs, we only check the FINAL step (arc→exit) for restrictions.
    // Intermediate arc→arc turns are structural and don't have explicit restrictions that affect traffic flow.
    // The only place an editor would set a local restriction is at the exit point.

    var finalStepIndex = fullPath.length - 2;
    if (finalStepIndex >= 0) {
      var stepFrom = fullPath[finalStepIndex];
      var stepTo = fullPath[finalStepIndex + 1];

      // Find the connecting node between these two segments
      var stepConnectingNodeId = ja_get_connecting_node(stepFrom, stepTo);
      if (stepConnectingNodeId === null) {
        return true; // Segments not adjacent
      }

      // Fetch the node (defensive check)
      var stepConnectingNode = sdk.DataModel.Nodes.getById({ nodeId: stepConnectingNodeId });
      if (!stepConnectingNode) {
        return true; // Node not found
      }

      // Check for explicit turn restrictions at this exit
      return ja_is_turn_restricted(stepTo);
    }

    return false;
  }

  /**
   * Wraps a raw `[lon, lat]` coordinate pair into a minimal GeoJSON Point geometry object.
   *
   * The WME Junction geometry stores coordinates as a plain array, but Turf.js helper functions
   * like `turf.distance()` and `turf.bearing()` require GeoJSON geometry objects.  This avoids
   * the overhead of `turf.point()` when only the geometry (not the full Feature wrapper) is
   * needed.
   *
   * @param {number[]} coordinates - `[longitude, latitude]` pair (WGS84).
   * @returns {{ type: 'Point', coordinates: number[] }} GeoJSON Point geometry.
   */
  function ja_coordinates_to_point(coordinates) {
    return { type: 'Point', coordinates: [coordinates[0], coordinates[1]] };
  }

  /**
   * Returns the first `[lon, lat]` coordinate of a segment's GeoJSON LineString geometry.
   * Used with `ja_get_second_point` to compute the bearing at the segment's start end.
   * @param {object} segment - SDK Segment object with a GeoJSON `geometry` property.
   * @returns {number[]} `[longitude, latitude]` WGS84 pair.
   */
  function ja_get_first_point(segment) {
    return segment.geometry.coordinates[0];
  }

  /**
   * Returns the last `[lon, lat]` coordinate of a segment's GeoJSON LineString geometry.
   * Used with `ja_get_next_to_last_point` to compute the bearing at the segment's end end.
   * @param {object} segment - SDK Segment object with a GeoJSON `geometry` property.
   * @returns {number[]} `[longitude, latitude]` WGS84 pair.
   */
  function ja_get_last_point(segment) {
    return segment.geometry.coordinates[segment.geometry.coordinates.length - 1];
  }

  /**
   * Returns the second `[lon, lat]` coordinate of a segment's GeoJSON LineString geometry.
   * Used together with `ja_get_first_point` to get the initial bearing leaving the start node.
   * @param {object} segment - SDK Segment object with a GeoJSON `geometry` property.
   * @returns {number[]} `[longitude, latitude]` WGS84 pair.
   */
  function ja_get_second_point(segment) {
    return segment.geometry.coordinates[1];
  }

  /**
   * Returns the second-to-last `[lon, lat]` coordinate of a segment's GeoJSON LineString.
   * Used together with `ja_get_last_point` to get the final bearing arriving at the end node.
   * @param {object} segment - SDK Segment object with a GeoJSON `geometry` property.
   * @returns {number[]} `[longitude, latitude]` WGS84 pair.
   */
  function ja_get_next_to_last_point(segment) {
    return segment.geometry.coordinates[segment.geometry.coordinates.length - 2];
  }

  /**
   * Returns the absolute departure angle for a segment end connected at a given node.
   *
   * Picks the first two coordinates (from-end) or last two (to-end) depending on which node
   * is the query node, then converts the Turf.js compass bearing (0=N, clockwise) into the
   * math-convention angle (0=East, counter-clockwise) used throughout this script.
   *
   * Uses the first/second or next-to-last/last vertex pair so the angle reflects the immediate
   * direction of the road as it leaves the node rather than the straight-line segment direction.
   *
   * @param {number|null} ja_node - WME Node id of the junction being measured.
   * @param {object|null} ja_segment - SDK Segment object whose departure bearing is needed.
   * @returns {number|null} Angle in math degrees (0=East CCW), or null if either argument is
   *   null.
   */
  function ja_getAngle(ja_node, ja_segment) {
    ja_log('[getAngle] node: ' + ja_node, 3);
    ja_log('[getAngle] segment: ' + ja_segment, 3);
    if (ja_node == null || ja_segment == null) {
      return null;
    }
    var p1, p2;
    if (ja_segment.fromNodeId === ja_node) {
      p1 = ja_get_first_point(ja_segment);
      p2 = ja_get_second_point(ja_segment);
    } else {
      p1 = ja_get_last_point(ja_segment);
      p2 = ja_get_next_to_last_point(ja_segment);
    }
    // turf.bearing returns compass bearing (0=N, CW); convert to math angle (0=E, CCW)
    var bearing = turf.bearing(turf.point(p1), turf.point(p2));
    return (90 - bearing + 360) % 360;
  }

  /**
   * Returns the overall bearing of a segment (first vertex → last vertex) as a math angle.
   *
   * Unlike `ja_getAngle` which uses the immediate endpoint-adjacent vertex pair,
   * `ja_getAngleMidleSeg` uses the full start-to-end chord.  This prevents false-positive
   * overlapping-angle matches on curved segments whose start bearing closely parallels an
   * adjacent segment even though the roads diverge over their full length.
   *
   * @param {number|null} ja_node - WME Node id used to determine direction of travel (from-end
   *   or to-end).
   * @param {object|null} ja_segment - SDK Segment object.
   * @returns {number|null} Angle in math degrees (0=East CCW), or null if either argument is
   *   null.
   */
  function ja_getAngleMidleSeg(ja_node, ja_segment) {
    ja_log('[getAngleMidleSeg] node: ' + ja_node, 3);
    ja_log('[getAngleMidleSeg] segment: ' + ja_segment, 3);
    if (ja_node == null || ja_segment == null) {
      return null;
    }
    var p1, p2;
    if (ja_segment.fromNodeId === ja_node) {
      p1 = ja_get_first_point(ja_segment);
      p2 = ja_get_last_point(ja_segment);
    } else {
      p1 = ja_get_last_point(ja_segment);
      p2 = ja_get_first_point(ja_segment);
    }
    // turf.bearing returns compass bearing (0=N, CW); convert to math angle (0=E, CCW)
    var bearing = turf.bearing(turf.point(p1), turf.point(p2));
    return (90 - bearing + 360) % 360;
  }

  // ── Far turn support (experimental) ──────────────────────────────────────────────────────────
  //
  // The two functions below add support for "far turns" — turns that cross one or more
  // intermediate segments rather than connecting directly across a single junction node.
  //
  // Waze exposes far turns on the SDK Turn interface via:
  //   turn.isPathTurn        — true when created with the Paths (FL2) tool
  //   turn.isJunctionBoxTurn — true when inside a Junction Box (BigJunction)
  //   turn.segmentPath[]     — array of intermediate segment IDs (excludes entry & exit segments)
  //
  // The two types are mutually exclusive: a segment cannot be part of both a Junction Box
  // and a Path simultaneously.

  /**
   * Finds the junction node shared between two adjacent segments.
   *
   * This helper is needed specifically for far turn angle calculation: the exit segment
   * of a far turn is NOT directly connected to the entry node. Instead the path runs:
   *
   *   entrySegment → [segmentPath[0] ... segmentPath[N]] → exitSegment
   *
   * To get the correct departure bearing of exitSegment we need the node where the last
   * intermediate segment (segmentPath[N]) hands off to exitSegment. That node is the
   * shared endpoint between those two segments — which is what this function finds.
   *
   * Usage:
   *   var lastPathSeg    = sdk.DataModel.Segments.getById({ segmentId: turn.segmentPath[turn.segmentPath.length - 1] });
   *   var connectingNode = ja_get_connecting_node(lastPathSeg, exitSeg);
   *   var exitAngle      = ja_getAngle(connectingNode, exitSeg);
   *
   * If the two segments do not share a node (e.g. bad data or disconnected path), returns null.
   * ja_getAngle() safely handles null and returns null in that case too.
   *
   * @param {object} segA - SDK Segment object (the last intermediate segment in segmentPath[]).
   * @param {object} segB - SDK Segment object (the exit segment of the far turn).
   * @returns {number|null} The shared node ID, or null if no shared node exists.
   */
  function ja_get_connecting_node(segA, segB) {
    if (segA == null || segB == null) return null;
    // Each segment has exactly two endpoint nodes (fromNodeId, toNodeId).
    // One of segA's endpoints must equal one of segB's endpoints where they connect.
    if (segA.fromNodeId === segB.fromNodeId || segA.fromNodeId === segB.toNodeId) return segA.fromNodeId;
    if (segA.toNodeId === segB.fromNodeId || segA.toNodeId === segB.toNodeId) return segA.toNodeId;
    return null; // segments are not adjacent — should not happen with valid SDK data
  }

  /**
   * Maps a turn.instructionOpCode to a ja_routing_type Override* value.
   * Returns null if the opcode is unrecognised — callers fall through to angle-based classification.
   *
   * @param {string} opcode - The turn.instructionOpCode value (e.g., 'TURN_LEFT', 'NONE', 'UTURN').
   * @param {boolean} logOpcode - If true, log the recognized opcode (default: true).
   * @returns {string|null} The mapped ja_routing_type value (OverrideTURN_LEFT, OverrideBC, etc.) or null if unrecognised.
   */
  function ja_opcode_to_routing_type(opcode, logOpcode) {
    if (typeof logOpcode === 'undefined') logOpcode = true;

    var result = null;
    switch (opcode) {
      case 'NONE':
        result = ja_routing_type.OverrideBC;
        break;
      case 'CONTINUE':
        result = ja_routing_type.OverrideCONTINUE;
        break;
      case 'TURN_LEFT':
        result = ja_routing_type.OverrideTURN_LEFT;
        break;
      case 'TURN_RIGHT':
        result = ja_routing_type.OverrideTURN_RIGHT;
        break;
      case 'KEEP_LEFT':
        result = ja_routing_type.OverrideKEEP_LEFT;
        break;
      case 'KEEP_RIGHT':
        result = ja_routing_type.OverrideKEEP_RIGHT;
        break;
      case 'EXIT_LEFT':
        result = ja_routing_type.OverrideEXIT_LEFT;
        break;
      case 'EXIT_RIGHT':
        result = ja_routing_type.OverrideEXIT_RIGHT;
        break;
      case 'UTURN':
        result = ja_routing_type.OverrideU_TURN;
        break;
    }

    // Single log: opcode recognized or not
    if (logOpcode) {
      ja_log('Turn opcode override: ' + (result ? opcode : 'none'), 3);
    }
    return result;
  }

  /**
   * Classifies a potential U-turn angle, applying Waze double-turn restrictions.
   * Used in ja_collect_double_turns to identify U-turns with or without Waze restrictions.
   *
   * Checks conditions in order:
   * 1. DEFAULT: Median qualifies (≤30m OR 31-50m with lane guidance) AND segments are parallel (±5°)
   * 2. OPTIONAL STRICTER: If setting is ON and median ≤15m, blocks the turn
   *
   * If angle is NOT in U-turn range (175 ± 1.5°), returns null (not a U-turn).
   *
   * @param {number} angle - The turn angle in degrees.
   * @param {number} medianLen - The length of the median segment (meters).
   * @param {object} segA - First connecting segment (SDK Segment).
   * @param {object} segC - Second connecting segment (SDK Segment).
   * @param {number} nodeA_id - Node ID at start of median.
   * @param {number} nodeC_id - Node ID at end of median.
   * @param {boolean} hasLaneGuidanceOnA - True if incoming segment A has lane guidance toward the median.
   * @returns {string|null} ja_routing_type.NO_U_TURN, ja_routing_type.U_TURN, or ja_routing_type.PROBLEM; null if not in U-turn range or doesn't qualify.
   */
  function ja_classify_uturn_angle(angle, medianLen, segA, segC, nodeA_id, nodeC_id, hasLaneGuidanceOnA) {
    // Check if angle is in U-turn range (175 ± 1.5°)
    if (angle < 175 - GRAY_ZONE || angle > 185 + GRAY_ZONE) {
      return null; // Not a U-turn angle
    }

    // Check if median qualifies by length (default or extended with lane guidance)
    var medianQualifies = medianLen <= WAZE_MEDIAN_LENGTH_DEFAULT || (medianLen <= WAZE_MEDIAN_LENGTH_EXTENDED && hasLaneGuidanceOnA);

    if (!medianQualifies) {
      return null; // Median too long to qualify as U-turn
    }

    // Check if segments A and C are parallel (±5°)
    var segmentsParallel = ja_is_segments_parallel(segA, segC, nodeA_id, nodeC_id, WAZE_PARALLELISM_TOLERANCE);

    if (!segmentsParallel) {
      return null; // Not parallel; doesn't qualify as U-turn
    }

    var turn_type;

    // Apply optional stricter Waze restriction (≤15m threshold; parallelism already verified above)
    if (ja_getOption('wazeDoubleUTurnRestriction') && medianLen <= WAZE_MEDIAN_LENGTH_THRESHOLD) {
      turn_type = ja_routing_type.NO_U_TURN;
      ja_log('Waze restriction: median ≤' + WAZE_MEDIAN_LENGTH_THRESHOLD + 'm and parallel within ' + WAZE_PARALLELISM_TOLERANCE + '°, flagged as NO_U_TURN', 2);
    } else {
      // Qualifies as U-turn by default rules (≤30m or 31-50m with LG, and parallel); angle determines the exact type
      turn_type = angle >= 175 + GRAY_ZONE && angle <= 185 - GRAY_ZONE ? ja_routing_type.U_TURN : ja_routing_type.PROBLEM;
    }

    return turn_type;
  }

  /**
   * Finds the intersection point between a segment and a BigJunction polygon boundary,
   * returning the point closest to a reference node.
   * Used in ja_draw_node_markers and ja_draw_far_turn_markers to reposition markers
   * when an exit segment crosses a JB boundary.
   *
   * @param {object} segment - The SDK Segment object (has geometry.coordinates).
   * @param {object} bjPolygon - The BigJunction polygon GeoJSON object.
   * @param {object|number[]} refPoint - Reference point (SDK Node with .geometry.coordinates or raw coordinates array [lon, lat]).
   * @returns {object|null} GeoJSON Point feature of the closest intersection, or null if no intersection found.
   */
  function ja_find_closest_bj_intersection(segment, bjPolygon, refPoint) {
    var exitLine = turf.lineString(segment.geometry.coordinates);
    var intersections = turf.lineIntersect(exitLine, bjPolygon);

    if (intersections.features.length === 0) {
      return null;
    }

    // Determine reference coordinates
    var refCoords;
    if (refPoint.geometry && refPoint.geometry.coordinates) {
      // refPoint is a Node object
      refCoords = refPoint.geometry.coordinates;
    } else if (Array.isArray(refPoint)) {
      // refPoint is raw [lon, lat] array
      refCoords = refPoint;
    } else {
      // refPoint is assumed to have coordinates
      refCoords = refPoint.coordinates || refPoint;
    }

    var referencePt = turf.point(refCoords);
    var closestPt = intersections.features.reduce(function (best, candidate) {
      return turf.distance(candidate, referencePt) < turf.distance(best, referencePt) ? candidate : best;
    });

    return closestPt;
  }

  /**
   * Determines if two compass bearings point in similar directions within a tolerance.
   *
   * Used to detect when local turn markers and far-turn markers target the same direction
   * at a node, allowing the positioning system to layer them (local closer, far-turn farther).
   *
   * Accounts for 360° wraparound: e.g., 10° vs 350° differ by 20°, not 340°.
   *
   * @param {number} bearing1 - First compass bearing (0–360°, 0=North, CW).
   * @param {number} bearing2 - Second compass bearing (0–360°).
   * @param {number} tolerance - Maximum angle difference in degrees (suggested: 30–45°).
   * @returns {boolean} True if bearings are within tolerance of each other.
   */
  function ja_markers_target_same_direction(bearing1, bearing2, tolerance) {
    var diff = Math.abs(bearing1 - bearing2);
    if (diff > 180) {
      diff = 360 - diff; // Account for 360° wraparound
    }
    return diff <= tolerance;
  }

  /**
   * Converts a math angle (0=East, CCW) to a compass bearing (0=North, CW) for use with Turf.js.
   * Turf functions like turf.destination() expect compass bearings, not math angles.
   * Used in marker positioning throughout ja_draw_node_markers and ja_draw_far_turn_markers.
   *
   * @param {number} angle - The math angle in degrees (0=East, increases CCW).
   * @returns {number} Compass bearing in degrees (0=North, increases CW).
   */
  function ja_math_to_compass(angle) {
    return (90 - angle + 360) % 360;
  }

  /**
   * Computes the extra-space multiplier for marker visibility based on angle and bearing.
   * Returns 2 (double distance) when angle is sharp (>120°) or bearing points upward (40-120°).
   * Used in marker positioning to avoid overlap with bridge/bridge icons.
   *
   * @param {number} angle - The angle measurement (turn angle or step angle in degrees).
   * @param {number} bearing - The compass or reference bearing for visibility (degrees).
   * @returns {number} Multiplier: 1 (normal spacing) or 2 (double spacing for visibility).
   */
  function ja_compute_extra_space(angle, bearing) {
    // Double spacing if angle is sharp (>120°)
    if (Math.abs(angle) > 120) {
      return 2;
    }
    // Double spacing if bearing points upward (40-120°) to avoid bridge icon
    if (bearing > 40 && bearing < 120) {
      return 2;
    }
    return 1;
  }

  /**
   * Classifies a turn angle into a routing instruction type using angle-based thresholds.
   * Does NOT apply TIO/VIO overrides — used for intermediate step classification where
   * local node angles determine turn type.
   *
   * @param {number} angle - The turn angle in degrees (can be negative or >360).
   * @returns {string} A ja_routing_type value (BC, TURN, U_TURN, PROBLEM).
   */
  function ja_classify_turn_angle(angle) {
    if (angle == null) return ja_routing_type.TURN;
    var absAngle = Math.abs(angle);
    if (absAngle > U_TURN_ANGLE + GRAY_ZONE) {
      return ja_routing_type.U_TURN;
    } else if (absAngle > U_TURN_ANGLE - GRAY_ZONE) {
      return ja_routing_type.PROBLEM;
    } else if (absAngle >= TURN_ANGLE - GRAY_ZONE) {
      return ja_routing_type.TURN;
    } else {
      return ja_routing_type.BC;
    }
  }

  /**
   * Determines the routing instruction type for the FINAL step of a far turn (JB or Path).
   * Applies turn.instructionOpCode override (if present), checks exit restriction, and
   * falls back to angle-based classification.
   *
   * Used only for the final marker in a breadcrumb trail for both Junction Box and Path turns;
   * intermediate steps use ja_classify_turn_angle() directly without override.
   *
   * @param {object} turn - The SDK Turn object (contains instructionOpCode, fromSegmentId, toSegmentId).
   * @param {object} connectingNode - The SDK Node where lastPathSeg hands off to exitSeg.
   * @param {number} lastPathSegId - ID of the last intermediate segment.
   * @param {object} lastPathSeg - The SDK Segment object for lastPathSegId.
   * @param {object} exitSeg - The SDK Segment object for the exit segment (turn.toSegmentId).
   * @param {number} localStepAngle - The angle at the connecting node (fallback if guess fails).
   * @returns {string} A ja_routing_type value for styling/display.
   */
  function ja_get_final_step_type(turn, connectingNode, lastPathSegId, lastPathSeg, exitSeg, localStepAngle) {
    // Step 1: Check for TIO/VIO override (turn.instructionOpCode)
    var opcode = turn.instructionOpCode || null;
    if (opcode) {
      var opcodeType = ja_opcode_to_routing_type(opcode, false);
      if (opcodeType !== null) {
        return opcodeType;
      }
    }

    // Step 2: Check if exit is restricted (local node turn restriction)
    if (!ja_is_turn_allowed(lastPathSeg, connectingNode, exitSeg)) {
      ja_log('[FAR-TURNS] Final step blocked by turn restriction', 2);
      return ja_routing_type.NO_TURN;
    }

    // Step 3: Use ja_guess_routing_instruction at connecting node (full classification)
    if (connectingNode) {
      // Build angles array for ja_guess_routing_instruction
      var guessAngles = [];
      connectingNode.connectedSegmentIds.forEach(function (cSegId) {
        var cSeg = sdk.DataModel.Segments.getById({ segmentId: cSegId });
        var cAngle = ja_getAngle(connectingNode.id, cSeg);
        if (cAngle != null) {
          guessAngles.push([cAngle, cSegId, false]);
        }
      });
      var guessed = ja_guess_routing_instruction(connectingNode, lastPathSegId, exitSeg.id, guessAngles);
      if (guessed !== ja_routing_type.NO_TURN) {
        return guessed;
      }
    }

    // Step 4: Fallback to angle-based classification
    return ja_classify_turn_angle(localStepAngle);
  }

  /**
   * Draws angle markers for "far turns" — turns that cross multiple segments rather than
   * connecting directly at a single junction node.
   *
   * This function handles two Waze feature types that both appear as far turns in the SDK:
   *
   *   PATH TURNS (turn.isPathTurn === true)
   *   ──────────────────────────────────────
   *   Created by editors using the Paths tool (also known as "far lanes phase 2" / FL2).
   *   Paths exist solely to provide better lane guidance and turn instructions to drivers.
   *   They CANNOT affect routing — the routing server ignores them entirely.
   *   They CAN be restricted (new feature in WME). Restricted Path turns display as gray (NO_TURN).
   *   Controlled by: enableFarTurnPath experimental setting (disabled by default).
   *
   *   JUNCTION BOX TURNS (turn.isJunctionBoxTurn === true)
   *   ─────────────────────────────────────────────────────
   *   Created when a Junction Box (BigJunction polygon) is drawn over a complex intersection.
   *   Junction Box turns CAN be restricted (red/yellow arrows in WME editor).
   *   They DO affect routing and collect per-path speed data.
   *   This function checks turn.isAllowed and displays restricted JB paths as gray (NO_TURN).
   *   Controlled by: enableFarTurnJB experimental setting (disabled by default).
   *
   * ANGLE CALCULATION APPROACH
   * ──────────────────────────
   * The angle shown is the INSTRUCTION-FIRING angle — the local heading change at the first
   * node along the path where the turn is NOT Best Continuation (straight).
   *
   * Algorithm (see "INSTRUCTION-FIRING ANGLE SEARCH" block inside the forEach below):
   *
   *   Step 1: Compute angle at entry node   (fromSeg → segmentPath[0])
   *   Step 2: Compute angle at connecting node (lastPathSeg → toSeg)
   *   • Use step 1 if |step1| > BC_THRESHOLD (10°)  — instruction fires at entry node
   *   • Else use step 2 if |step2| > BC_THRESHOLD   — instruction fires at connecting node
   *   • Fall back to step 1 if both are below threshold (both straight)
   *
   * Why not the net entry→exit angle?
   * The net angle across the entire path is geometrically correct but is NOT what Waze uses for the
   * voice prompt. Waze fires at the first meaningful turn node, which typically produces a clearer,
   * more actionable turn angle.
   *
   * Why not always use the entry node?
   * When the entry segment continues STRAIGHT onto the first intermediate segment (straight geometry,
   * ~0-5°), the instruction fires further along at the connecting node where the real heading change
   * occurs. This produces more accurate turn angles than using the entry node alone.
   *
   * LABEL PLACEMENT
   * ───────────────
   * Marker placement (the ★ boundary point and ha direction) always uses the connecting node
   * and the exit segment's departure bearing — this is unchanged regardless of which node
   * the instruction fires at. The angle VALUE changes; the position does not.
   *
   * @param {object} node             - The SDK Node object at which far turns originate.
   *                                    We query turns from each segment connected to this
   *                                    node and filter to those whose fromSegment touches it.
   * @param {number} ja_label_distance - Label placement distance in metres, computed by
   *                                    ja_compute_label_distance() based on zoom level.
   * @param {number[]} ja_selected_seg_ids - IDs of the currently selected segments. Far turns
   *                                    are only drawn for entry segments in this list. When
   *                                    empty (node selection), all entry segments are shown.
   * @param {Array}    angles           - The angles array from ja_draw_node_markers() for this node.
   *                                    Format: [[bearing, segmentId, isSelected], ...] for all
   *                                    segments connected to node. Passed to
   *                                    ja_guess_routing_instruction() so far-turn classification
   *                                    uses the same KEEP/EXIT/TURN thresholds as regular turns.
   */
  function ja_draw_far_turn_markers(node, ja_label_distance, ja_selected_seg_ids, allBigJunctions) {
    ja_log('[FAR-TURNS] Processing node ' + node.id + ' (' + node.connectedSegmentIds.length + ' segments)', 4);

    // WHY getTurnsFromSegment instead of getTurnsThroughNode:
    // getTurnsThroughNode only returns turns where BOTH the entry and exit segment connect
    // at the same node — i.e., standard direct turns. Far turns (JB turns, path turns) have
    // an exit segment that is NOT directly connected to the entry node, so they are not
    // returned by getTurnsThroughNode.
    //
    // Instead we query getTurnsFromSegment for every segment connected to this node, which
    // returns ALL turns (including far turns) that originate from that segment. We then filter
    // to only far turns whose fromSegment actually touches our node (to avoid processing the
    // same far turn again when we reach the node at the other end of the segment).
    var seenTurnIds = {}; // deduplicate in case two connected segments share a far turn
    var drawnIntermediateSteps = {}; // key: "nodeId_angleRounded", prevents duplicate intermediate-step markers when multiple paths share the same median segment

    node.connectedSegmentIds.forEach(function (segId) {
      // Only draw far turns FROM the user-selected entry segment(s).
      //
      // When a segment is selected, regular departure-mode markers show angles FROM that
      // segment only — not from every other segment at the same node. Far turns follow the
      // same principle: we only want to see "if I came in on THIS segment, what are my JB/
      // path exits and at what angle?"
      //
      // Without this filter, every entry segment at the node produces its own set of far-turn
      // markers. When two different entries share the same exit segment, their markers land at
      // the same boundary point — producing visually duplicate circles at the exit arrow.
      //
      // When ja_selected_seg_ids is empty (user selected a node, not a segment) we skip this
      // filter and show far turns from all connected non-median segments, since there is no
      // single "incoming" segment to anchor the perspective.
      if (ja_selected_seg_ids.length > 0 && ja_selected_seg_ids.indexOf(segId) === -1) {
        ja_log('Skip segment ' + segId + ' (not selected)', 4);
        return;
      }

      // Skip segments that are contained within a BigJunction (median/intermediate segments).
      if (sdk.DataModel.Segments.isContainedInBigJunction({ segmentId: segId })) {
        ja_log('Skip segment ' + segId + ' (median)', 4);
        return;
      }

      // Get direct turns from this segment
      var turnsFromSeg = sdk.DataModel.Turns.getTurnsFromSegment({ segmentId: segId });
      if (!turnsFromSeg || turnsFromSeg.length === 0) return;

      // Check if this segment has far turns (JB or Path) via the normal method
      var jbFarTurnsCount = turnsFromSeg.filter(function (t) {
        return t.isJunctionBoxTurn || t.isPathTurn;
      }).length;

      // FALLBACK: If no far turns (JB or Path) found, try getAllPossibleTurns() as alternative
      if (jbFarTurnsCount === 0) {
        // Check if this segment crosses into a BigJunction and if we're at the correct entry node
        var seg = sdk.DataModel.Segments.getById({ segmentId: segId });
        if (seg) {
          var fromNode = sdk.DataModel.Nodes.getById({ nodeId: seg.fromNodeId });
          var toNode = sdk.DataModel.Nodes.getById({ nodeId: seg.toNodeId });

          if (fromNode && toNode) {
            var isJBSegment = false;
            var entryNodeForTurns = null;

            // Determine if segment crosses JB boundary and which node is the entry node
            for (var bji = 0; bji < allBigJunctions.length; bji++) {
              var bj = allBigJunctions[bji];
              var bjPolygon = turf.polygon(bj.geometry.coordinates);
              var fromInside = turf.booleanPointInPolygon(turf.point(fromNode.geometry.coordinates), bjPolygon);
              var toInside = turf.booleanPointInPolygon(turf.point(toNode.geometry.coordinates), bjPolygon);

              // Check if segment crosses JB boundary (entry: outside→inside, exit: inside→outside)
              if ((!fromInside && toInside) || (fromInside && !toInside)) {
                isJBSegment = true;
                // The entry node is where the turn originates (inside the JB)
                entryNodeForTurns = toInside ? seg.toNodeId : seg.fromNodeId;
                break; // Only check the first matching JB
              }
            }

            // Only query getAllPossibleTurns when we're at the correct entry node
            if (isJBSegment && entryNodeForTurns === node.id) {
              try {
                var allTurns = sdk.DataModel.BigJunctions.getAllPossibleTurns({ bigJunctionId: bj.id });
                ja_log('[FAR-TURNS] Fallback getAllPossibleTurns: ' + allTurns.length + ' turns found', 3);

                // Filter for turns from this entry segment that have intermediate segments (far turns through JB)
                // CRITICAL: getAllPossibleTurns() returns ALL path combinations through a JB, including
                // traversals of median segments in BOTH directions. We must reject nonsensical paths.
                var fallbackTurns = allTurns.filter(function (t) {
                  if (!(t.fromSegmentId === segId && t.segmentPath && t.segmentPath.length > 0)) {
                    return false;
                  }

                  // REJECT: paths that traverse the same segment multiple times
                  // These are backtrack/loop paths and geometrically invalid
                  var pathSegIds = t.segmentPath;
                  for (var psi = 0; psi < pathSegIds.length - 1; psi++) {
                    if (pathSegIds[psi] === pathSegIds[psi + 1]) {
                      ja_log('[FAR-TURNS] Rejecting turn ' + t.id + ' — same segment ' + pathSegIds[psi] + ' appears consecutively in path', 3);
                      return false;
                    }
                  }

                  var tFromSeg = sdk.DataModel.Segments.getById({ segmentId: t.fromSegmentId });
                  var tFirstPathSeg = sdk.DataModel.Segments.getById({ segmentId: t.segmentPath[0] });
                  if (tFromSeg == null || tFirstPathSeg == null) return false;

                  var tEntryNode = ja_get_connecting_node(tFromSeg, tFirstPathSeg);
                  if (tEntryNode !== entryNodeForTurns) return false;

                  // Determine which node of firstPathSeg is the "exit" (not the entry)
                  var firstPathExitNode = null;
                  if (tFirstPathSeg.fromNodeId === entryNodeForTurns) {
                    firstPathExitNode = tFirstPathSeg.toNodeId; // traverse toward toNodeId
                  } else if (tFirstPathSeg.toNodeId === entryNodeForTurns) {
                    firstPathExitNode = tFirstPathSeg.fromNodeId; // traverse toward fromNodeId
                  } else {
                    return false; // doesn't touch entry node
                  }

                  // Now check: if there's a second segment in the path, it should connect
                  // from firstPathExitNode. If it doesn't, this traversal direction is wrong.
                  if (t.segmentPath.length > 1) {
                    var tSecondPathSeg = sdk.DataModel.Segments.getById({ segmentId: t.segmentPath[1] });
                    if (tSecondPathSeg == null) return false;

                    // Check if secondPathSeg connects to firstPathExitNode
                    if (tSecondPathSeg.fromNodeId !== firstPathExitNode && tSecondPathSeg.toNodeId !== firstPathExitNode) {
                      // Second segment doesn't connect to exit of first segment
                      // This indicates wrong traversal direction through first segment
                      ja_log('[FAR-TURNS] Rejecting turn ' + t.id + ' — path breaks at first→second segment connection', 3);
                      return false;
                    }
                  }

                  return true;
                });

                ja_log('[FAR-TURNS] Filtered to ' + fallbackTurns.length + ' far turns FROM entry segment ' + segId, 2);

                if (fallbackTurns.length > 0) {
                  turnsFromSeg = fallbackTurns;
                  ja_log('[FAR-TURNS] Using getAllPossibleTurns() results as fallback', 3);
                }
              } catch (e) {
                ja_log('[FAR-TURNS] getAllPossibleTurns(' + bj.id + ') threw error: ' + e.message, 1);
              }
            } else if (isJBSegment && entryNodeForTurns !== node.id) {
              ja_log('[FAR-TURNS] Segment ' + segId + ' is a JB segment, but entry node is ' + entryNodeForTurns + ', not current node ' + node.id + ' — skipping', 3);
              return; // Skip this segment at this node
            }
          }
        }
      }

      turnsFromSeg.forEach(function (turn) {
        ja_log('[FAR-TURNS] Turn ' + turn.id + ': isPathTurn=' + turn.isPathTurn + ', isJunctionBoxTurn=' + turn.isJunctionBoxTurn, 3);

        // Only process far turns (Path turns or Junction Box turns).
        // Regular turns (isPathTurn=false, isJunctionBoxTurn=false) are already handled
        // by the existing ja_draw_node_markers() loop and should not be duplicated here.
        // Also accept turns with intermediate segments (from getAllPossibleTurns fallback)
        // even if they don't have the isJunctionBoxTurn flag set.
        var isFarTurn = turn.isPathTurn || turn.isJunctionBoxTurn || (turn.segmentPath && turn.segmentPath.length > 0);
        if (!isFarTurn) {
          ja_log('[FAR-TURNS] Skipping turn ' + turn.id + ' — not a far turn', 3);
          return;
        }

        // segmentPath[] contains the IDs of the intermediate segments connecting
        // fromSegmentId to toSegmentId. It excludes the entry and exit segments themselves.
        // For a valid far turn segmentPath should always have at least one entry — if it is
        // somehow empty we cannot determine the entry node, so skip.
        if (!turn.segmentPath || turn.segmentPath.length === 0) {
          ja_log('[FAR-TURNS] Skipping far turn ' + turn.id + ' — segmentPath is empty', 1);
          return;
        }

        // Fetch the entry segment for this far turn.
        var fromSeg = sdk.DataModel.Segments.getById({ segmentId: turn.fromSegmentId });
        if (fromSeg == null) {
          ja_log('[FAR-TURNS] Skipping far turn ' + turn.id + ' — fromSegment not found', 1);
          return;
        }

        // For fallback turns from getAllPossibleTurns(): verify path orientation.
        // getAllPossibleTurns() returns paths using median segments in both forward and
        // reverse directions. We must skip paths that traverse median segments backward.
        var isFallbackTurn = !turn.isJunctionBoxTurn && turn.segmentPath && turn.segmentPath.length > 0;
        if (isFallbackTurn) {
          var firstPathSegId = turn.segmentPath[0];
          var firstPathSeg = sdk.DataModel.Segments.getById({ segmentId: firstPathSegId });
          if (firstPathSeg == null) {
            ja_log('[FAR-TURNS] Skipping fallback turn ' + turn.id + ' — firstPathSeg not found', 1);
            return;
          }
          // Check if fromSeg actually connects to firstPathSeg. If no connection exists,
          // it means the path uses a segment in the wrong (backward) orientation.
          var testConnectNode = ja_get_connecting_node(fromSeg, firstPathSeg);
          if (testConnectNode == null) {
            ja_log('[FAR-TURNS] Skipping fallback turn ' + turn.id + ' — cannot connect to first path segment (wrong orientation)', 3);
            return;
          }
        }

        // CRITICAL: Verify the current node is the actual JB/path entry node.
        //
        // Problem: getTurnsFromSegment returns the same far turn when called from EITHER
        // endpoint of the fromSegment. A selected segment A→B only enters the JB at B,
        // but we process both node A and node B. Without this check, markers appear at A too.
        //
        // For fallback turns from getAllPossibleTurns(), they are indexed to a specific
        // entry node. We must validate before marking as seen, otherwise the turn won't
        // be processed when we reach the correct node.
        //
        // Fix: The true entry node is the node shared between fromSegment and the FIRST
        // segment in segmentPath[]. That is the point where the segment physically hands off
        // into the JB or path. If the current node is not that shared node, skip this turn.
        var firstPathSeg = sdk.DataModel.Segments.getById({ segmentId: turn.segmentPath[0] });
        if (firstPathSeg == null) {
          ja_log('[FAR-TURNS] Skipping far turn ' + turn.id + ' — firstPathSeg not found', 1);
          return;
        }
        var entryNodeId = ja_get_connecting_node(fromSeg, firstPathSeg);
        if (entryNodeId !== node.id) {
          // This node is not the JB/path entry — the far turn belongs to the other end.
          ja_log('[FAR-TURNS] Skipping far turn ' + turn.id + ' at node ' + node.id + ' — entry is at node ' + entryNodeId, 3);
          return;
        }

        // Deduplicate: a far turn may appear in results for multiple connected segments.
        // Only draw it once per node visit. This is checked AFTER node validation so that
        // fallback turns (which are node-specific) can be processed from their correct node
        // even if they were encountered and skipped from a different node first.
        if (seenTurnIds[turn.id]) return;
        seenTurnIds[turn.id] = true;

        // Check if this turn type is enabled via experimental settings
        if (turn.isJunctionBoxTurn && !ja_getOption('enableFarTurnJB')) {
          ja_log('[FAR-TURNS] Skipping JB turn ' + turn.id + ' — enableFarTurnJB is disabled', 3);
          return;
        }
        if (turn.isPathTurn && !ja_getOption('enableFarTurnPath')) {
          ja_log('[FAR-TURNS] Skipping Path turn ' + turn.id + ' — enableFarTurnPath is disabled', 3);
          return;
        }

        // Fetch the exit segment (NOT connected to this node — it is at the far end of the path).
        var toSeg = sdk.DataModel.Segments.getById({ segmentId: turn.toSegmentId });
        if (toSeg == null) {
          ja_log('[FAR-TURNS] Skipping far turn ' + turn.id + ' — toSegment not found', 1);
          return;
        }

        // Skip JB turns whose exit segment is itself contained inside a BigJunction.
        //
        // In a multi-segment JB path (E → M1 → M2 → Exit), the JB model can emit
        // separate turns for sub-routes where a median segment (M1 or M2) appears as
        // the toSegmentId. These represent internal routing steps, not the full path to
        // the real outside-exit segment. Drawing a marker for one of them would:
        //   1. Place the marker inside the JB polygon (at an internal node).
        //   2. Produce a duplicate alongside the correct marker at the real exit.
        //
        // Only turns where toSeg is a genuine external exit segment should generate markers.
        //
        // Note: this check is only applied to JB turns (isJunctionBoxTurn) because Path
        // turns (isPathTurn) do not use BigJunctions and their exit segments are always
        // external by definition.
        if (turn.isJunctionBoxTurn && sdk.DataModel.Segments.isContainedInBigJunction({ segmentId: turn.toSegmentId })) {
          ja_log('[FAR-TURNS] Skipping JB turn ' + turn.id + ' — toSegment ' + turn.toSegmentId + ' is a median (isContainedInBigJunction)', 1);
          return;
        }

        // To get the correct departure bearing of the exit segment we need to know which
        // of its two endpoint nodes connects to the path. That is the node shared between
        // the last intermediate segment (segmentPath[last]) and the exit segment.
        var lastPathSegId = turn.segmentPath[turn.segmentPath.length - 1];
        var lastPathSeg = sdk.DataModel.Segments.getById({ segmentId: lastPathSegId });
        if (lastPathSeg == null) {
          ja_log('[FAR-TURNS] Skipping far turn ' + turn.id + ' — lastPathSeg not found', 1);
          return;
        }

        // The connecting node is where the path "hands off" to the exit segment.
        // ja_getAngle() uses this to know which end of the exit segment faces the path.
        var connectingNodeId = ja_get_connecting_node(lastPathSeg, toSeg);
        if (connectingNodeId == null) {
          ja_log('[FAR-TURNS] Skipping far turn ' + turn.id + ' — could not find connecting node between lastPathSeg and toSeg', 1);
          return;
        }

        // BUILD PATH SEGMENTS ARRAY
        // ──────────────────────────────────────────────────────────────────
        // pathSegs = [fromSeg, segmentPath[0], ..., segmentPath[last], toSeg]
        var pathSegs = [fromSeg];
        var pathSegFetchOk = true;
        for (var pi = 0; pi < turn.segmentPath.length; pi++) {
          var pSeg = sdk.DataModel.Segments.getById({ segmentId: turn.segmentPath[pi] });
          if (pSeg == null) {
            pathSegFetchOk = false;
            break;
          }
          pathSegs.push(pSeg);
        }
        pathSegs.push(toSeg);

        if (!pathSegFetchOk) {
          ja_log('[FAR-TURNS] Skipping turn ' + turn.id + ' — could not fetch all path segments', 1);
          return;
        }

        // BREADCRUMB TRAIL: Draw a marker for each step in the path
        // ───────────────────────────────────────────────────────────────────
        // For both JB and Path turns: show round markers for intermediate steps, square for final exit
        // Deduplication prevents duplicate intermediate markers when multiple paths share median segments
        // Each marker shows the LOCAL turn angle at its connecting node.

        // Check restriction state: can be blocked at turn level (JB/Path) or at intermediate nodes
        // Track this so we can display gray marker instead of skipping entirely (for consistency with local turns)
        var isPathRestricted = false;
        var isFallbackJBTurn = !turn.isJunctionBoxTurn && !turn.isPathTurn && turn.segmentPath && turn.segmentPath.length > 0;

        // First check: Turn restriction set directly on the turn (JB or Path turns)
        // Both turn types now support restrictions
        if (turn.isJunctionBoxTurn && !turn.isAllowed) {
          ja_log('[FAR-TURNS] JB turn ' + turn.id + ' restricted: turn.isAllowed=false', 3);
          isPathRestricted = true;
        }
        if (turn.isPathTurn && !turn.isAllowed) {
          ja_log('[FAR-TURNS] Path turn ' + turn.id + ' restricted: turn.isAllowed=false', 3);
          isPathRestricted = true;
        }
        // For fallback JB turns (from getAllPossibleTurns): treat as restricted if not explicitly allowed
        // This is because JB turns are controlled by JB logic, and a missing "allowed" indicator likely means restriction
        if (isFallbackJBTurn) {
          // Show as restricted unless it clearly has an explicit "allowed" status from SDK
          // Since getAllPossibleTurns may not fully reflect WME-set restrictions, be conservative
          ja_log('[FAR-TURNS] Fallback JB turn ' + turn.id + ' marked as restricted (JB-controlled path)', 3);
          isPathRestricted = true;
        }

        // Build blockedSteps map for JB turns and fallback JB turns (check local node restrictions)
        var blockedSteps = {};
        var pathHasBlockedStep = false; // Track if ANY step is blocked
        if (turn.isJunctionBoxTurn || isFallbackJBTurn) {
          for (var si = 0; si < pathSegs.length - 1; si++) {
            var stepFrom = pathSegs[si];
            var stepTo = pathSegs[si + 1];
            var stepNodeId = ja_get_connecting_node(stepFrom, stepTo);
            if (stepNodeId == null) {
              blockedSteps[si] = true;
              pathHasBlockedStep = true;
              ja_log('[FAR-TURNS] Step ' + si + ' blocked: no connecting node', 3);
              continue;
            }
            var stepNode = sdk.DataModel.Nodes.getById({ nodeId: stepNodeId });
            if (stepNode == null) {
              blockedSteps[si] = true;
              pathHasBlockedStep = true;
              ja_log('[FAR-TURNS] Step ' + si + ' blocked: stepNode is null', 3);
            } else {
              var isAllowed = ja_is_turn_allowed(stepFrom, stepNode, stepTo);
              if (!isAllowed) {
                blockedSteps[si] = true;
                pathHasBlockedStep = true;
                ja_log('[FAR-TURNS] Step ' + si + ' blocked: turn restriction', 3);
              }

              // For fallback turns on final step: check JB turn instructions at entry node
              // JB turn instructions are stored at the first node inside the JB (toNodeId of entry segment)
              var isFinalStep = si === pathSegs.length - 2;
              if (isFallbackJBTurn && isFinalStep) {
                // Entry node is toNodeId of the entry segment (where it crosses INTO JB)
                var entryNodeId = fromSeg.toNodeId;
                var jbTurnsAtEntry = sdk.DataModel.Turns.getTurnsThroughNode({ nodeId: entryNodeId });
                var hasJBRestriction = jbTurnsAtEntry.some(function (t) {
                  // Check if this turn matches our path AND has a restriction
                  return t.fromSegmentId === turn.fromSegmentId && t.toSegmentId === turn.toSegmentId && (!t.isAllowed || t.instructionOpCode !== null);
                });
                if (hasJBRestriction) {
                  ja_log('[FAR-TURNS] Step ' + si + ' blocked by JB turn instruction at node ' + entryNodeId, 1);
                  blockedSteps[si] = true;
                  pathHasBlockedStep = true;
                }
              }
            }
          }
          // If ANY intermediate step is blocked, mark path as restricted but continue to draw gray marker
          if (pathHasBlockedStep) {
            var turnTypeStr = turn.isJunctionBoxTurn ? 'JB' : 'fallback JB';
            ja_log('[FAR-TURNS] ' + turnTypeStr + ' turn ' + turn.id + ' restricted: internal node restriction blocks this path', 3);
            isPathRestricted = true;
          }
        }

        // U-TURN TOTAL ANGLE ACCUMULATION
        // ────────────────────────────────
        // For U-TURN paths (instructionOpCode='UTURN'), accumulate all turn angles
        // through the path. The final marker will display the total heading change
        // (sum of all intermediate turns), not just the final step's local angle.
        // This is more robust for non-parallel/irregular geometries.
        var isUTurnPath = turn.instructionOpCode === 'UTURN';
        var uTurnAccumulatedAngle = 0;

        // Loop through each step and draw markers
        for (var stepIndex = 0; stepIndex < pathSegs.length - 1; stepIndex++) {
          var stepFrom = pathSegs[stepIndex];
          var stepTo = pathSegs[stepIndex + 1];
          var isFinalStep = stepIndex === pathSegs.length - 2;

          // Skip blocked steps (JB only)
          if (blockedSteps[stepIndex]) {
            ja_log('[FAR-TURNS] Skipping step ' + stepIndex + ' — local restriction', 3);
            continue;
          }

          // Get connecting node for this step
          var stepConnectingNodeId = ja_get_connecting_node(stepFrom, stepTo);
          if (stepConnectingNodeId == null) {
            ja_log('[FAR-TURNS] Step ' + stepIndex + ': no connecting node', 3);
            continue;
          }
          var stepConnectingNode = sdk.DataModel.Nodes.getById({ nodeId: stepConnectingNodeId });
          if (stepConnectingNode == null) {
            ja_log('[FAR-TURNS] Step ' + stepIndex + ': connecting node not found', 3);
            continue;
          }

          // Calculate angle at this step's connecting node
          var stepInAngle = ja_getAngle(stepConnectingNodeId, stepFrom);
          var stepOutAngle = ja_getAngle(stepConnectingNodeId, stepTo);
          if (stepInAngle == null || stepOutAngle == null) {
            ja_log('[FAR-TURNS] Step ' + stepIndex + ': could not compute angles', 3);
            continue;
          }

          var stepAngle = ja_angle_diff(stepInAngle, stepOutAngle, false);

          // Accumulate for U-TURN total (regardless of whether this step is drawn)
          if (isUTurnPath) {
            uTurnAccumulatedAngle += stepAngle;
          }

          // DEDUPLICATION: For intermediate steps, skip if already drawn
          if (!isFinalStep) {
            // Skip stepIndex 0 (entry node angle): already drawn by ja_draw_node_markers()
            // ja_draw_node_markers processes this node and draws all connected segment angles,
            // including entry→firstMedianSegment. Drawing it again here as the first breadcrumb
            // would create a duplicate marker.
            if (stepIndex === 0) {
              ja_log('[FAR-TURNS] Skip step 0 (already drawn by ja_draw_node_markers)', 4);
              continue;
            }

            // Skip if another path already drew this node+angle pair
            // Multiple paths may traverse the same median segment pair (e.g., A→M1→B and C→M1→B).
            // We only need to show the marker once.
            var stepKey = stepConnectingNodeId + '_' + ja_round(Math.abs(stepAngle));
            if (drawnIntermediateSteps[stepKey]) {
              ja_log('[FAR-TURNS] Skip step ' + stepIndex + ' (already drawn)', 4);
              continue;
            }
            drawnIntermediateSteps[stepKey] = true;
          }

          // Determine marker type
          var stepMarkerType;
          if (isFinalStep) {
            // FINAL STEP: apply turn.instructionOpCode override and check exit restriction
            stepMarkerType = ja_getOption('guess') ? ja_get_final_step_type(turn, stepConnectingNode, lastPathSegId, lastPathSeg, toSeg, stepAngle) : ja_routing_type.TURN;
          } else {
            // INTERMEDIATE STEP: use full routing instruction logic to match local turn colors
            // Build angles array for this node (all connected segments with their bearings)
            var stepAngles = [];
            stepConnectingNode.connectedSegmentIds.forEach(function (connSegId) {
              var connSeg = sdk.DataModel.Segments.getById({ segmentId: connSegId });
              if (connSeg) {
                var connBearing = ja_getAngle(stepConnectingNodeId, connSeg);
                if (connBearing != null) {
                  stepAngles.push([connBearing, connSegId, false]);
                }
              }
            });
            // Apply full routing instruction logic, matching regular departure-mode turns
            // Pass false to suppress "Turn blocked" logs since they're redundant with on-demand evaluation
            stepMarkerType = ja_getOption('guess') ? ja_guess_routing_instruction(stepConnectingNode, stepFrom.id, stepTo.id, stepAngles, false) : ja_routing_type.TURN;
          }

          // If path is restricted (JB turn level or intermediate node), override to NO_TURN (gray marker)
          // This applies to both final and intermediate steps so the entire path appears gray
          if (isPathRestricted) {
            stepMarkerType = ja_routing_type.NO_TURN;
          }

          // Determine marker placement anchor
          var stepMarkerAnchor = stepConnectingNode;
          var stepExitBearing = stepOutAngle;

          // For final step, place at JB boundary crossing (not at node) if it's a JB or fallback JB turn
          // This applies to:
          // A) Turns with isJunctionBoxTurn flag (normal SDK behavior)
          // B) Fallback turns from getAllPossibleTurns() that cross JB boundaries
          //    (these have segmentPath but not the JB flag set)
          var shouldRepositionToBoundary = isFinalStep && (turn.isJunctionBoxTurn || isFallbackJBTurn);

          if (shouldRepositionToBoundary) {
            var bigJunction = null;
            var firstPathSegId = turn.segmentPath[0];

            // Find the BigJunction containing the first path segment (median segment)
            for (var bji = 0; bji < allBigJunctions.length; bji++) {
              if (allBigJunctions[bji].segmentIds.indexOf(firstPathSegId) !== -1) {
                bigJunction = allBigJunctions[bji];
                break;
              }
            }

            if (bigJunction != null) {
              var bjPolygon = turf.polygon(bigJunction.geometry.coordinates);
              var closestPt = ja_find_closest_bj_intersection(toSeg, bjPolygon, stepConnectingNode);
              if (closestPt !== null) {
                stepMarkerAnchor = { geometry: closestPt.geometry };
                ja_log('[FAR-TURNS] Step ' + stepIndex + ': repositioned to JB boundary (isFallback=' + isFallbackJBTurn + ')', 2);
              }
            }
          }

          // Compute label distance corrected for latitude
          var stepJaLd = ja_corrected_ld(ja_label_distance, stepMarkerAnchor.geometry.coordinates);

          // Apply extra-space multiplier for visibility
          var stepExtraSpace = ja_compute_extra_space(stepAngle, stepExitBearing);

          // CHECK FOR LOCAL TURN MARKER CONFLICT
          // If a local turn at this node targets the same direction, move this far-turn marker farther out
          var hasLocalConflict = ja_local_markers_by_node[stepConnectingNodeId] &&
                                 ja_local_markers_by_node[stepConnectingNodeId].some(function(ltMarker) {
                                   return ja_markers_target_same_direction(stepExitBearing, ltMarker.bearing, 30);
                                 });

          // Check if we repositioned to JB boundary (stepMarkerAnchor !== stepConnectingNode)
          var isAtBoundary = stepMarkerAnchor !== stepConnectingNode;

          // Determine distance multiplier: prioritize boundary distance, then local conflict distance
          var stepDistanceMultiplier = stepExtraSpace;
          if (isAtBoundary) {
            // At JB boundary: use larger distance to avoid overlapping with WME turn restriction arrows
            stepDistanceMultiplier = 2.5;
            if (hasLocalConflict) {
              ja_log('[MARKER-OVERLAP] Far-turn + local conflict at node ' + stepConnectingNodeId + ': 2.5x distance', 3);
            } else {
              ja_log('[MARKER-OVERLAP] Far-turn at JB boundary: 2.5x distance', 3);
            }
          } else if (hasLocalConflict) {
            stepDistanceMultiplier = 1.5;
            ja_log('[MARKER-OVERLAP] Far-turn conflict: 1.5x distance', 3);
          }

          // Calculate marker position
          var stepPoint = turf.destination(turf.point(stepMarkerAnchor.geometry.coordinates), (stepDistanceMultiplier * stepJaLd) / 1000, ja_math_to_compass(stepExitBearing)).geometry;

          // Determine marker shape: round for intermediate, square for final
          var stepIsSquareMarker = isFinalStep;

          // For U-TURN paths on final step, use accumulated total angle instead of local step angle
          var angleToDisplay = stepAngle;
          var angleLogDetail = '';
          if (isUTurnPath && isFinalStep) {
            angleToDisplay = uTurnAccumulatedAngle;
            angleLogDetail = ' (U-TURN: ' + uTurnAccumulatedAngle.toFixed(1) + '° accumulated)';
          }

          ja_log('[FAR-TURNS] Step ' + stepIndex + ': angle=' + angleToDisplay.toFixed(1) + '° type=' + stepMarkerType + angleLogDetail, 2);

          // Draw the marker (isFarTurn=true, isSquareMarker=stepIsSquareMarker)
          ja_draw_marker(stepPoint, stepMarkerAnchor, stepJaLd, angleToDisplay, stepExitBearing, true, stepMarkerType, true, stepIsSquareMarker);

          // Record this far-turn marker for local turn conflict detection
          if (!ja_far_turn_bearings_by_node[stepConnectingNodeId]) {
            ja_far_turn_bearings_by_node[stepConnectingNodeId] = [];
          }
          ja_far_turn_bearings_by_node[stepConnectingNodeId].push({ bearing: stepExitBearing, distance: stepDistanceMultiplier * stepJaLd });
        }
      });
    });
  }

  // ── End far turn support ──────────────────────────────────────────────────────────────────────

  /**
   * Decimal adjustment of a number. Borrowed (with some modifications) from
   * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round
   * ja_round(55.55); with 1 decimal // 55.6
   * ja_round(55.549); with 1 decimal // 55.5
   * ja_round(55); with -1 decimals // 60
   * ja_round(54.9); with -1 decimals // 50
   *
   * @param	{Number}	value	The number.
   * @returns	{Number}			The adjusted value.
   */
  function ja_round(value) {
    var ja_rounding = -parseInt(ja_getOption('decimals'));
    var valueArray;
    if (typeof ja_rounding === 'undefined' || +ja_rounding === 0) {
      return Math.round(value);
    }
    value = +value;
    // If the value is not a number or the exp is not an integer...
    if (isNaN(value) || !(typeof ja_rounding === 'number' && ja_rounding % 1 === 0)) {
      return NaN;
    }
    // Shift
    valueArray = value.toString().split('e');
    value = Math.round(+(valueArray[0] + 'e' + (valueArray[1] ? +valueArray[1] - ja_rounding : -ja_rounding)));
    // Shift back
    valueArray = value.toString().split('e');
    return +(valueArray[0] + 'e' + (valueArray[1] ? +valueArray[1] + ja_rounding : ja_rounding));
  }

  // ── Settings I/O ──────────────────────────────────────────────────────────────

  /**
   * Returns the current value of a user setting, falling back to its default if missing or
   * invalid.
   *
   * **Important:** `name` must be a key that exists in `ja_settings`.  Calling this with an
   * arbitrary key (e.g. `'lastVersion'`) will throw because `ja_settings[name]` is undefined.
   * For bookkeeping values not in the settings schema, read `ja_options[key]` directly.
   *
   * Validation rules per element type:
   * - `select` — value must appear in `ja_settings[name].options`
   * - `color` — value must match `/#[0-9a-f]{6}/`
   * - `number` — value must be within `[min, max]` and not NaN
   * - `checkbox` — value must be a boolean
   *
   * @param {string} name - Key from the `ja_settings` schema.
   * @returns {*} The stored (or default) setting value.
   */
  function ja_getOption(name) {
    ja_log('Loading option: ' + name, 3);
    if (!ja_options.hasOwnProperty(name) || typeof ja_options[name] === 'undefined') {
      ja_options[name] = ja_settings[name].defaultValue;
    }
    //Check for invalid values
    //Select values
    if (ja_settings[name].elementType === 'select' && ja_settings[name].options.lastIndexOf(ja_options[name]) < 0) {
      ja_log(ja_settings[name].options, 4);
      ja_log('Found invalid value for setting ' + name + ': ' + ja_options[name] + '. Using default.', 3);
      ja_options[name] = ja_settings[name].defaultValue;
    }
    //Color values
    else if (ja_settings[name].elementType === 'color' && String(ja_options[name]).match(/#[0-9a-f]{6}/) == null) {
      ja_log('Found invalid value for setting ' + name + ': "' + ja_options[name] + '". Using default.', 3);
      ja_options[name] = ja_settings[name].defaultValue;
    }
    //Numeric values
    else if (ja_settings[name].elementType === 'number') {
      var minValue = typeof ja_settings[name].min === 'undefined' ? Number.MIN_VALUE : ja_settings[name].min;
      var maxValue = typeof ja_settings[name].max === 'undefined' ? Number.MAX_VALUE : ja_settings[name].max;
      if (isNaN(ja_options[name]) || ja_options[name] < minValue || ja_options[name] > maxValue) {
        ja_log('Found invalid value for setting ' + name + ': "' + ja_options[name] + '". Using default.', 3);
        ja_options[name] = ja_settings[name].defaultValue;
      }
    }
    //Checkboxes
    else if (ja_settings[name].elementType === 'checkbox' && ja_options[name] !== true && ja_options[name] !== false) {
      ja_log('Found invalid value for setting ' + name + ': "' + ja_options[name] + '". Using default.', 3);
      ja_options[name] = ja_settings[name].defaultValue;
    }

    ja_log('Got value: ' + ja_options[name], 3);
    return ja_options[name];
  }

  /**
   * Persists a single setting value to `ja_options` and immediately writes the entire options
   * object to `localStorage` under the key `wme_ja_options`.
   *
   * @param {string} name - Key from the `ja_settings` schema (or a raw `ja_options` key for
   *   bookkeeping values like `lastVersion`).
   * @param {*} val - New value to store.
   */
  function ja_setOption(name, val) {
    ja_options[name] = val;
    if (localStorage) {
      localStorage.setItem('wme_ja_options', JSON.stringify(ja_options));
    }
    ja_log(ja_options, 3);
  }

  /**
   * `onchange` handler attached to every settings control in the sidebar form.
   *
   * Checks whether the new control value actually differs from the stored value; if so it
   * sets `applyPending = true` and schedules `ja_save()` after a 500 ms debounce.
   *
   * Also handles enabling/disabling dependent controls:
   * - `override` checkbox — enables/disables the override sub-group
   * - `guess` checkbox — enables/disables the guess group and, transitively, the override
   *   sub-group
   * - `roundaboutOverlayDisplay` select — enables/disables the overlay color pickers
   *
   * Contains the inner helper `disable_input(element, disable)` which toggles `element.disabled`
   * and adds/removes the `disabled` CSS class on the parent node so the CSS overlay covers the
   * control visually.
   *
   * @param {HTMLInputElement|HTMLSelectElement} e - The DOM element that fired the `change`
   *   event (passed as `this` from the inline `onchange` handler in `setupHtml`).
   */
  var ja_onchange = function (e) {
    var applyPending = false;
    var settingName = Object.getOwnPropertyNames(ja_settings).filter(function (a) {
      ja_log(ja_settings[a], 4);
      return ja_settings[a].elementId === e.id;
    })[0];
    ja_log(e, 4);
    ja_log(settingName, 4);
    switch (ja_settings[settingName].elementType) {
      case 'checkbox':
        ja_log('Checkbox setting ' + e.id + ': stored value is: ' + ja_options[settingName] + ', new value: ' + e.checked, 3);
        if (ja_options[settingName] !== e.checked) {
          applyPending = true;
        }
        break;
      case 'select':
      case 'color':
      case 'number':
        ja_log('Setting ' + e.id + ': stored value is: ' + ja_options[settingName] + ', new value: ' + e.value, 3);
        if (String(ja_options[settingName]) !== String(e.value)) {
          applyPending = true;
        }
        break;
      default:
        ja_log('Unknown setting ' + e.id + ': stored value is: ' + ja_options[settingName] + ', new value: ' + e.value, 3);
    }

    /**
     * Toggles the disabled state of a settings control and its parent container's `disabled`
     * CSS class, which triggers the translucent overlay defined in the sidebar stylesheet.
     *
     * @param {HTMLElement} element - The input or select element to enable/disable.
     * @param {boolean} disable - True to disable and show the overlay; false to enable.
     */
    function disable_input(element, disable) {
      element.disabled = disable;
      var row = element.closest('.ja-row');
      if (row) {
        if (disable) {
          row.classList.add('disabled');
        } else {
          row.classList.remove('disabled');
        }
      }
    }

    //Enable|disable certain dependent settings
    switch (e.id) {
      case ja_settings.override.elementId:
        Object.getOwnPropertyNames(ja_settings).forEach(function (a) {
          var setting = ja_settings[a];
          if (setting.group && setting.group === 'override') {
            ja_log(a + ': ' + !e.checked, 3);
            disable_input(document.getElementById(setting.elementId), !e.checked || e.disabled);
          }
        });
        break;
      case ja_settings.guess.elementId:
        Object.getOwnPropertyNames(ja_settings).forEach(function (a) {
          var setting = ja_settings[a];
          if (setting.group && (setting.group === 'guess' || setting.group === 'override')) {
            ja_log(a + ': ' + !e.checked, 3);
            var overrideCb = document.getElementById(ja_settings.override.elementId);
            var shouldDisable = !e.checked || (setting.group === 'override' && (!overrideCb.checked || overrideCb.disabled));
            disable_input(document.getElementById(setting.elementId), shouldDisable);
          }
        });
        break;
      case ja_settings.roundaboutOverlayDisplay.elementId:
        Object.getOwnPropertyNames(ja_settings).forEach(function (a) {
          var setting = ja_settings[a];
          if (setting.group && setting.group === 'roundaboutOverlayDisplay') {
            ja_log(a + ': ' + e.value, 3);
            disable_input(document.getElementById(setting.elementId), e.value === 'rOverNever');
          }
        });
        break;
      // PHASE 4: Continuous Scanning toggle handler
      case ja_settings.continuousScanning.elementId:
        ja_log('Continuous scanning toggle: ' + e.checked, 2);
        ja_continuous_enabled = e.checked;

        if (e.checked) {
          // Enable: only start if layer is visible; otherwise just set flag and wait for layer to be shown
          if (ja_layer_visible) {
            ja_continuous_mode = true;
            ja_start_continuous_scan();
            ja_log('Continuous scanning enabled. Markers persist until segment edits.', 2);
          } else {
            ja_continuous_mode = false;
            ja_log('Continuous scanning enabled (setting saved) but layer is hidden. Will activate when layer is shown.', 2);
          }
        } else {
          // Disable: stop scanning, clear timer and state (markers stay on layer)
          ja_continuous_mode = false;
          if (ja_continuous_scan_timer) {
            clearTimeout(ja_continuous_scan_timer);
            ja_continuous_scan_timer = null;
          }
          // Note: we DON'T clear cache or rendered markers when disabled.
          // They persist and won't update until user re-enables scanning.
          // This allows user to review markers without continuous background scanning.
          ja_continuous_batch_index = 0;
          ja_log('Continuous scanning disabled. Markers remain visible; scanning paused.', 2);
        }
        break;
      default:
        ja_log('Nothing to do for ' + e.id, 3);
    }

    ja_log('Apply pending configuration changes? ' + applyPending, 3);
    if (applyPending) {
      ja_log('Applying new settings now', 3);
      setTimeout(function () {
        ja_save();
      }, 500);
    } else {
      ja_log('No new settings to apply', 3);
    }
  };

  /**
   * Loads saved settings from `localStorage` into `ja_options` at startup.
   *
   * If `localStorage` is unavailable or the stored JSON is malformed, falls back to
   * `ja_reset()` which clears `ja_options` and calls `ja_apply()` with all defaults.
   * On a successful load, schedules `ja_apply()` after 500 ms to let the sidebar DOM
   * finish rendering before populating form controls.
   */
  var ja_load = function loadJAOptions() {
    ja_log('Should load settings now.', 3);
    if (localStorage != null) {
      ja_log('We have local storage! =)', 3);
      try {
        ja_options = JSON.parse(localStorage.getItem('wme_ja_options'));
      } catch (e) {
        ja_log('Loading settings failed.. ' + e.message, 1);
        ja_options = null;
      }
    }
    if (ja_options == null) {
      ja_reset();
    } else {
      ja_log(ja_options, 4);
      setTimeout(function () {
        ja_apply();
      }, 500);
    }
  };

  /**
   * Reads every settings control from the sidebar DOM and writes the values to `ja_options`
   * via `ja_setOption` (which also persists to `localStorage`).
   *
   * Color inputs with invalid hex are silently reverted to the schema default.  Number inputs
   * outside their `[min, max]` range are similarly reverted.  After saving, calls `ja_apply()`
   * to push the new values back into the style context and redraw markers.
   *
   * @returns {boolean} Always returns false (used as the form `onsubmit` handler to prevent
   *   page reload).
   */
  var ja_save = function saveJAOptions() {
    ja_log('Saving settings', 2);
    Object.getOwnPropertyNames(ja_settings).forEach(function (a) {
      var setting = ja_settings[a];
      ja_log(setting, 4);
      switch (setting.elementType) {
        case 'checkbox':
          ja_setOption(a, document.getElementById(setting.elementId).checked);
          break;
        case 'color':
          var re = /^#[0-9a-f]{6}$/;
          if (re.test(document.getElementById(setting.elementId).value)) {
            ja_setOption(a, document.getElementById(setting.elementId).value);
          } else {
            ja_setOption(a, ja_settings[a]['default']);
          }
          break;
        case 'number':
          var val = parseInt(document.getElementById(setting.elementId).value);
          if (!isNaN(val) && val === parseInt(val) && setting.min <= val && val <= setting.max) {
            ja_setOption(a, document.getElementById(setting.elementId).value);
          } else {
            ja_setOption(a, ja_settings[a]['default']);
          }
          break;
        case 'text':
        case 'select':
          ja_setOption(a, document.getElementById(setting.elementId).value);
          break;
        default:
          ja_log('Unknown setting type ' + setting.elementType, 1);
      }
    });
    ja_apply();
    return false;
  };

  /**
   * Pushes all stored settings back into the sidebar DOM controls and triggers a map redraw.
   *
   * If the SDK layer isn't ready yet (`ja_layer_created === false`) the function reschedules
   * itself after 400 ms and returns early.  Similarly, if the `#sidepanel-ja` element doesn't
   * exist yet, the DOM-population step is skipped (the settings will be applied on the next
   * call once the sidebar has rendered).
   *
   * After populating controls it calls `ja_calculate_real()` to refresh angle markers using
   * the updated style context — this is the mechanism by which color-setting changes take
   * effect immediately without a page reload.
   */
  var ja_apply = function applyJAOptions() {
    ja_log('Applying stored (or default) settings', 2);
    if (!ja_layer_created) {
      ja_log('Layer not ready yet, trying again in 400 ms', 3);
      setTimeout(function () {
        ja_apply();
      }, 400);
      return;
    }
    if (document.getElementById('sidepanel-ja') == null) {
      ja_log('WME not ready (no settings tab)', 3);
    } else {
      ja_log(Object.getOwnPropertyNames(ja_settings), 4);
      Object.getOwnPropertyNames(ja_settings).forEach(function (a) {
        var setting = ja_settings[a];
        ja_log(a, 4);
        ja_log(setting, 4);
        ja_log(document.getElementById(setting.elementId), 4);
        switch (setting.elementType) {
          case 'checkbox':
            document.getElementById(setting.elementId).checked = ja_getOption(a);
            document.getElementById(setting.elementId).onchange(null);
            break;
          case 'color':
          case 'number':
          case 'text':
            document.getElementById(setting.elementId).value = ja_getOption(a);
            break;
          case 'select':
            document.getElementById(setting.elementId).value = ja_getOption(a);
            document.getElementById(setting.elementId).onchange(null);
            break;
          default:
            ja_log('Unknown setting type ' + setting.elementType, 1);
        }
      });
    }
    // Style driven by ja_build_style_context() closures — recalculate refreshes colors.
    ja_calculate_real();
    ja_log(ja_options, 4);

    // PHASE 4: Initialize Continuous Scanning if enabled AND layer is visible
    ja_continuous_enabled = ja_getOption('continuousScanning') || false;
    if (ja_continuous_enabled && ja_layer_visible) {
      ja_continuous_mode = true;
      ja_log('Continuous scanning enabled on startup', 2);
      // Start the first scan after a brief delay to ensure map is ready
      setTimeout(() => ja_start_continuous_scan(), 500);
    } else {
      ja_continuous_mode = false;
      if (!ja_layer_visible) {
        ja_log('Layer is hidden, continuous scanning suppressed', 2);
      }
    }
  };

  /**
   * Clears all saved settings and redraws markers with default values.
   *
   * Removes the `wme_ja_options` key from `localStorage` entirely, empties `ja_options`, then
   * calls `ja_apply()` which will fall back to schema defaults for every setting via
   * `ja_getOption`.
   *
   * @returns {boolean} Always returns false (used as the Reset button's `onclick` handler).
   */
  var ja_reset = function resetJAOptions() {
    ja_log('Resetting settings', 2);
    if (localStorage != null) {
      localStorage.removeItem('wme_ja_options');
    }
    ja_options = {};
    ja_apply();
    return false;
  };

  /**
   * Creates a `<li>` element containing an anchor that opens an external URL in a new tab.
   *
   * The anchor's text is retrieved via `ja_getMessage(text)` so the link label participates in
   * the i18n translation system.  Used in `setupHtml` to populate the "Version info & links"
   * list at the bottom of the sidebar panel.
   *
   * @param {string} url - Fully-qualified URL for the link's `href`.
   * @param {string} text - I18n message key (looked up via `ja_getMessage`).
   * @returns {HTMLLIElement} The newly created list item element.
   */
  function ja_helpLink(url, text) {
    var elem = document.createElement('li');
    var l = document.createElement('a');
    l.href = url;
    l.target = '_blank';
    l.appendChild(document.createTextNode(ja_getMessage(text)));
    elem.appendChild(l);
    return elem;
  }

  // ── Calculation debounce timer ────────────────────────────────────────────────
  //
  // Debounces calls to `ja_calculate_real()` so that rapid successive WME events
  // (e.g. map-move + selection-changed firing together) only trigger one redraw.
  // `ja_calculate()` always goes through this object; never call `ja_calculate_real()`
  // directly from event handlers except where an immediate synchronous redraw is required
  // (e.g. inside `ja_apply`).
  var ja_calculation_timer = {
    start: function () {
      ja_log('Starting timer', 3);
      this.cancel();
      var ja_calculation_timer_self = this;
      this.timeoutID = setTimeout(function () {
        ja_calculation_timer_self.calculate();
      }, 200);
    },

    calculate: function () {
      ja_calculate_real();
      delete this.timeoutID;
    },

    cancel: function () {
      if (typeof this.timeoutID === 'number') {
        clearTimeout(this.timeoutID);
        ja_log('Cleared timeout ID : ' + this.timeoutID, 3);
        delete this.timeoutID;
      }
    },
  };

  /**
   * Public entry point for triggering a map redraw.
   *
   * All WME event handlers call this function rather than `ja_calculate_real()` directly.
   * It delegates to `ja_calculation_timer.start()` which applies a 200 ms debounce so that
   * clusters of rapid events (e.g. selection change + data model update) only produce one
   * redraw.
   */
  function ja_calculate() {
    ja_calculation_timer.start();
  }

  /**
   * Returns `'black'` or `'white'` for use as a text color that contrasts against the given
   * background.
   *
   * Uses the YIQ luminance formula (`(R*299 + G*587 + B*114) / 1000`) — a perceptual
   * weighting that approximates human sensitivity to each colour channel.  Values ≥ 128
   * are considered light backgrounds (black text); < 128 are dark (white text).
   *
   * @param {string} hex_color - Six-digit hex color string (e.g. `'#aa0000'`).
   * @returns {'black'|'white'} High-contrast text color.
   */
  function ja_get_contrast_color(hex_color) {
    ja_log('Parsing YIQ-based contrast color for: ' + hex_color + ' ...', 3);
    var r = parseInt(hex_color.substring(1, 3), 16);
    var g = parseInt(hex_color.substring(3, 5), 16);
    var b = parseInt(hex_color.substring(5, 7), 16);
    var yiq = (r * 299 + g * 587 + b * 114) / 1000;
    return yiq >= 128 ? 'black' : 'white';
  }

  /**
   * Returns the user-configured fill color for a given routing-instruction type.
   *
   * Centralises the mapping from `ja_routing_type` constants to the corresponding color option
   * key so that both `ja_build_style_context()` and any future callers have a single source of
   * truth.  Override instruction types map to the same color buckets as their non-override
   * equivalents (e.g. `OverrideTURN_LEFT` → `turnInstructionColor`).
   *
   * Returns the generic fallback `'#ffcc88'` for any type not explicitly listed — this acts as
   * a visible sentinel to catch unhandled new routing types during development.
   *
   * @param {string} ja_type - A value from the `ja_routing_type` enum.
   * @returns {string} Six-digit hex color string (e.g. `'#00bd00'`).
   */
  function ja_fill_color_for_type(ja_type) {
    if (ja_type === ja_routing_type.TURN || ja_type === ja_routing_type.TURN_LEFT || ja_type === ja_routing_type.TURN_RIGHT) return ja_getOption('turnInstructionColor');
    if (ja_type === ja_routing_type.BC) return ja_getOption('noInstructionColor');
    if (ja_type === ja_routing_type.KEEP || ja_type === ja_routing_type.KEEP_LEFT || ja_type === ja_routing_type.KEEP_RIGHT) return ja_getOption('keepInstructionColor');
    if (ja_type === ja_routing_type.EXIT || ja_type === ja_routing_type.EXIT_LEFT || ja_type === ja_routing_type.EXIT_RIGHT) return ja_getOption('exitInstructionColor');
    if (ja_type === ja_routing_type.U_TURN) return ja_getOption('uTurnInstructionColor');
    if (ja_type === ja_routing_type.NO_TURN || ja_type === ja_routing_type.NO_U_TURN) return ja_getOption('noTurnColor');
    if (ja_type === ja_routing_type.PROBLEM) return ja_getOption('problemColor');
    if (ja_type === ja_routing_type.ROUNDABOUT) return ja_getOption('roundaboutColor');
    if (ja_type === ja_routing_type.ROUNDABOUT_EXIT) return ja_getOption('exitInstructionColor');
    if (ja_type === ja_routing_type.OverrideTURN_LEFT || ja_type === ja_routing_type.OverrideTURN_RIGHT) return ja_getOption('turnInstructionColor');
    if (ja_type === ja_routing_type.OverrideBC) return ja_getOption('noInstructionColor');
    if (ja_type === ja_routing_type.OverrideCONTINUE) return ja_getOption('continueInstructionColor');
    if (ja_type === ja_routing_type.OverrideKEEP_LEFT || ja_type === ja_routing_type.OverrideKEEP_RIGHT) return ja_getOption('keepInstructionColor');
    if (ja_type === ja_routing_type.OverrideEXIT || ja_type === ja_routing_type.OverrideEXIT_LEFT || ja_type === ja_routing_type.OverrideEXIT_RIGHT) return ja_getOption('exitInstructionColor');
    if (ja_type === ja_routing_type.OverrideU_TURN) return ja_getOption('uTurnInstructionColor');

    return '#ffcc88'; // default/generic — visible sentinel for unhandled types
  }

  /**
   * Builds the SDK `styleContext` object for the `junction_angles` map layer.
   *
   * Each property is a function that receives an `{feature}` context object and returns the
   * value for the corresponding style attribute at render time.  Because the functions close
   * over `ja_getOption`, changes to color settings take effect immediately on the next redraw
   * without needing to recreate the layer.
   *
   * Style context properties:
   * - `ja_fillColor` — circle background; transparent for `arrow_line` and `roundaboutOverlay`
   * - `ja_fontColor` — auto-contrasted against fill; transparent for non-text features
   * - `ja_strokeColor` — dark green default; salmon for `arrow_line`; orange for Override types
   *                      (Override color applies equally to regular and far-turn Override markers)
   * - `ja_strokeWidth` — thicker border for Override types to aid visual distinction
   * - `ja_graphicName` — 'square' for far-turn markers (JB/Path); 'circle' for regular turns
   * - `ja_strokeOpacity` — semi-transparent for `arrow_line` features
   * - `ja_fillOpacity` — 10 % for `roundaboutOverlay` polygon; 0 for `arrow_line`
   * - `ja_pointRadius` — grows with decimal-count setting; extra width in Simple display mode
   * - `ja_fontSize` — larger for Override types; 0 for non-label features
   * - `ja_label` — the `angle` property string of the feature, or `''`
   *
   * @returns {Object} styleContext object suitable for `sdk.Map.addLayer({ styleContext })`.
   */
  function ja_build_style_context() {
    return {
      ja_fillColor: function (ctx) {
        var props = ctx.feature && ctx.feature.properties;
        if (!props || props.ja_type === 'arrow_line') return 'transparent';
        if (props.ja_type === 'roundaboutOverlay') return ja_getOption('roundaboutOverlayColor');
        return ja_fill_color_for_type(props.ja_type);
      },
      ja_fontColor: function (ctx) {
        var props = ctx.feature && ctx.feature.properties;
        if (!props || props.ja_type === 'arrow_line' || props.ja_type === 'roundaboutOverlay') return 'transparent';
        return ja_get_contrast_color(ja_fill_color_for_type(props.ja_type));
      },
      ja_strokeColor: function (ctx) {
        var props = ctx.feature && ctx.feature.properties;
        if (!props) return '#183800';
        if (props.ja_type === 'arrow_line') return '#ff9966';
        if (props.ja_type === 'roundaboutOverlay') return ja_getOption('roundaboutOverlayColor');
        if (props.ja_type && props.ja_type.indexOf('Override') === 0) return '#F68F23';
        return '#183800';
      },
      ja_strokeWidth: function (ctx) {
        var props = ctx.feature && ctx.feature.properties;
        if (!props) return 2;
        if (props.ja_type === 'arrow_line') return 1.2;
        if (props.ja_type && props.ja_type.indexOf('Override') === 0) return 5;
        return 2;
      },
      ja_strokeOpacity: function (ctx) {
        var props = ctx.feature && ctx.feature.properties;
        return props && props.ja_type === 'arrow_line' ? 0.6 : 1;
      },
      ja_fillOpacity: function (ctx) {
        var props = ctx.feature && ctx.feature.properties;
        if (!props) return 1;
        if (props.ja_type === 'arrow_line') return 0;
        if (props.ja_type === 'roundaboutOverlay') return 0.1;
        return 1;
      },
      ja_pointRadius: function (ctx) {
        var props = ctx.feature && ctx.feature.properties;
        var baseRadius = parseInt(ja_getOption('pointSize'), 10) + (parseInt(ja_getOption('decimals')) > 0 ? 4 * parseInt(ja_getOption('decimals')) : 0);
        if (!props || props.ja_type === 'arrow_line') return 0;
        // In Simple display mode the arrow sits on the same line as the number.
        // Detect this: label has no newline but contains a non-digit/non-degree char (the arrow).
        var labelStr = props.angle != null ? String(props.angle) : '';
        if (labelStr.indexOf('\n') === -1 && /[^\d°.\s]/.test(labelStr)) {
          baseRadius += 4;
        }
        if (props.ja_type && props.ja_type.indexOf('Override') === 0) return 2 + baseRadius;
        return 3 + baseRadius;
      },
      ja_fontSize: function (ctx) {
        var props = ctx.feature && ctx.feature.properties;
        if (!props || props.ja_type === 'arrow_line' || props.ja_type === 'roundaboutOverlay') return '0px';
        if (props.ja_type && props.ja_type.indexOf('Override') === 0) {
          return parseInt(ja_getOption('pointSize')) + (ja_getOption('overrideAngles') ? -1 : 8) + 'px';
        }
        return parseInt(ja_getOption('pointSize')) - 1 + 'px';
      },
      ja_label: function (ctx) {
        var props = ctx.feature && ctx.feature.properties;
        return props && props.angle != null ? String(props.angle) : '';
      },
      ja_graphicName: function (ctx) {
        var props = ctx.feature && ctx.feature.properties;
        // Shape determined by marker type and far-turn breadcrumb role:
        // - Regular turns: always circle
        // - Far-turn intermediate breadcrumbs: circle (ja_is_far_turn=true, ja_is_square_marker=false)
        // - Far-turn final exits: square (ja_is_far_turn=true, ja_is_square_marker=true)
        if (!props || props.ja_type === 'arrow_line' || props.ja_type === 'roundaboutOverlay') {
          return 'circle';
        }
        // If marked as square far-turn marker (final exit), use square; else circle
        return props.ja_is_square_marker ? 'square' : 'circle';
      },
    };
  }

  /**
   * Builds the SDK `styleRules` array for the `junction_angles` map layer.
   *
   * Returns a single catch-all rule that applies the dynamic style context expressions to
   * every feature on the layer.  All visual variation (color, size, label, opacity) is
   * driven by the `styleContext` functions rather than by multiple rules with filter
   * predicates — this keeps the rule list simple and avoids SDK predicate evaluation overhead
   * on every render cycle.
   *
   * @returns {Array<{style: Object}>} styleRules array for `sdk.Map.addLayer({ styleRules })`.
   */
  function ja_build_style_rules() {
    return [
      {
        style: {
          fillColor: '${ja_fillColor}',
          fillOpacity: '${ja_fillOpacity}',
          strokeColor: '${ja_strokeColor}',
          strokeWidth: '${ja_strokeWidth}',
          strokeOpacity: '${ja_strokeOpacity}',
          fontColor: '${ja_fontColor}',
          pointRadius: '${ja_pointRadius}',
          fontSize: '${ja_fontSize}',
          label: '${ja_label}',
          graphicName: '${ja_graphicName}',
          fontWeight: 'bold',
          labelOutlineWidth: 0,
        },
      },
    ];
  }

  /**
   * Returns the localised string for an I18n key in the `ja.*` namespace.
   *
   * If no translation is registered for the current locale, the raw `key` string is returned
   * unchanged.  This mirrors WME's own pattern for graceful degradation when a translation is
   * missing — the key itself is usually a readable English fallback.
   *
   * @param {string} key - I18n key within the `ja` namespace (e.g. `'settingsTitle'`).
   * @returns {string} Translated string, or `key` if no translation exists.
   */
  function ja_getMessage(key) {
    var tr = I18n.translate('ja.' + key),
      no_tr = I18n.missingTranslation('ja.' + key);
    return tr === no_tr ? key : tr;
  }

  /**
   * Registers all supported translations into the WME I18n system under the `ja` namespace.
   *
   * Inspects `I18n.locale` and calls the inner `set_trans(def)` helper to assign the
   * appropriate translation object.  The `default` case installs English, which also serves as
   * the fallback for any unrecognised locale.
   *
   * Called once during `junctionangle_init` and again whenever a `wme-user-settings-changed`
   * event fires (e.g. when the user switches WME's interface language at runtime).
   *
   * Supported locales: `cs`, `fi`, `pl`, `ru`, `sv`, `fr`, `es-419`, `uk`, and English default.
   */
  function ja_loadTranslations() {
    /**
     * Registers a translation definition object under `I18n.translations[locale].ja`.
     *
     * The assignment expression is intentional — it both stores the object and returns it
     * (the `jshint -W093` directive suppresses the "assignment in return" lint warning).
     *
     * @param {Object} def - Key/value map of i18n strings for the `ja` namespace.
     * @returns {Object} The same `def` object that was just stored.
     */
    var set_trans = function (def) {
      /*jshint -W093*/
      return (I18n.translations[I18n.locale].ja = def);
    };

    ja_log('Loading translations', 2);

    //Apply
    switch (I18n.locale) {
      default:
        //Default language (English)
        set_trans({
          name: 'Junction Angle Info',
          settingsTitle: 'Junction Angle Info settings',
          resetToDefault: 'Reset to default',
          aAbsolute: 'Absolute',
          aDeparture: 'Departure',
          angleMode: 'Angle mode',
          angleDisplay: 'Angle display style',
          angleDisplayArrows: 'Direction arrows',
          displayFancy: 'Fancy',
          displaySimple: 'Simple',
          override: 'Check "override instruction"',
          overrideAngles: 'Show angles of "override instruction"',
          guess: 'Estimate routing instructions',
          noInstructionColor: 'Best continuation',
          continueInstructionColor: 'Continue straight',
          keepInstructionColor: 'Keep',
          exitInstructionColor: 'Exit',
          turnInstructionColor: 'Turn',
          uTurnInstructionColor: 'U-turn',
          noTurnColor: 'Disallowed turn',
          problemColor: 'Angle to avoid',
          roundaboutColor: 'Non-Normal Exit',
          roundaboutOverlayColor: 'Overlay',
          roundaboutOverlayDisplay: 'Show roundabout',
          rOverNever: 'Never',
          rOverSelected: 'When selected',
          rOverAlways: 'Always',
          uTurnIncludeStreet: 'Include Street Type',
          uTurnIncludeParkingLot: 'Include Parking Lot roads',
          uTurnIncludePrivateRoad: 'Include Private roads',
          wazeDoubleUTurnRestriction: 'Disable for <15m and ±5° parallel',
          enableFarTurnJB: 'Enable JAI for Junction Boxes',
          enableFarTurnPath: 'Enable JAI for Paths',
          continuousScanning: 'Scan for Angles to Avoid',
          decimals: 'Number of decimals',
          pointSize: 'Base point size',
          settingsguide: 'Settings & User Guide',
          roundaboutnav: 'WIKI: Roundabouts',
          wazeAlgorithm: 'Waze Turn/Keep/Exit Algorithm',
          ghissues: 'JAI issue tracker',
        });
        break;

      //Czech (čeština)
      case 'cs':
        set_trans({
          name: 'Junction Angle Info',
          settingsTitle: 'Nastavení JAI',
          resetToDefault: 'Výchozí nastavení',
          aAbsolute: 'Absolutní',
          aDeparture: 'Odjezdový',
          angleMode: 'Styl zobrazení úhlů',
          angleDisplay: 'Styl výpisu úhlů',
          angleDisplayArrows: 'Směrové šipky',
          displayFancy: 'Zdobný',
          displaySimple: 'Jednoduchý',
          override: 'Zvýraznit vynucené hlasové pokyny',
          overrideAngles: 'Zobrazit úhly vynucených pokynů',
          guess: 'Odhadovat navigační hlášky',
          noInstructionColor: 'Bez hlášení',
          continueInstructionColor: 'Rovně',
          keepInstructionColor: 'Držte se',
          exitInstructionColor: 'Sjeďte',
          turnInstructionColor: 'Odbočte',
          uTurnInstructionColor: 'Otočte se',
          noTurnColor: 'Nepovolené směry',
          problemColor: 'Nejasné úhly',
          roundaboutColor: 'Rozbité kruháče',
          roundaboutOverlayColor: 'Kruháče',
          roundaboutOverlayDisplay: 'ukazovat kruháče',
          rOverNever: 'Ne-',
          rOverSelected: 'Při výběru',
          rOverAlways: 'Vždy',
          uTurnIncludeStreet: 'Zahrnout ulice',
          uTurnIncludeParkingLot: 'Zahrnout parkoviště',
          uTurnIncludePrivateRoad: 'Zahrnout soukromé cesty',
          wazeDoubleUTurnRestriction: 'Zakázat pro <15m a ±5° paralelně',
          enableFarTurnJB: 'Povolit JAI pro silniční křižovatky',
          enableFarTurnPath: 'Povolit JAI pro cesty',
          continuousScanning: 'Skenování úhlů k vyhnutí',
          decimals: 'Počet des. míst',
          pointSize: 'Velikost písma',
          settingsguide: 'Nastavení a Uživatelská příručka',
          roundaboutnav: 'US WIKI: Kruhové objezdy',
          wazeAlgorithm: 'Algoritmus Waze Turn/Keep/Exit',
          ghissues: 'Hlášení problémů JAI',
        });
        break;

      //Finnish (Suomen kieli)
      case 'fi':
        set_trans({
          name: 'Risteyskulmat',
          settingsTitle: 'Rysteyskulmien asetukset',
          resetToDefault: 'Palauta',
          aAbsolute: 'Absoluuttinen',
          aDeparture: 'Käännös',
          angleMode: 'Kulmien näyttö',
          angleDisplay: 'Näyttötyyli',
          angleDisplayArrows: 'Suuntanuolet',
          displayFancy: 'Nätti',
          displaySimple: 'Yksinkertainen',
          override: 'Check "override instruction"',
          overrideAngles: 'Show angles of "override instruction"',
          guess: 'Arvioi reititysohjeet',
          noInstructionColor: 'ohjeeton "Suora"-väri',
          continueInstructionColor: 'Continue straight',
          keepInstructionColor: '"Pysy vasemmalla/oikealla"-ohjeen väri',
          exitInstructionColor: '"Poistu"-ohjeen väri',
          turnInstructionColor: '"Käänny"-ohjeen väri',
          uTurnInstructionColor: '"Käänny ympäri"-ohjeen väri',
          noTurnColor: 'Kielletyn käännöksen väri',
          problemColor: 'Vältettävien kulmien väri',
          roundaboutColor: 'Liikenneympyrän (jolla ei-suoria kulmia) ohjeen väri',
          roundaboutOverlayColor: 'Liikenneympyrän korostusväri',
          roundaboutOverlayDisplay: 'Korosta liikenneympyrä',
          rOverNever: 'Ei ikinä',
          rOverSelected: 'Kun valittu',
          rOverAlways: 'Aina',
          uTurnIncludeStreet: 'Sisällytä kadut',
          uTurnIncludeParkingLot: 'Sisällytä parkkialueen tiet',
          uTurnIncludePrivateRoad: 'Sisällytä yksityistiet',
          wazeDoubleUTurnRestriction: 'Poista käytöstä <15m ja ±5° rinnakkaisille',
          enableFarTurnJB: 'Ota JAI käyttöön risteyksissä',
          enableFarTurnPath: 'Ota JAI käyttöön poluilla',
          continuousScanning: 'Skannaa välttämisen kulmia',
          decimals: 'Desimaalien määrä',
          pointSize: 'Ympyrän peruskoko',
          wazeAlgorithm: 'Waze Turn/Keep/Exit Algoritmi',
        });
        break;

      //Polish (język polski)
      case 'pl':
        set_trans({
          settingsTitle: 'Ustawienia',
          resetToDefault: 'Przywróć domyślne',
          aAbsolute: 'Absolutne',
          aDeparture: 'Rozjazdy',
          angleMode: 'Tryb wyświetlania kątów',
          angleDisplay: 'Styl kierunków',
          displayFancy: 'Dwuliniowy',
          displaySimple: 'Prosty',
          angleDisplayArrows: 'Strzałki kierunków',
          override: 'Check "override instruction"',
          overrideAngles: 'Show angles of "override instruction"',
          guess: 'Szacuj komunikaty trasy',
          noInstructionColor: 'Kolor najlepszej kontynuacji',
          continueInstructionColor: 'Continue straight',
          keepInstructionColor: 'Kolor dla "kieruj się"',
          exitInstructionColor: 'Kolor dla "zjedź"',
          turnInstructionColor: 'Kolor dla "skręć"',
          uTurnInstructionColor: 'Kolor dla "zawróć"',
          noTurnColor: 'Kolor niedozwolonych manewrów',
          problemColor: 'Kolor problematycznych kątów',
          roundaboutColor: 'Kolor rond niestandardowych',
          roundaboutOverlayColor: 'Kolor znacznika rond',
          roundaboutOverlayDisplay: 'Pokazuj ronda',
          rOverNever: 'Nigdy',
          rOverSelected: 'Gdy zaznaczone',
          rOverAlways: 'Zawsze',
          uTurnIncludeStreet: 'Uwzględnij ulice',
          uTurnIncludeParkingLot: 'Uwzględnij drogi parkingowe',
          uTurnIncludePrivateRoad: 'Uwzględnij drogi prywatne',
          wazeDoubleUTurnRestriction: 'Wyłącz dla <15m i ±5° równoległy',
          enableFarTurnJB: 'Włącz JAI dla skrzyżowań',
          enableFarTurnPath: 'Włącz JAI dla ścieżek',
          continuousScanning: 'Skanuj kąty do uniknięcia',
          decimals: 'Ilość cyfr po przecinku',
          pointSize: 'Rozmiar punktów pomiaru',
          wazeAlgorithm: 'Algorytm Waze Turn/Keep/Exit',
        });
        break;

      //Portuguese (português)
      case 'pt':
        set_trans({
          name: 'Informações de Ângulos de Junção',
          settingsTitle: 'Definições de Informações de Ângulos de Junção',
          resetToDefault: 'Repor predefinições',
          aAbsolute: 'Absoluto',
          aDeparture: 'Partida',
          angleMode: 'Modo de ângulo',
          angleDisplay: 'Estilo de visualização de ângulos',
          angleDisplayArrows: 'Setas de direção',
          displayFancy: 'Elegante',
          displaySimple: 'Simples',
          override: 'Verificar "instrução de substituição"',
          overrideAngles: 'Mostrar ângulos de "instrução de substituição"',
          guess: 'Estimar instruções de encaminhamento',
          noInstructionColor: 'Melhor continuação',
          continueInstructionColor: 'Continuar reto',
          keepInstructionColor: 'Manter',
          exitInstructionColor: 'Sair',
          turnInstructionColor: 'Virar',
          uTurnInstructionColor: 'Inversão de marcha',
          noTurnColor: 'Viragem desautorizada',
          problemColor: 'Ângulo a evitar',
          roundaboutColor: 'Saída não-normal',
          roundaboutOverlayColor: 'Sobreposição',
          roundaboutOverlayDisplay: 'Mostrar rotunda',
          rOverNever: 'Nunca',
          rOverSelected: 'Quando selecionado',
          rOverAlways: 'Sempre',
          uTurnIncludeStreet: 'Incluir ruas',
          uTurnIncludeParkingLot: 'Incluir estacionamentos',
          uTurnIncludePrivateRoad: 'Incluir estradas privadas',
          wazeDoubleUTurnRestriction: 'Desativar para <15m e ±5° paralelo',
          enableFarTurnJB: 'Ativar JAI para caixas de junção',
          enableFarTurnPath: 'Ativar JAI para caminhos',
          continuousScanning: 'Procurar ângulos para evitar',
          decimals: 'Número de casas decimais',
          pointSize: 'Tamanho base do ponto',
          settingsguide: 'Definições e guia do utilizador',
          roundaboutnav: 'WIKI: Rotundas',
          wazeAlgorithm: 'Algoritmo Waze Turn/Keep/Exit',
          ghissues: 'Rastreador de problemas JAI',
        });
        break;

      //Russian (русский)
      case 'ru':
        set_trans({
          name: 'Углы поворотов',
          settingsTitle: 'Настройки Junction Angle Info',
          resetToDefault: 'Сбросить настройки',
          aAbsolute: 'Абсолютные',
          aDeparture: 'Повороты',
          angleMode: '- режим углов',
          angleDisplay: '- стиль отображения',
          angleDisplayArrows: '- стрелки направлений',
          displayFancy: 'Модный',
          displaySimple: 'Простой',
          override: 'Визуализировать изменённые подсказки',
          overrideAngles: 'Показывать углы изменённых подсказок',
          guess: 'Ожидаемые подсказки',
          noInstructionColor: 'Нет подсказки',
          continueInstructionColor: 'Прямо',
          keepInstructionColor: 'Держитесь',
          exitInstructionColor: 'Съезд',
          turnInstructionColor: 'Поверните',
          uTurnInstructionColor: 'Развернитесь',
          noTurnColor: 'Запрещённый манёвр',
          problemColor: 'Избегать',
          roundaboutColor: 'Некорректный выезд',
          roundaboutOverlayColor: 'Кольцо',
          roundaboutOverlayDisplay: '- показ колец',
          rOverNever: 'Никогда',
          rOverSelected: 'Если выбрано',
          rOverAlways: 'Всегда',
          uTurnIncludeStreet: '- включить улицы',
          uTurnIncludeParkingLot: '- включить парковки',
          uTurnIncludePrivateRoad: '- включить частные дороги',
          wazeDoubleUTurnRestriction: 'Отключить для <16м и ±5° параллель',
          enableFarTurnJB: 'Включить JAI для перекрёстков',
          enableFarTurnPath: 'Включить JAI для путей',
          continuousScanning: 'Сканировать углы для избежания',
          decimals: '- знаков после запятой',
          pointSize: '- размер кружка',
          settingsguide: 'Настройки и руководство пользователя',
          roundaboutnav: 'Вики: круговые перекрестки',
          wazeAlgorithm: 'Алгоритм Waze Turn/Keep/Exit',
          ghissues: 'Сообщить об ошибке',
        });
        break;

      //Swedish (svenska)
      case 'sv':
        set_trans({
          name: 'Korsningsvinklar',
          settingsTitle: 'Inställningar för korsningsvinklar',
          resetToDefault: 'Återställ',
          aAbsolute: 'Absolut',
          aDeparture: 'Sväng',
          angleMode: 'Vinkelvisning',
          angleDisplay: 'Vinkelstil',
          angleDisplayArrows: 'Riktningspilar',
          displayFancy: 'Grafisk',
          displaySimple: 'Simpel',
          override: 'Check "override instruction"',
          overrideAngles: 'Show angles of "override instruction"',
          guess: 'Gissa navigeringsinstruktioner',
          noInstructionColor: 'Färg för "ingen instruktion"',
          continueInstructionColor: 'Continue straight',
          keepInstructionColor: 'Färg för "håll höger/vänster"-instruktion',
          exitInstructionColor: 'Färg för "ta av"-instruktion',
          turnInstructionColor: 'Färg för "sväng"-instruktion',
          uTurnInstructionColor: 'Färg för "U-sväng"-instruktion',
          noTurnColor: 'Färg förbjuden sväng',
          problemColor: 'Färg för vinklar att undvika',
          roundaboutColor: 'Färg för rondell (med icke-räta vinklar)',
          roundaboutOverlayColor: 'Färg för rondellcirkel',
          roundaboutOverlayDisplay: 'Visa cirkel på rondell',
          rOverNever: 'Aldrig',
          rOverSelected: 'När vald',
          rOverAlways: 'Alltid',
          uTurnIncludeStreet: 'Inkludera gator',
          uTurnIncludeParkingLot: 'Inkludera parkeringsvägar',
          uTurnIncludePrivateRoad: 'Inkludera enskilda vägar',
          wazeDoubleUTurnRestriction: 'Inaktivera för <15m och ±5° parallell',
          enableFarTurnJB: 'Aktivera JAI för korsningar',
          enableFarTurnPath: 'Aktivera JAI för vägar',
          continuousScanning: 'Skanna vinklar att undvika',
          decimals: 'Decimaler',
          pointSize: 'Cirkelns basstorlek',
          wazeAlgorithm: 'Waze Turn/Keep/Exit Algoritm',
        });
        break;

      //French (Francais)
      case 'fr':
        set_trans({
          name: 'Junction Angle Info',
          settingsTitle: 'Paramètres de Junction Angle Info',
          angleMode: 'Mode Angle',
          aAbsolute: 'Absolu',
          aDeparture: 'Départ',
          angleDisplay: "Style d'affichage d'Angles",
          angleDisplayArrows: 'Flèches de Direction',
          displayFancy: 'Fancy',
          displaySimple: 'Simple',
          override: 'Contrôler les "overrides instruction"',
          overrideAngles: 'Afficher les angles des "overrides" actives',
          guess: 'Estimer les instructions routage',
          noInstructionColor: 'Sans instruction',
          continueInstructionColor: 'Tout droit',
          keepInstructionColor: 'Serrez',
          exitInstructionColor: 'Sortez',
          turnInstructionColor: 'Tournez',
          uTurnInstructionColor: 'Demi-tour',
          noTurnColor: 'Virage interdit',
          problemColor: 'Angle à éviter',
          roundaboutOverlayDisplay: 'Surligner les rond-point',
          rOverNever: 'Jamais',
          rOverSelected: 'Sélectionné',
          rOverAlways: 'Toujours',
          roundaboutOverlayColor: 'Surlignage',
          roundaboutColor: 'Sortie anormale',
          uTurnIncludeStreet: 'Inclure les rues',
          uTurnIncludeParkingLot: 'Inclure les voies de parking',
          uTurnIncludePrivateRoad: 'Inclure les voies privées',
          wazeDoubleUTurnRestriction: 'Désactiver pour <15m et ±5° parallèle',
          enableFarTurnJB: 'Activer JAI pour les carrefours',
          enableFarTurnPath: 'Activer JAI pour les chemins',
          continuousScanning: 'Analyser les angles à éviter',
          decimals: 'Nombre de decimales',
          pointSize: 'Taille des bulles',
          wazeAlgorithm: 'Algorithme Waze Turn/Keep/Exit',
          resetToDefault: 'Réinitialiser par défaut',
          settingsguide: 'Paramètres et Guide utilisateur',
          roundaboutnav: 'WIKI: Rond-point (en)',
          ghissues: 'JAI Reporter un problème',
        });
        break;
      //Latin-American Spanish (español latinoamericano)
      case 'es-419':
        set_trans({
          name: 'Información en Ángulos de Intersección (JAI)',
          settingsTitle: 'Configuración de Información en Ángulos',
          resetToDefault: 'Limpiar configuración',
          aAbsolute: 'Absoluto',
          aDeparture: 'Salida',
          angleMode: 'Modo de ángulos',
          angleDisplay: 'Estilo para mostrar',
          angleDisplayArrows: 'Flechas de dirección',
          displayFancy: 'Lujoso',
          displaySimple: 'Simple',
          override: 'Revisar "instrucciones forzadas"',
          overrideAngles: 'Ver ángulos en "instrucciones forzadas"',
          guess: 'Estimar instrucciones de giro',
          noInstructionColor: 'Sin instrucción',
          continueInstructionColor: '"Sigue derecho"',
          keepInstructionColor: '"Mantente"',
          exitInstructionColor: '"Sale"',
          turnInstructionColor: '"Gira"',
          uTurnInstructionColor: '"Gira en U"',
          noTurnColor: 'Giros deshabilitados',
          problemColor: 'Ángulos a evitar',
          roundaboutColor: 'Rotondas anormales',
          roundaboutOverlayColor: 'Rotondas',
          roundaboutOverlayDisplay: 'Mostrar rotondas',
          rOverNever: 'Nunca',
          rOverSelected: 'Seleccionadas',
          rOverAlways: 'Siempre',
          uTurnIncludeStreet: 'Incluir calles',
          uTurnIncludeParkingLot: 'Incluir vías de estacionamiento',
          uTurnIncludePrivateRoad: 'Incluir caminos privados',
          wazeDoubleUTurnRestriction: 'Desactivar para <15m y ±5° paralelo',
          enableFarTurnJB: 'Habilitar JAI para intersecciones',
          enableFarTurnPath: 'Habilitar JAI para caminos',
          continuousScanning: 'Escanear ángulos para evitar',
          decimals: 'Decimales',
          pointSize: 'Tamaño del texto',
          wazeAlgorithm: 'Algoritmo Waze Turn/Keep/Exit',
          settingsguide: 'Configuración y Guía del usuario',
          roundaboutnav: 'WIKI: Rotondas',
          ghissues: 'Seguimiento de problemas',
        });
        break;
      //Ukrainian (український)
      case 'uk':
        set_trans({
          name: 'Junction Angle Info',
          settingsTitle: 'Налаштування Junction Angle Info',
          resetToDefault: 'Скинути налаштування',
          aAbsolute: 'Абсолютні',
          aDeparture: 'Повороти',
          angleMode: '- режим кутів',
          angleDisplay: '- стиль відображення',
          angleDisplayArrows: '- стрілки напрямків',
          displayFancy: 'Модний',
          displaySimple: 'Простий',
          override: 'Візуалізувати "змінені підказки"',
          overrideAngles: 'Показувати кути для "змінених підказок"',
          guess: 'Очікувані підказки:',
          noInstructionColor: 'Немає підказки',
          continueInstructionColor: 'Прямо',
          keepInstructionColor: 'Тримайтеся',
          exitInstructionColor: "З'їзд",
          turnInstructionColor: 'Поверніть',
          uTurnInstructionColor: 'Розверніться',
          noTurnColor: 'Заборонений маневр',
          problemColor: 'Уникати',
          roundaboutColor: 'Некоректний виїзд',
          roundaboutOverlayColor: 'Кільце',
          roundaboutOverlayDisplay: '- показ кілець',
          rOverNever: 'Ніколи',
          rOverSelected: 'Якщо вибрано',
          rOverAlways: 'Завжди',
          uTurnIncludeStreet: '- включати вулиці',
          uTurnIncludeParkingLot: '- включати парковки',
          uTurnIncludePrivateRoad: '- включати приватні дороги',
          wazeDoubleUTurnRestriction: 'Вимкнути для <15м і ±5° паралельно',
          enableFarTurnJB: 'Увімкнути JAI для перехресть',
          enableFarTurnPath: 'Увімкнути JAI для шляхів',
          continuousScanning: 'Сканування кутів для уникнення',
          decimals: '- знаків після коми',
          pointSize: '- розмір шрифту',
          wazeAlgorithm: 'Алгоритм Waze Turn/Keep/Exit',
          settingsguide: 'Налаштування та посібник користувача',
          roundaboutnav: 'WIKI: кругові перехрестя(en)',
          ghissues: 'JAI - Повідомити про помилку',
        });
        break;
    }
  }

  /*
   * Bootstrapping and logging
   */

  /**
   * Displays the WazeWrap "script updated" notification banner when the script version changes.
   *
   * Compares `SCRIPT_VERSION` against the `lastVersion` value stored in `ja_options`.  If they
   * differ (and `SHOW_UPDATE_MESSAGE` is true) it calls `WazeWrap.Interface.ShowScriptUpdate`
   * with the current release notes built from `SCRIPT_VERSION_CHANGES`, then updates
   * `ja_options['lastVersion']` and persists it to `localStorage` so the banner is not shown
   * again on the next page load.
   *
   * Note: `lastVersion` is stored directly in `ja_options` and must never be accessed via
   * `ja_getOption`/`ja_setOption` because it is not declared in the `ja_settings` schema.
   */
  function showScriptInfoAlert() {
    /* Check version and alert on update */
    if (SHOW_UPDATE_MESSAGE && SCRIPT_VERSION !== ja_options['lastVersion']) {
      let releaseNotes = '';
      releaseNotes += "<p>What's New:</p>";
      if (SCRIPT_VERSION_CHANGES.length > 0) {
        releaseNotes += '<ul>';
        for (let idx = 0; idx < SCRIPT_VERSION_CHANGES.length; idx++) releaseNotes += `<li>${SCRIPT_VERSION_CHANGES[idx]}`;
        releaseNotes += '</ul>';
      } else {
        releaseNotes += '<ul><li>Nothing major.</ul>';
      }
      WazeWrap.Interface.ShowScriptUpdate(GM_info.script.name, SCRIPT_VERSION, releaseNotes, DOWNLOAD_URL);
      ja_options['lastVersion'] = SCRIPT_VERSION;
      if (localStorage) {
        localStorage.setItem('wme_ja_options', JSON.stringify(ja_options));
      }
    }
  }

  /**
   * Conditional console logger gated by `junctionangle_debug`.
   *
   * Messages are only printed when `ja_log_level <= junctionangle_debug`, so verbosity can be
   * tuned without touching call sites:
   * - Level 0 — silent
   * - Level 1 — important warnings / start-up messages (default release setting)
   * - Level 2 — setting changes, calculation entry/exit
   * - Level 3 — per-segment / per-node detail
   * - Level 4 — insane (angle math step-by-step)
   *
   * Object arguments are passed directly to `console.log` (no string coercion) so the browser
   * DevTools inspector can expand them interactively.
   *
   * @param {*} ja_log_msg - Message string or object to log.
   * @param {number} [ja_log_level=1] - Minimum debug level required to print this message.
   */
  function ja_log(ja_log_msg, ja_log_level) {
    if (typeof ja_log_level === 'undefined') {
      ja_log_level = 1;
    }
    if (ja_log_level <= junctionangle_debug) {
      if (typeof ja_log_msg === 'object') {
        console.log(ja_log_msg);
      } else {
        console.log('WME JAI: ' + ja_log_msg);
      }
    }
  }

  sdk = await bootstrap(
    /** @type {BootstrapArgs} */ ({
      scriptUpdateMonitor: { downloadUrl: DOWNLOAD_URL },
    }),
  );

  /**
   * Renders (or re-renders) the entire JAI settings panel inside the SDK sidebar tab pane.
   *
   * Clears `jaTabPane` and rebuilds its contents from scratch — safe to call more than once
   * (e.g. on `wme-user-settings-changed`) to pick up a new locale without stale DOM nodes.
   *
   * Structure: scoped `<style>`, a script header, four setting cards (Display, Routing
   * instructions, Instruction colors, Roundabout), and a footer with a Reset button and links.
   * CSS is scoped to `.wme-ja-panel` (added to `jaTabPane` by `junctionangle_init`).
   * Checkboxes use custom toggle-switch markup; all element IDs are unchanged from `ja_settings`
   * so `ja_save`, `ja_apply`, and `ja_onchange` work without modification.
   *
   * @param {HTMLElement} jaTabPane - The SDK sidebar tab pane element returned by
   *   `sdk.Sidebar.registerScriptTab()`.
   */
  function setupHtml(jaTabPane) {
    jaTabPane.innerHTML = '';
    ja_log('---------- Creating settings HTML ----------', 2);

    // ── CSS (scoped to .wme-ja-panel) ─────────────────────────────────
    var style = document.createElement('style');
    style.textContent = [
      '.wme-ja-panel { padding: 8px; box-sizing: border-box; }',
      '.wme-ja-panel .ja-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; padding: 8px 10px; background: linear-gradient(135deg, #0066cc, #0052a3); color: #fff; border-radius: 8px; }',
      '.wme-ja-panel .ja-header-left { display: flex; align-items: center; gap: 6px; }',
      '.wme-ja-panel .ja-header-icon { color: #fff; font-size: 1.2em; }',
      '.wme-ja-panel .ja-header-name { font-weight: 700; font-size: 13px; color: #fff; }',
      '.wme-ja-panel .ja-header-version { font-size: 10px; opacity: 0.8; color: #fff; }',
      '.wme-ja-panel .ja-card { border: 1px solid var(--hairline, #ddd); border-radius: 8px; margin-bottom: 8px; overflow: hidden; }',
      '.wme-ja-panel .ja-card-header { display: flex; align-items: center; gap: 7px; padding: 7px 10px; font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.03em; border-bottom: 1px solid var(--hairline, #ddd); background: linear-gradient(135deg, #f8f9fa, #f0f1f3); color: #333; }',
      '.wme-ja-panel .ja-card-header:hover { background: linear-gradient(135deg, #f0f1f3, #e8eaed); }',
      '.wme-ja-panel .ja-card-header i { color: #0066cc; font-size: 11px; width: 14px; text-align: center; }',
      '.wme-ja-panel .ja-card-body { padding: 2px 0; }',
      '.wme-ja-panel .ja-row { display: flex; justify-content: space-between; align-items: center; padding: 5px 10px; min-height: 32px; box-sizing: border-box; }',
      '.wme-ja-panel .ja-sub-row { padding-left: 22px; }',
      '.wme-ja-panel .ja-sub-sub-row { padding-left: 34px; }',
      '.wme-ja-panel .ja-row-label { flex: 1; font-size: 12px; padding-right: 8px; line-height: 1.3; }',
      '.wme-ja-panel .ja-row.disabled { opacity: 0.4; pointer-events: none; }',
      '.wme-ja-panel select { font-size: 12px; border: 1px solid var(--hairline, #ccc); border-radius: 4px; padding: 3px 5px; width: 130px; max-width: 130px; box-sizing: border-box; background: var(--background_default, #fff); color: var(--content_default, #333); }',
      '.wme-ja-panel input[type="number"] { font-size: 12px; border: 1px solid var(--hairline, #ccc); border-radius: 4px; padding: 3px 5px; width: 52px; text-align: right; box-sizing: border-box; background: var(--background_default, #fff); color: var(--content_default, #333); }',
      '.wme-ja-panel input[type="color"] { width: 30px; height: 22px; padding: 1px 2px; border: 1px solid var(--hairline, #ccc); border-radius: 3px; cursor: pointer; flex-shrink: 0; }',
      '@supports (-webkit-appearance:none) { .wme-ja-panel input[type="color"] { padding: 0 2px; } }',
      '.wme-ja-panel .ja-toggle { position: relative; display: inline-block; width: 34px; height: 18px; flex-shrink: 0; }',
      '.wme-ja-panel .ja-toggle input { opacity: 0; width: 0; height: 0; position: absolute; }',
      '.wme-ja-panel .ja-toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; border-radius: 18px; transition: background-color 0.2s; }',
      '.wme-ja-panel .ja-toggle-slider:before { position: absolute; content: ""; height: 12px; width: 12px; left: 3px; bottom: 3px; background-color: white; border-radius: 50%; transition: transform 0.2s; }',
      '.wme-ja-panel .ja-toggle input:checked + .ja-toggle-slider { background-color: #00bd00; }',
      '.wme-ja-panel .ja-toggle input:checked + .ja-toggle-slider:before { transform: translateX(16px); }',
      '.wme-ja-panel .ja-colors-grid { display: grid; grid-template-columns: 1fr 1fr; }',
      '.wme-ja-panel .ja-colors-grid .ja-row { padding: 4px 8px; }',
      '.wme-ja-panel .ja-colors-grid .ja-row-label { font-size: 11px; }',
      '.wme-ja-panel .ja-footer { margin-top: 4px; }',
      '.wme-ja-panel .ja-footer .btn { width: 100%; margin-bottom: 6px; font-size: 12px; }',
      '.wme-ja-panel .ja-footer ul { margin: 0; padding: 0; font-size: 11px; }',
      '.wme-ja-panel .ja-footer ul li { margin-bottom: 2px; }',
      '.wme-ja-panel .ja-footer ul li a { opacity: 0.7; }',
      '.wme-ja-panel .ja-footer ul li a:hover { opacity: 1; }',
      '[wz-theme="dark"] .wme-ja-panel .ja-header { background: linear-gradient(135deg, #0052a3, #003d7a); }',
      '[wz-theme="dark"] .wme-ja-panel .ja-card-header { background: linear-gradient(135deg, #2a2c30, #202124); color: #e8eaed; }',
      '[wz-theme="dark"] .wme-ja-panel .ja-card-header:hover { background: linear-gradient(135deg, #333538, #2a2c30); }',
      '[wz-theme="dark"] .wme-ja-panel .ja-card-header i { color: #33ccff; }',
    ].join('\n');
    jaTabPane.appendChild(style);

    // ── Inner helpers ─────────────────────────────────────────────────

    /** Creates a card div with an icon header and returns { card, body }. */
    function makeCard(iconClass, title) {
      var card = document.createElement('div');
      card.className = 'ja-card';
      var cardHeader = document.createElement('div');
      cardHeader.className = 'ja-card-header';
      var icon = document.createElement('i');
      icon.className = 'fa ' + iconClass;
      var titleSpan = document.createElement('span');
      titleSpan.textContent = title;
      cardHeader.appendChild(icon);
      cardHeader.appendChild(titleSpan);
      card.appendChild(cardHeader);
      var body = document.createElement('div');
      body.className = 'ja-card-body';
      card.appendChild(body);
      return { card: card, body: body };
    }

    /** Creates a flex row with a label and a trailing control element. */
    function makeRow(labelText, control, extraClass) {
      var row = document.createElement('div');
      row.className = 'ja-row' + (extraClass ? ' ' + extraClass : '');
      var labelEl = document.createElement('span');
      labelEl.className = 'ja-row-label';
      labelEl.textContent = labelText;
      row.appendChild(labelEl);
      row.appendChild(control);
      return row;
    }

    /** Creates a <select> wired to ja_onchange for the given settings key. */
    function makeSelect(settingKey) {
      var setting = ja_settings[settingKey];
      var select = document.createElement('select');
      select.id = setting.elementId;
      for (var i = 0; i < setting.options.length; i++) {
        var opt = document.createElement('option');
        opt.value = setting.options[i];
        opt.textContent = ja_getMessage(setting.options[i]);
        select.appendChild(opt);
      }
      select.onchange = function () {
        ja_onchange(this);
      };
      return select;
    }

    /** Creates a toggle-switch <label> wrapping a checkbox for the given settings key. */
    function makeToggle(settingKey) {
      var setting = ja_settings[settingKey];
      var toggleLabel = document.createElement('label');
      toggleLabel.className = 'ja-toggle';
      var input = document.createElement('input');
      input.type = 'checkbox';
      input.id = setting.elementId;
      input.onchange = function () {
        ja_onchange(this);
      };
      var slider = document.createElement('span');
      slider.className = 'ja-toggle-slider';
      toggleLabel.appendChild(input);
      toggleLabel.appendChild(slider);
      return toggleLabel;
    }

    /** Creates a number <input> wired to ja_onchange for the given settings key. */
    function makeNumber(settingKey) {
      var setting = ja_settings[settingKey];
      var input = document.createElement('input');
      input.type = 'number';
      input.id = setting.elementId;
      input.min = setting.min;
      input.max = setting.max;
      input.onchange = function () {
        ja_onchange(this);
      };
      return input;
    }

    /** Creates a color <input> wired to ja_onchange for the given settings key. */
    function makeColor(settingKey) {
      var setting = ja_settings[settingKey];
      var input = document.createElement('input');
      input.type = 'color';
      input.id = setting.elementId;
      input.onchange = function () {
        ja_onchange(this);
      };
      return input;
    }

    // ── Script header ──────────────────────────────────────────────────
    var header = document.createElement('div');
    header.className = 'ja-header';
    var headerLeft = document.createElement('div');
    headerLeft.className = 'ja-header-left';
    var headerIcon = document.createElement('i');
    headerIcon.className = 'fa fa-code-fork ja-header-icon';
    var headerName = document.createElement('span');
    headerName.className = 'ja-header-name';
    headerName.textContent = ja_getMessage('name');
    headerLeft.appendChild(headerIcon);
    headerLeft.appendChild(headerName);
    var headerVersion = document.createElement('span');
    headerVersion.className = 'ja-header-version';
    headerVersion.textContent = 'v' + SCRIPT_VERSION;
    header.appendChild(headerLeft);
    header.appendChild(headerVersion);
    jaTabPane.appendChild(header);

    // ── Display card ───────────────────────────────────────────────────
    var displayCard = makeCard('fa-cog', 'Display');
    displayCard.body.appendChild(makeRow(ja_getMessage('angleMode'), makeSelect('angleMode')));
    displayCard.body.appendChild(makeRow(ja_getMessage('angleDisplay'), makeSelect('angleDisplay')));
    displayCard.body.appendChild(makeRow(ja_getMessage('angleDisplayArrows'), makeSelect('angleDisplayArrows')));
    displayCard.body.appendChild(makeRow(ja_getMessage('decimals'), makeNumber('decimals')));
    displayCard.body.appendChild(makeRow(ja_getMessage('pointSize'), makeNumber('pointSize')));
    jaTabPane.appendChild(displayCard.card);

    // ── Routing instructions card ──────────────────────────────────────
    var routingCard = makeCard('fa-road', 'Routing instructions');
    routingCard.body.appendChild(makeRow(ja_getMessage('guess'), makeToggle('guess')));
    routingCard.body.appendChild(makeRow(ja_getMessage('override'), makeToggle('override'), 'ja-sub-row'));
    routingCard.body.appendChild(makeRow(ja_getMessage('overrideAngles'), makeToggle('overrideAngles'), 'ja-sub-row ja-sub-sub-row'));
    jaTabPane.appendChild(routingCard.card);

    // ── Instruction colors card ────────────────────────────────────────
    var colorsCard = makeCard('fa-paint-brush', 'Instruction colors');
    colorsCard.body.className += ' ja-colors-grid';
    ['noInstructionColor', 'continueInstructionColor', 'keepInstructionColor', 'exitInstructionColor', 'turnInstructionColor', 'uTurnInstructionColor', 'noTurnColor', 'problemColor'].forEach(
      function (key) {
        colorsCard.body.appendChild(makeRow(ja_getMessage(key), makeColor(key)));
      },
    );
    jaTabPane.appendChild(colorsCard.card);

    // ── Roundabout card ────────────────────────────────────────────────
    var roundaboutCard = makeCard('fa-circle-o', 'Roundabout');
    roundaboutCard.body.appendChild(makeRow(ja_getMessage('roundaboutOverlayDisplay'), makeSelect('roundaboutOverlayDisplay')));
    roundaboutCard.body.appendChild(makeRow(ja_getMessage('roundaboutOverlayColor'), makeColor('roundaboutOverlayColor'), 'ja-sub-row'));
    roundaboutCard.body.appendChild(makeRow(ja_getMessage('roundaboutColor'), makeColor('roundaboutColor'), 'ja-sub-row'));
    jaTabPane.appendChild(roundaboutCard.card);

    // ── U-Turn detection card ──────────────────────────────────────────
    var uturnsCard = makeCard('fa-undo', 'U-Turn detection road types');
    uturnsCard.body.appendChild(makeRow(ja_getMessage('uTurnIncludeStreet'), makeToggle('uTurnIncludeStreet')));
    uturnsCard.body.appendChild(makeRow(ja_getMessage('uTurnIncludeParkingLot'), makeToggle('uTurnIncludeParkingLot')));
    uturnsCard.body.appendChild(makeRow(ja_getMessage('uTurnIncludePrivateRoad'), makeToggle('uTurnIncludePrivateRoad')));
    uturnsCard.body.appendChild(makeRow(ja_getMessage('wazeDoubleUTurnRestriction'), makeToggle('wazeDoubleUTurnRestriction')));
    jaTabPane.appendChild(uturnsCard.card);

    // ── Experimental card ──────────────────────────────────────────────
    var experimentalCard = makeCard('fa-flask', 'Experimental');
    experimentalCard.body.appendChild(makeRow(ja_getMessage('enableFarTurnJB'), makeToggle('enableFarTurnJB')));
    experimentalCard.body.appendChild(makeRow(ja_getMessage('enableFarTurnPath'), makeToggle('enableFarTurnPath')));
    experimentalCard.body.appendChild(makeRow(ja_getMessage('continuousScanning'), makeToggle('continuousScanning')));
    jaTabPane.appendChild(experimentalCard.card);

    // ── Footer: reset button + info links ──────────────────────────────
    var footer = document.createElement('div');
    footer.className = 'ja-footer';

    var resetBtn = document.createElement('button');
    resetBtn.type = 'button';
    resetBtn.className = 'btn btn-default';
    resetBtn.addEventListener('click', ja_reset, true);
    resetBtn.textContent = ja_getMessage('resetToDefault');
    footer.appendChild(resetBtn);

    var infoList = document.createElement('ul');
    infoList.className = 'list-unstyled';
    infoList.appendChild(ja_helpLink('https://github.com/WazeDev/WME-JAI/blob/development/USER-SETTINGS.md', 'settingsguide'));
    infoList.appendChild(ja_helpLink('https://www.waze.com/discuss/t/roundabout/377970', 'roundaboutnav'));
    infoList.appendChild(ja_helpLink('https://www.waze.com/discuss/t/how-waze-determines-turn-keep-exit-maneuvers/378038', 'wazeAlgorithm'));
    infoList.appendChild(ja_helpLink('https://www.waze.com/discuss/t/script-wme-junction-angle-info/52238', 'ghissues'));
    footer.appendChild(infoList);

    jaTabPane.appendChild(footer);
  }

  /**
   * Entry point called once after the WME SDK bootstrap resolves.
   *
   * Responsibilities (in order):
   * 1. **Event registration** — wires SDK events for selection changes, segment/node data model
   *    changes, map zoom/move, and user settings changes to their respective handlers.
   * 2. **Settings & translations** — calls `ja_load()` to restore saved options from
   *    `localStorage`, `ja_loadTranslations()` to install the i18n strings, and
   *    `showScriptInfoAlert()` to display a WazeWrap update banner if the version changed.
   * 3. **Map layer** — creates the `junction_angles` SDK layer with the style context and rules
   *    built by `ja_build_style_context()` / `ja_build_style_rules()`, then sets
   *    `ja_layer_created = true` so `ja_apply()` can proceed.
   * 4. **Sidebar** — registers the script tab with the SDK, injects the power button into the
   *    tab label, sets up `setupHtml()`, and binds `ja_setLayerEnabled()` to both the Map
   *    Layers checkbox event and the power button click.
   * 5. **Initial render** — calls `ja_apply()` and `ja_calculate()` to populate controls and
   *    draw angle markers for whatever is currently selected in WME.
   */
  async function junctionangle_init() {
    // ── Event registration ────────────────────────────────────────────
    sdk.Events.on({ eventName: 'wme-selection-changed', eventHandler: testSelectedItem });

    sdk.Events.trackDataModelEvents({ dataModelName: 'segments' });
    sdk.Events.on({
      eventName: 'wme-data-model-objects-changed',
      eventHandler: function (payload) {
        if (payload.dataModelName === 'segments' && hasSelection()) {
          ja_calculate();
        }
      },
    });
    sdk.Events.on({
      eventName: 'wme-data-model-objects-removed',
      eventHandler: function (payload) {
        if (payload.dataModelName === 'segments' && hasSelection()) {
          ja_calculate();
        }
      },
    });

    sdk.Events.trackDataModelEvents({ dataModelName: 'nodes' });
    sdk.Events.on({
      eventName: 'wme-data-model-objects-changed',
      eventHandler: function (payload) {
        if (payload.dataModelName === 'nodes' && hasSelection()) {
          ja_calculate();
        }
      },
    });
    sdk.Events.on({
      eventName: 'wme-data-model-objects-removed',
      eventHandler: function (payload) {
        if (payload.dataModelName === 'nodes' && hasSelection()) {
          ja_calculate();
        }
      },
    });

    // ── BigJunction (Junction Box) change tracking (experimental) ─────────
    // Junction Box data lives in the BigJunctions data model, separate from segments
    // and nodes. Without this tracking, adding, editing, or deleting a Junction Box
    // while a segment is selected would not refresh the far turn markers — the user
    // would have to deselect and reselect to see the updated state.
    //
    // We mirror the same pattern used for 'segments' and 'nodes' above: track the
    // data model and re-run ja_calculate() on change or removal events.
    sdk.Events.trackDataModelEvents({ dataModelName: 'bigJunctions' });
    sdk.Events.on({
      eventName: 'wme-data-model-objects-changed',
      eventHandler: function (payload) {
        if (payload.dataModelName === 'bigJunctions' && hasSelection()) {
          ja_calculate();
        }
      },
    });
    sdk.Events.on({
      eventName: 'wme-data-model-objects-removed',
      eventHandler: function (payload) {
        if (payload.dataModelName === 'bigJunctions' && hasSelection()) {
          ja_calculate();
        }
      },
    });

    sdk.Events.on({
      eventName: 'wme-map-zoom-changed',
      eventHandler: function () {
        if (hasSelection()) {
          ja_calculate();
        }
      },
    });
    sdk.Events.on({
      eventName: 'wme-map-move-end',
      eventHandler: function () {
        // On-demand roundabout overlay: only update if setting is "Always" and something is selected
        if (ja_options.roundaboutOverlayDisplay === 'rOverAlways' && hasSelection()) {
          if (sdk.Map.getZoomLevel() >= MIN_ZOOM_LEVEL) {
            ja_calculate();
          }
        }

        // Continuous scanning: trigger on every pan (if enabled, regardless of selection)
        if (ja_continuous_mode) {
          ja_debounced_continuous_scan();
        }
      },
    });

    // PHASE 3: Continuous Scanning Event Handlers ────────────────────
    // Wire up zoom and move events to trigger debounced continuous scans
    sdk.Events.on({
      eventName: 'wme-map-zoom-changed',
      eventHandler: function () {
        if (ja_continuous_mode) {
          ja_debounced_continuous_scan();
        }
      },
    });

    // Track segment/node/turn edits and clear relevant cache entries
    sdk.Events.on({
      eventName: 'wme-data-model-objects-changed',
      eventHandler: function (payload) {
        ja_log('*** CONTINUOUS CACHE HANDLER: dataModelName=' + payload.dataModelName + ', ja_continuous_mode=' + ja_continuous_mode + ', hasObjects=' + (payload.objects ? payload.objects.length : 0) + ', payload keys=' + Object.keys(payload).join(','), 2);

        if (ja_continuous_mode && (payload.dataModelName === 'segments' || payload.dataModelName === 'nodes' || payload.dataModelName === 'turns')) {
          var nodeIds = [];

          // Collect node IDs from affected segments
          if (payload.dataModelName === 'segments' && payload.objects && payload.objects.length > 0) {
            for (var i = 0; i < payload.objects.length; i++) {
              var seg = payload.objects[i];
              if (seg.fromNodeId) nodeIds.push(seg.fromNodeId);
              if (seg.toNodeId) nodeIds.push(seg.toNodeId);
            }
            ja_log('Cache invalidation: clearing ' + nodeIds.length + ' nodes affected by ' + payload.objects.length + ' segment edits', 2);
          }

          // Collect node IDs from affected turns
          if (payload.dataModelName === 'turns' && payload.objects && payload.objects.length > 0) {
            for (var i = 0; i < payload.objects.length; i++) {
              var turn = payload.objects[i];
              // Turn has nodeId property (the junction node)
              if (turn.nodeId) nodeIds.push(turn.nodeId);
            }
            ja_log('Cache invalidation: clearing ' + nodeIds.length + ' nodes affected by ' + payload.objects.length + ' turn edits', 2);
          }

          // Collect node IDs from affected nodes (geometry changes)
          if (payload.dataModelName === 'nodes' && payload.objects && payload.objects.length > 0) {
            for (var i = 0; i < payload.objects.length; i++) {
              var node = payload.objects[i];
              if (node.id) nodeIds.push(node.id);
            }
            ja_log('Cache invalidation: clearing ' + nodeIds.length + ' nodes directly affected by ' + payload.objects.length + ' node geometry edits', 2);
          }

          // Clear cache and rendered entries for all affected nodes
          for (var j = 0; j < nodeIds.length; j++) {
            var nId = nodeIds[j];
            delete ja_continuous_cache[nId];
            ja_log('Cleared cache for node ' + nId, 3);

            // Remove all rendered markers for this node (they'll be recalculated)
            var keysToDelete = [];
            ja_continuous_rendered.forEach(function (key) {
              if (key.startsWith(nId + '_')) {
                keysToDelete.push(key);
              }
            });
            keysToDelete.forEach(function (key) {
              ja_continuous_rendered.delete(key);
              ja_log('Invalidated rendered marker: ' + key, 3);
            });
          }

          if (nodeIds.length > 0) {
            ja_log('Cache cleared for ' + nodeIds.length + ' affected nodes, triggering debounced scan', 2);
            ja_debounced_continuous_scan();
          }
        }
      },
    });

    sdk.Events.on({
      eventName: 'wme-user-settings-changed',
      eventHandler: function () {
        ja_loadTranslations();
        if (ja_sidebar_tabPane) {
          setupHtml(ja_sidebar_tabPane);
        }
        ja_apply();
      },
    });

    // ── Translations & sidebar HTML ───────────────────────────────────
    ja_load();
    ja_loadTranslations();

    showScriptInfoAlert();

    // ── SDK map layer ─────────────────────────────────────────────────
    sdk.Map.addLayer({
      layerName: 'junction_angles',
      styleContext: ja_build_style_context(),
      styleRules: ja_build_style_rules(),
    });
    ja_layer_created = true;

    // ── SDK Sidebar ───────────────────────────────────────────────────
    const { tabLabel: jaTabLabel, tabPane: jaTabPane } = await sdk.Sidebar.registerScriptTab();

    // ── Tab label with power button ───────────────────────────────────
    jaTabLabel.innerHTML =
      '<span id="ja-power-btn" class="fa fa-power-off"' +
      ' style="margin-right:5px;cursor:pointer;color:#00bd00;font-size:13px;"' +
      ' title="Toggle Junction Angles"></span>' +
      '<span title="Junction Angle Info">JAI</span>';
    jaTabPane.id = 'sidepanel-ja';
    jaTabPane.classList.add('wme-ja-panel');
    ja_sidebar_tabPane = jaTabPane;
    setupHtml(jaTabPane);
    ja_apply();

    // ── Shared toggle — called by both the power button and the layer checkbox ──
    /**
     * Single source of truth for enabling/disabling the junction_angles map layer.
     *
     * Updates `ja_layer_visible`, sets SDK layer visibility, syncs the Map Layers checkbox
     * (without firing `wme-layer-checkbox-toggled` — that event only fires on user interaction),
     * and updates the power button colour in the sidebar tab label.
     *
     * Both the power button click handler and the `wme-layer-checkbox-toggled` event handler
     * call this function to avoid circular update loops.
     *
     * @param {boolean} enabled - True to show the layer; false to hide it.
     */
    function ja_setLayerEnabled(enabled) {
      ja_layer_visible = enabled;

      // When layer is disabled, also stop continuous scanning (no point scanning if layer is hidden)
      if (!enabled) {
        if (ja_continuous_mode) {
          ja_continuous_mode = false;
          if (ja_continuous_scan_timer) {
            clearTimeout(ja_continuous_scan_timer);
            ja_continuous_scan_timer = null;
          }
          ja_log('Layer disabled. Stopping continuous scanning.', 2);
        }
      } else {
        // When layer is re-enabled, restart continuous scanning if the setting is on
        if (ja_continuous_enabled && !ja_continuous_mode) {
          ja_continuous_mode = true;
          ja_log('Layer enabled. Resuming continuous scanning.', 2);
          ja_start_continuous_scan();
        }
      }

      // Persist layer visibility state to localStorage
      if (localStorage) {
        localStorage.setItem('wme_ja_layer_visible', ja_layer_visible ? 'true' : 'false');
      }
      sdk.Map.setLayerVisibility({ layerName: 'junction_angles', visibility: ja_layer_visible });
      sdk.LayerSwitcher.setLayerCheckboxChecked({ name: 'Junction Angle Info', isChecked: ja_layer_visible });
      var btn = jaTabLabel.querySelector('#ja-power-btn');
      if (btn) {
        btn.style.color = ja_layer_visible ? '#00bd00' : '#ccc';
      }
    }

    // ── Layer visibility checkbox ─────────────────────────────────────
    // Restore layer visibility state from localStorage (default to true if not saved)
    if (localStorage) {
      var savedVisibility = localStorage.getItem('wme_ja_layer_visible');
      ja_layer_visible = savedVisibility === 'false' ? false : true;
    }
    sdk.LayerSwitcher.addLayerCheckbox({ name: 'Junction Angle Info', isChecked: ja_layer_visible });
    sdk.Events.on({
      eventName: 'wme-layer-checkbox-toggled',
      eventHandler: function (evt) {
        if (evt.name === 'Junction Angle Info') {
          ja_setLayerEnabled(evt.checked);
        }
      },
    });

    // ── Auto-refresh on turn instruction/restriction changes ──────────────
    // When a user edits a turn (changes instruction, adds/removes restrictions, etc.),
    // automatically recalculate JAI markers if a segment or node is currently selected.
    // This prevents stale markers when turn data changes in-place.
    sdk.Events.on({
      eventName: 'wme-after-edit',
      eventHandler: function () {
        // Check if we have a current selection (segment, node, or multiple items)
        var currentSel = sdk.Editing.getSelection();
        if (currentSel && currentSel.ids && currentSel.ids.length > 0) {
          // Recalculate markers for the currently selected items
          ja_calculate();
        }
      },
    });

    // ── Power button click ────────────────────────────────────────────
    jaTabLabel.addEventListener('click', function (e) {
      if (e.target && e.target.id === 'ja-power-btn') {
        ja_setLayerEnabled(!ja_layer_visible);
        e.stopPropagation();
      }
    });

    // ── Apply saved visibility state to layer ─────────────────────────
    if (!ja_layer_visible) {
      // If saved state is off, hide the layer immediately
      sdk.Map.setLayerVisibility({ layerName: 'junction_angles', visibility: false });
      var btn = jaTabLabel.querySelector('#ja-power-btn');
      if (btn) {
        btn.style.color = '#ccc';
      }
    }

    ja_apply();
    ja_calculate();
  }

  junctionangle_init();
})();