WME Polygon Snap

Snap to nearby polygon edges and vertices when drawing or modifying polygons in the Waze Map Editor.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         WME Polygon Snap
// @namespace    https://greasyfork.org/users/wme-polygon-snap
// @version      1.0.0
// @description  Snap to nearby polygon edges and vertices when drawing or modifying polygons in the Waze Map Editor.
// @author       ThatVictoriaGuy (Secured_ on Discord)
// @license      MIT
// @match        https://www.waze.com/editor*
// @match        https://www.waze.com/*/editor*
// @match        https://beta.waze.com/editor*
// @match        https://beta.waze.com/*/editor*
// @icon         data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='80' font-size='80'%3E🧲%3C/text%3E%3C/svg%3E
// @grant        none
// @run-at       document-idle
// ==/UserScript==

/* jshint esversion: 11 */

(function () {
    'use strict';

    // ╔════════════════════════════════════════════════════════════════════╗
    // ║                     USER CONFIGURATION                           ║
    // ║  Adjust these values to customize snapping behavior.             ║
    // ╚════════════════════════════════════════════════════════════════════╝

    /** Snap tolerance in pixels. Higher = snaps from farther away.
     *  Recommended range: 8–20. Default: 12 */
    const SNAP_TOLERANCE = 12;

    /** How often (ms) to check for drawing/editing state changes.
     *  Lower = more responsive, higher = less CPU usage.
     *  Recommended range: 100–500. Default: 250 */
    const POLL_INTERVAL = 250;

    /** Start with snapping enabled? Set to false to start disabled. */
    const ENABLED_BY_DEFAULT = true;

    /** Snap to polygon edges (the lines between vertices)? */
    const SNAP_TO_EDGES = true;

    /** Snap to polygon vertices (corner points)? */
    const SNAP_TO_VERTICES = true;

    /** Snap to polygon nodes? (Usually the same as vertices for polygons.) */
    const SNAP_TO_NODES = true;

    /** Which layers to snap to. Set any to false to exclude that layer.
     *  Only affects layers that exist on the current map view. */
    const SNAP_LAYERS = {
        venues: true,   // Places / landmarks
        mapComments: true,   // Map comments (polygon type)
        majorTrafficEvents: true,   // MTEs
        restrictedDrivingAreas: true,   // Restricted driving areas
        permanentHazards: true,   // Permanent hazards (polygon type)
        bigJunctions: true,   // Big junctions
    };

    // ╔════════════════════════════════════════════════════════════════════╗
    // ║                  END OF USER CONFIGURATION                       ║
    // ║  Do not modify below this line unless you know what you're doing ║
    // ╚════════════════════════════════════════════════════════════════════╝

    const SCRIPT_NAME = 'WME Polygon Snap';
    const SCRIPT_VERSION = '1.0.0';

    // ─── Internal State ──────────────────────────────────────────────────
    let enabled = ENABLED_BY_DEFAULT;
    let drawSnappingControl = null;
    let modifySnappingControl = null;
    let currentModifyLayerName = null;
    let pollingTimer = null;
    let W = null;
    let OL = null;

    // ─── Logging ─────────────────────────────────────────────────────────
    const log = (...a) => console.log(`[${SCRIPT_NAME}]`, ...a);
    const warn = (...a) => console.warn(`[${SCRIPT_NAME}]`, ...a);

    // ─── Bootstrap ───────────────────────────────────────────────────────
    function bootstrap() {
        const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
        W = win.W;
        OL = win.OpenLayers;

        const ready =
            W?.map?.getWazeMap?.() &&
            W.model &&
            W.editingMediator &&
            W.selectionManager &&
            OL?.Control?.Snapping &&
            document.querySelector('#WazeMap');

        if (ready) {
            log(`v${SCRIPT_VERSION} — WME loaded, initializing`);
            init();
        } else {
            setTimeout(bootstrap, 800);
        }
    }

    // ─── OpenLayers Map Access ───────────────────────────────────────────
    function getOLMap() {
        try { return W.map.getWazeMap().getOLMap(); } catch (_) { }
        try { return W.map.getWazeMap().olMap; } catch (_) { }
        try { return W.map.wazeMap?.olMap; } catch (_) { }
        return null;
    }

    // ─── Layer Utilities ─────────────────────────────────────────────────
    // WME wraps OL layers in WazeFeatureMapper objects. The raw OL layer
    // lives at wrapper.layer. We need the raw layer for snapping.

    function getRawOLLayer(obj) {
        if (!obj) return null;
        if (obj.CLASS_NAME) return obj;              // already raw
        if (obj.layer?.CLASS_NAME) return obj.layer;  // unwrap mapper
        return null;
    }

    function getSketchLayer() {
        try {
            const sl = W.map.getSketchLayer?.() || W.map.sketchLayer;
            return getRawOLLayer(sl) || sl;
        } catch (_) { return null; }
    }

    /**
     * Collect all OL vector layers that should act as snap targets,
     * filtered by the user's SNAP_LAYERS configuration.
     */
    function getSnapTargetLayers() {
        const targets = [];
        const sketchName = getSketchLayer()?.name;

        // Map config keys → WMEMap property names
        const layerMap = [
            ['venues', 'venueLayer'],
            ['mapComments', 'commentLayer'],
            ['majorTrafficEvents', 'mteLayer'],
            ['restrictedDrivingAreas', 'restrictedDrivingAreaLayer'],
            ['permanentHazards', 'permanentHazardLayer'],
            ['bigJunctions', 'bigJunctionLayer'],
        ];

        for (const [configKey, prop] of layerMap) {
            if (!SNAP_LAYERS[configKey]) continue;  // user disabled this layer
            const raw = getRawOLLayer(W.map[prop]);
            if (raw && raw.name !== sketchName && raw.features) {
                targets.push(raw);
            }
        }

        // Fallback: if no named layers found, scan all vector layers
        if (targets.length === 0) {
            const olMap = getOLMap();
            if (!olMap) return [];
            for (const layer of olMap.layers) {
                if (
                    layer.CLASS_NAME === 'OpenLayers.Layer.Vector' &&
                    layer.features &&
                    layer.visibility &&
                    !layer.isBaseLayer &&
                    layer.name !== sketchName
                ) {
                    targets.push(layer);
                }
            }
            if (targets.length) {
                log('Using fallback layer scan — found', targets.length, 'vector layers');
            }
        }

        return targets;
    }

    // ─── Snapping Control Factory ────────────────────────────────────────

    function createSnappingControl(editableLayer) {
        if (!OL?.Control?.Snapping) {
            warn('OpenLayers.Control.Snapping not available');
            return null;
        }

        const targetLayers = getSnapTargetLayers();
        if (!targetLayers.length) {
            warn('No target layers found for snapping');
            return null;
        }

        const targets = targetLayers.map(layer => ({
            layer,
            tolerance: SNAP_TOLERANCE,
            node: SNAP_TO_NODES,
            vertex: SNAP_TO_VERTICES,
            edge: SNAP_TO_EDGES,
        }));

        const ctrl = new OL.Control.Snapping({
            layer: editableLayer,
            targets,
            greedy: false,
        });

        // Vertex/node snapping takes priority over edge snapping.
        // When the cursor is near both a corner and an edge, it will
        // snap to the corner — which is the expected behavior.
        ctrl.precedence = ['node', 'vertex', 'edge'];

        return ctrl;
    }

    function activateControl(ctrl) {
        const olMap = getOLMap();
        if (!ctrl || !olMap) return;
        try {
            olMap.addControl(ctrl);
            ctrl.activate();
        } catch (e) { warn('Failed to activate snapping control:', e); }
    }

    function deactivateControl(ctrl) {
        if (!ctrl) return;
        const olMap = getOLMap();
        try { ctrl.deactivate(); } catch (_) { }
        try { olMap?.removeControl(ctrl); } catch (_) { }
        try { ctrl.destroy(); } catch (_) { }
    }

    // ─── Draw-Mode Snapping ──────────────────────────────────────────────
    // Attaches snapping to the sketch layer so the cursor snaps while
    // placing vertices during polygon drawing.

    function attachDrawSnapping() {
        if (!enabled || drawSnappingControl) return;
        const sketch = getSketchLayer();
        if (!sketch) return;

        drawSnappingControl = createSnappingControl(sketch);
        if (drawSnappingControl) {
            activateControl(drawSnappingControl);
            log('Draw snapping activated');
        }
    }

    function detachDrawSnapping() {
        if (!drawSnappingControl) return;
        deactivateControl(drawSnappingControl);
        drawSnappingControl = null;
        log('Draw snapping deactivated');
    }

    // ─── Modify-Mode Snapping ────────────────────────────────────────────
    // Attaches snapping to the selected feature's layer so dragged vertices
    // snap to edges/vertices of other polygons.

    function attachModifySnapping() {
        if (!enabled || modifySnappingControl) return;
        const layer = getSelectedPolygonLayer();
        if (!layer) return;

        modifySnappingControl = createSnappingControl(layer);
        if (modifySnappingControl) {
            activateControl(modifySnappingControl);
            currentModifyLayerName = layer.name;
            log('Modify snapping activated on', layer.name);
        }
    }

    function detachModifySnapping() {
        if (!modifySnappingControl) return;
        deactivateControl(modifySnappingControl);
        modifySnappingControl = null;
        currentModifyLayerName = null;
        log('Modify snapping deactivated');
    }

    // ─── Feature Detection ───────────────────────────────────────────────

    const POLYGON_FEATURE_TYPES = [
        'venue', 'mapComment', 'mte',
        'restrictedDrivingArea', 'permanentHazard', 'bigJunction',
    ];

    function isPolygonFeature(feature) {
        const type = feature.getType?.();
        if (type) return POLYGON_FEATURE_TYPES.includes(type);

        const geom = feature.getGeometry?.() || feature.geometry;
        if (geom) {
            const gt = (geom.type || geom.CLASS_NAME || '').toLowerCase();
            return gt.includes('polygon');
        }
        return false;
    }

    function getSelectedPolygonLayer() {
        try {
            const selected =
                W.selectionManager.getSelectedDataModelObjects?.() ||
                W.selectionManager.getSelectedFeatures?.();
            if (!selected?.length) return null;

            const feature = selected[0];
            if (!feature || !isPolygonFeature(feature)) return null;

            const layer = W.selectionManager.getLayerFromModel?.(feature);
            return getRawOLLayer(layer) || layer || null;
        } catch (_) { return null; }
    }

    // ─── Drawing Detection ───────────────────────────────────────────────

    function isDrawingActive() {
        try {
            if (W.editingMediator.isDrawing?.()) return true;

            const olMap = getOLMap();
            if (olMap?.controls) {
                for (const ctrl of olMap.controls) {
                    if (ctrl.active && ctrl.CLASS_NAME?.includes('DrawFeature')) {
                        return true;
                    }
                }
            }
        } catch (_) { }
        return false;
    }

    // ─── State Poll ──────────────────────────────────────────────────────

    function pollState() {
        if (!enabled) {
            detachDrawSnapping();
            detachModifySnapping();
            return;
        }

        // Drawing
        if (isDrawingActive()) {
            if (!drawSnappingControl) attachDrawSnapping();
        } else if (drawSnappingControl) {
            detachDrawSnapping();
        }

        // Modifying
        const polyLayer = getSelectedPolygonLayer();
        if (polyLayer) {
            if (!modifySnappingControl || currentModifyLayerName !== polyLayer.name) {
                detachModifySnapping();
                attachModifySnapping();
            }
        } else if (modifySnappingControl) {
            detachModifySnapping();
        }
    }

    // ─── Toggle Button ───────────────────────────────────────────────────

    function createToggleButton() {
        if (document.getElementById('wme-polygon-snap-toggle')) return;

        const btn = document.createElement('button');
        btn.id = 'wme-polygon-snap-toggle';
        Object.assign(btn.style, {
            position: 'fixed',
            bottom: '30px',
            right: '60px',
            zIndex: '10000',
            width: '36px',
            height: '36px',
            borderRadius: '50%',
            border: '2px solid #4a90d9',
            background: '#4a90d9',
            color: '#fff',
            fontSize: '18px',
            cursor: 'pointer',
            boxShadow: '0 2px 8px rgba(0,0,0,0.25)',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            transition: 'all 0.2s ease',
            opacity: '0.9',
            lineHeight: '1',
        });
        btn.textContent = '🧲';

        function refresh() {
            btn.style.background = enabled ? '#4a90d9' : '#fff';
            btn.style.borderColor = enabled ? '#3a7bc8' : '#ccc';
            btn.style.color = enabled ? '#fff' : '#999';
            btn.title = `Polygon Snap: ${enabled ? 'ON' : 'OFF'}`;
        }

        btn.addEventListener('click', () => {
            enabled = !enabled;
            refresh();
            if (!enabled) {
                detachDrawSnapping();
                detachModifySnapping();
            }
            log(enabled ? 'Enabled' : 'Disabled');
        });

        btn.addEventListener('mouseenter', () => {
            btn.style.opacity = '1';
            btn.style.transform = 'scale(1.1)';
        });
        btn.addEventListener('mouseleave', () => {
            btn.style.opacity = '0.9';
            btn.style.transform = 'scale(1)';
        });

        refresh();
        document.body.appendChild(btn);
    }

    // ─── Initialization ──────────────────────────────────────────────────

    function init() {
        const olMap = getOLMap();
        if (!olMap) {
            warn('OpenLayers map not accessible — retrying in 2 s');
            setTimeout(init, 2000);
            return;
        }

        log('Map ready —', olMap.layers.length, 'layers');

        // State polling
        pollingTimer = setInterval(pollState, POLL_INTERVAL);

        // Event-driven updates for snappier response
        try {
            W.selectionManager.addEventListener?.('selectionchanged', () =>
                setTimeout(pollState, 80));
        } catch (_) { }

        try {
            W.editingMediator.on?.('change', () =>
                setTimeout(pollState, 80));
        } catch (_) { }

        createToggleButton();
        log('Ready ✓');
    }

    // ─── Entry Point ─────────────────────────────────────────────────────
    log('Waiting for WME…');
    bootstrap();

})();