diff --git a/tasks/enduro-trails/prototype/static/app.css b/tasks/enduro-trails/prototype/static/app.css index 3738913..df0ff5a 100644 --- a/tasks/enduro-trails/prototype/static/app.css +++ b/tasks/enduro-trails/prototype/static/app.css @@ -266,8 +266,24 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; } display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); + position: relative; } .wl-item:last-child { border-bottom: none; } +.wl-drag-handle { + width: 20px; height: 28px; + display: flex; align-items: center; justify-content: center; + color: var(--text3); cursor: grab; flex-shrink: 0; + touch-action: none; + -webkit-tap-highlight-color: transparent; +} +.wl-drag-handle svg { width: 16px; height: 16px; } +.wl-item.dragging { + opacity: 0.4; + background: var(--surface); + border-radius: 4px; +} +.wl-item.drag-over-top { border-top: 2px solid var(--accent); } +.wl-item.drag-over-bottom { border-bottom: 2px solid var(--accent); } .wl-pin { flex-shrink: 0; display: flex; align-items: center; } .wl-label { flex: 1; font-size: 13px; color: var(--text); diff --git a/tasks/enduro-trails/prototype/static/app.js b/tasks/enduro-trails/prototype/static/app.js index 7744be7..292f7a1 100644 --- a/tasks/enduro-trails/prototype/static/app.js +++ b/tasks/enduro-trails/prototype/static/app.js @@ -532,15 +532,18 @@ async function renderWaypointsList() { const list = document.getElementById('waypoints-list'); if (!routeWaypoints.length) { list.innerHTML = ''; return; } + const gripSvg = ``; + let html = routeWaypoints.map((wp, i) => { const isStart = i === 0; const isEnd = i === routeWaypoints.length - 1; const label = isStart ? 'S' : isEnd ? 'F' : String(i); const color = isStart ? '#2EA043' : isEnd ? '#FF3B1F' : '#0066ff'; const coordText = `${wp.lat.toFixed(3)}, ${wp.lon.toFixed(3)}`; - return `
+ return `
${waypointPinSvg(label, color)}
${coordText} +
${gripSvg}
@@ -563,6 +566,129 @@ async function renderWaypointsList() { const el = document.getElementById(`wl-label-${i}`); if (el) el.textContent = name; }); + + // Touch drag-and-drop (mobile only) + _initWaypointDragHandles(list); +} + +function _initWaypointDragHandles(list) { + let dragIdx = -1; + let startY = 0; + let dragging = false; + let lastOverEl = null; + let lastOverPos = null; + + function getItemEls() { + return Array.from(list.querySelectorAll('.wl-item[data-idx]')); + } + + function clearHighlights() { + getItemEls().forEach(el => { + el.classList.remove('drag-over-top', 'drag-over-bottom', 'dragging'); + }); + } + + function getDropTarget(clientY) { + const items = getItemEls(); + for (const el of items) { + const idx = parseInt(el.dataset.idx, 10); + if (idx === dragIdx) continue; + const rect = el.getBoundingClientRect(); + if (clientY >= rect.top && clientY <= rect.bottom) { + const mid = rect.top + rect.height / 2; + return { el, idx, pos: clientY < mid ? 'top' : 'bottom' }; + } + } + return null; + } + + function startDrag(clientY, idx) { + dragIdx = idx; + startY = clientY; + dragging = false; + lastOverEl = null; + lastOverPos = null; + const dragEl = document.getElementById(`wl-item-${idx}`); + if (dragEl) dragEl.classList.add('dragging'); + } + + function moveDrag(clientY) { + if (dragIdx < 0) return; + const dy = Math.abs(clientY - startY); + if (dy > 5) dragging = true; + if (!dragging) return; + clearHighlights(); + const target = getDropTarget(clientY); + if (target) { + lastOverEl = target.el; + lastOverPos = target.pos; + target.el.classList.add(target.pos === 'top' ? 'drag-over-top' : 'drag-over-bottom'); + } else { + lastOverEl = null; + lastOverPos = null; + } + } + + function endDrag(finalClientY) { + if (dragIdx < 0) return; + clearHighlights(); + const dy = Math.abs(finalClientY - startY); + + if (dragging && dy > 30 && lastOverEl !== null) { + const dropIdx = parseInt(lastOverEl.dataset.idx, 10); + let insertAt = lastOverPos === 'top' ? dropIdx : dropIdx + 1; + const moved = routeWaypoints.splice(dragIdx, 1)[0]; + if (insertAt > dragIdx) insertAt--; + routeWaypoints.splice(insertAt, 0, moved); + rebuildWaypointMarkers(); + renderWaypointsList(); + if (routeWaypoints.length >= 2) debounceBuildRoute(); + updateMiniRouteCard(); + } + + dragIdx = -1; + dragging = false; + lastOverEl = null; + lastOverPos = null; + } + + // Touch (mobile) + list.addEventListener('touchstart', (e) => { + const handle = e.target.closest('.wl-drag-handle'); + if (!handle) return; + startDrag(e.touches[0].clientY, parseInt(handle.dataset.idx, 10)); + }, { passive: true }); + + list.addEventListener('touchmove', (e) => { + if (dragIdx < 0) return; + moveDrag(e.touches[0].clientY); + e.preventDefault(); + }, { passive: false }); + + list.addEventListener('touchend', (e) => { + endDrag(e.changedTouches[0].clientY); + }, { passive: true }); + + // Mouse (desktop) + list.addEventListener('mousedown', (e) => { + const handle = e.target.closest('.wl-drag-handle'); + if (!handle) return; + e.preventDefault(); + startDrag(e.clientY, parseInt(handle.dataset.idx, 10)); + document.addEventListener('mousemove', _onDragMouse); + document.addEventListener('mouseup', _onDropMouse); + }); + + function _onDragMouse(e) { + if (dragIdx < 0) return; + moveDrag(e.clientY); + } + + function _onDropMouse(e) { + endDrag(e.clientY); + document.removeEventListener('mousemove', _onDragMouse); + document.removeEventListener('mouseup', _onDropMouse); + } } function removeWaypoint(idx) {