9.2 KiB
Movable UI Panels — Draggable Panel Positioning
Status: Proposed
Related: #279 (original request), PR #606 (collapsible panels — immediate fix)
Date: 2026-04-05
Problem
The live map page overlays several UI panels on the map viewport: legend, live feed, node detail, and filters. On smaller screens or dense deployments, these panels obscure map content. Users have no control over where panels sit — they're CSS-fixed in corners, and when they collide with each other or with map data, the only option is to close them entirely. Closing a panel means losing access to the data it shows.
PR #606 addresses the immediate pain with collapsible panels and responsive breakpoints. This spec covers the next step: letting users reposition panels to wherever serves their workflow best.
Solution
Panels become draggable within the map viewport. Users grab a handle, drag to a new position, release. Position persists in localStorage per panel ID. That's it.
What each panel gets
| Affordance | Behavior |
|---|---|
| Drag handle | A subtle grip indicator (6-dot grid or ⋮⋮) in the panel header. Cursor changes to grab/grabbing. The handle is the ONLY drag target — the panel body remains interactive (scrollable, clickable). |
| Snap-to-edge | When released within 20px of a viewport edge, the panel snaps flush to that edge. Prevents panels floating 3px from the side looking broken. |
| Position persistence | localStorage key per panel: panel-pos-{id} → { x, y } as viewport percentages (not pixels — survives resize). |
| Z-index on focus | Clicking or dragging a panel brings it to front. Simple incrementing counter, reset on page load. |
| Reset button | Single button (in settings or as a map control) resets ALL panels to default positions. Clears all panel-pos-* keys. |
What we do NOT build
- Resizable panels. Drag-to-resize adds complexity for marginal benefit. Panels have natural content-driven sizes.
- Docking/tiling/splitting. This is not a window manager. No snap-to-other-panel, no split view, no tiling grid.
- Panel minimization to a taskbar. Collapsible (PR #606) is sufficient.
- Drag on mobile. Touch-drag conflicts with map pan. Mobile keeps collapsible behavior from PR #606. Draggable is desktop-only (
pointer: finemedia query).
Design Considerations
Drag handle affordance
The handle must be visible enough that users discover it, but not so prominent that it becomes visual noise. A 6-dot grip icon (⋮⋮) in the panel title bar, styled at 60% opacity, rising to 100% on hover. The cursor change (grab → grabbing) provides the primary affordance.
Snap-to-edge
Panels snap to the nearest edge when released within a 20px threshold. Snap positions: top-left, top-right, bottom-left, bottom-right, or any edge midpoint. This prevents the "floating at 47px from the left" awkwardness without constraining users to a rigid grid.
Position persistence
Positions stored as viewport percentages: { xPct: 0.02, yPct: 0.15 }. On window resize, panels stay proportionally positioned. If a resize would push a panel off-screen, clamp it to the nearest visible edge.
Responsive breakpoints
Below the medium breakpoint (defined in PR #606), panels revert to their fixed/collapsible positions. The draggable behavior is a progressive enhancement for viewports wide enough to have meaningful repositioning space. Persisted positions are preserved in localStorage but not applied until the viewport is wide enough again.
Z-index management
A module-level counter starting at 1000. Each panel interaction (click, drag start) sets that panel's z-index to ++counter. On page load, counter resets to 1000. No panel can exceed z-index 9999 (modal/overlay territory) — if counter approaches that, compact all panel z-indices down.
Accessibility
- Panels are focusable (
tabindex="0"on the drag handle). - Arrow keys reposition the focused panel by 10px per press (Shift+Arrow = 50px).
Escapewhile dragging cancels and returns to the previous position.Homekey resets the focused panel to its default position.- Screen readers:
aria-label="Drag handle for {panel name}. Use arrow keys to reposition."androle="slider"witharia-valuenowreflecting position.
Implementation
Milestones
M1: Core drag mechanics (~2 days)
DragManagerclass: registers panels, handles pointer events, updates positions- Snap-to-edge logic
- Z-index management
- No persistence yet — positions reset on reload
M2: Persistence + reset (~1 day)
localStorageread/write for panel positions- Reset-to-defaults button
- Viewport-percentage storage with resize clamping
M3: Responsive + accessibility (~1 day)
- Disable drag below medium breakpoint
- Keyboard repositioning (arrow keys)
- ARIA attributes
- Screen reader announcements on position change
M4: Polish + testing (~1 day)
- Playwright E2E tests: drag, snap, persist, reset, keyboard
- Performance validation: drag must not trigger layout thrash (use
transform: translate(), nottop/left) - Edge case handling (see below)
Technical approach
- No library. Pointer events (
pointerdown,pointermove,pointerup) withsetPointerCapture. ~150 lines of vanilla JS. - CSS transforms for positioning.
transform: translate(Xpx, Ypx)avoids layout reflow during drag. Only write tostyle.transform, nevertop/left. - Debounce persistence. Write to
localStorageonpointerup, not during drag. - Single file:
public/drag-manager.js— imported bylive.js, no other dependencies.
Edge Cases
| Case | Handling |
|---|---|
| Panel dragged partially off-screen | Clamp to viewport bounds on pointerup |
| Window resized while panel is near edge | Re-clamp on resize (debounced 200ms) |
| Two panels overlap after drag | Allowed — z-index determines which is on top. Users can move them. |
localStorage full or unavailable |
Graceful fallback to default positions. No error shown. |
| Panel content changes size after drag | Panel stays at dragged position; content reflows within. If panel grows past viewport edge, clamp. |
User has old localStorage keys from a removed panel |
Ignore unknown keys on load. Clean up stale keys on reset. |
| RTL layouts | Snap logic uses physical viewport edges, not logical start/end. Drag is inherently physical. |
Expert Reviews
Tufte (Information Design)
- Draggability is justified only if it serves data access — and here it does. Panels obscuring map data is a data-visibility problem, not a UI-decoration problem. Letting users clear their sightlines to the data is correct.
- The drag handle must be minimal. Six dots at 60% opacity is acceptable. Anything more prominent (colored bars, icons, labels) becomes chartjunk — UI chrome competing with data for attention.
- Resist feature creep. Resizable panels, docking zones, panel-to-panel snapping — all increase interface complexity without increasing data throughput. The spec correctly excludes these.
- Snap-to-edge is good. It prevents the visual noise of arbitrarily placed rectangles. Panels aligned to edges create clean negative space for the map data.
Torvalds (Engineering Pragmatism)
- This is borderline over-engineering. The real question: do users actually need free-form drag, or would a simpler "pick a corner" toggle (TL/TR/BL/BR) cover 95% of use cases with 20% of the code?
- The 4-corner toggle would be ~40 lines. The full drag system is ~150+ lines plus persistence, snap logic, accessibility, resize handling, z-index management, and edge cases. That's a lot of surface area for "I want the legend on the right instead of the left."
- Recommendation: Ship the 4-corner toggle first (M0). If users actually request free-form drag after that, build it. YAGNI applies here.
- If you do build drag: the spec is sound. Pointer events + transforms + localStorage is the right stack. No library is correct. But test it on Firefox — pointer capture has quirks.
Doshi (Product/Business)
- This is an N (Nice-to-have), not an L (Leverage). It improves UX for power users who spend hours on the live map, but it doesn't unlock new capabilities or new users.
- Opportunity cost: 5 developer-days on draggable panels is 5 days not spent on features that expand what CoreScope can do (new analytics, alerting, multi-site support).
- The collapsible panels (PR #606) likely resolve the P1 pain. Track whether users still complain about panel placement after #606 ships. If complaints drop to zero, this spec can stay on the shelf.
- If built: ship M1+M2 only (3 days). M3 accessibility can come later if adoption warrants it. M4 testing is non-negotiable.
Feedback incorporated
Based on the reviews, the spec adds a Milestone 0 recommendation:
M0: Corner-position toggle (~0.5 days)
Before building full drag, ship a simpler panel-position toggle: each panel's header gets a small button that cycles through TL → TR → BR → BL placement. Positions persist in localStorage. If this satisfies user needs, M1–M4 become unnecessary.
Decision gate: Ship M0 with PR #606 or shortly after. Monitor feedback for 2 weeks. If users request free-form repositioning, proceed to M1. If corner toggle is sufficient, close this spec as "resolved by M0."