Show the angle between two connected (or selected) segments — experimental branch adding Junction Box and Path (far turn) support
// ==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(); })();