From f67515fe7432c05bf405282d1449cb95fa7a22cb Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 27 Aug 2026 18:01:22 -0400 Subject: [PATCH 1/8] @web bug: yaw stepper crashed the panel, and points could not be re-picked Three defects in the add-lineup flow, all reported from live use. The aim fields are type="number", so v-model casts and hands back a Number the moment either is touched. hasAim called .trim() on it, threw out of a computed the step strip and the footer both read, and took the whole panel subtree down -- leaving the page-level CANCEL as the only thing on screen. It now tests the value rather than its spelling, and the refs are typed for what they actually hold. Picking silently stopped working at any zoom above 1. The viewport took a pointer capture on press, and while a capture is set the compatibility click is dispatched at the capturing element instead of the element under the pointer -- so the click never reached the svg. Capture is taken on first movement instead, by which point the gesture is a pan and there is no click to lose. The drag/pick threshold is now measured from where the press started rather than between consecutive move events, so a slow drag pans without also picking. Re-picking an end never worked: the two coordinate buttons were the only affordance and they were inside the place step, which the panel leaves on its own the moment both ends are set, while onPick kept handing the mode back to landing so every later click moved the wrong end. The markers are now what the hint always claimed -- press one to aim the next pick at it, drag it to move it -- the coordinates stay visible on every step, and the mode only auto-advances while the pair is still being placed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JTocnM98q1YiSRN4Z73REh --- components/utility/UtilityCreatePanel.vue | 175 +++++++++++++--------- components/utility/UtilityRadarBoard.vue | 129 ++++++++++++++-- i18n/locales/en.json | 4 +- pages/utility/[map].vue | 4 + utilities/utilityDisplay.ts | 12 ++ 5 files changed, 238 insertions(+), 86 deletions(-) diff --git a/components/utility/UtilityCreatePanel.vue b/components/utility/UtilityCreatePanel.vue index d4842906..a9dbbf1d 100644 --- a/components/utility/UtilityCreatePanel.vue +++ b/components/utility/UtilityCreatePanel.vue @@ -111,8 +111,11 @@ const tagsInput = ref(""); const visibility = ref("Private"); const teamId = ref(NO_TEAM); -const yawInput = ref(""); -const pitchInput = ref(""); +// Both fields are type="number", so Vue's v-model casts and hands back a +// Number the moment either is touched -- a string only ever survives while the +// field is empty. Anything reading these has to cope with both. +const yawInput = ref(""); +const pitchInput = ref(""); const anglesTouched = ref(false); const saving = ref(false); @@ -225,12 +228,36 @@ const pitch = computed(() => { function onPick(point: UtilitySightlinePoint) { if (pickMode.value === "origin") { origin.value = point; - pickMode.value = "landing"; + // Hand over to the other end only while first placing the pair. Once both + // exist the mode is a choice the user made -- by grabbing an end or by + // pressing its coordinates -- and moving it out from under them is why + // re-picking the throw silently relocated the landing instead. + if (!landing.value) { + pickMode.value = "landing"; + } return; } landing.value = point; } +/** Pressing an end says which end the next pick means. */ +function onMarkerGrab(key: string) { + if (key === "origin" || key === "landing") { + pickMode.value = key; + } +} + +/** Dragging one moves that end, whatever the pick mode happens to be. */ +function onMarkerDrag(key: string, point: UtilitySightlinePoint) { + if (key === "origin") { + origin.value = point; + return; + } + if (key === "landing") { + landing.value = point; + } +} + function clearPoints() { origin.value = null; landing.value = null; @@ -249,6 +276,7 @@ const markers = computed(() => { color: "#e6ebf5", shape: "cross" as const, label: t("pages.utility.create.origin_short"), + draggable: true, }); } if (landing.value) { @@ -257,6 +285,7 @@ const markers = computed(() => { point: landing.value, color: typeColor.value, label: t("pages.utility.create.landing_short"), + draggable: true, }); } return out; @@ -313,8 +342,16 @@ const step = ref("place"); const placed = computed(() => !!origin.value && !!landing.value); const named = computed(() => name.value.trim().length > 0); + +function aimGiven(value: string | number): boolean { + if (typeof value === "number") { + return Number.isFinite(value); + } + return value.trim().length > 0; +} + const hasAim = computed( - () => yawInput.value.trim().length > 0 && pitchInput.value.trim().length > 0, + () => aimGiven(yawInput.value) && aimGiven(pitchInput.value), ); const stepState = computed(() => ({ @@ -508,6 +545,8 @@ watch( markers: markers.value, segments: segments.value, onPick, + onMarkerGrab, + onMarkerDrag, }); }, { immediate: true }, @@ -550,6 +589,70 @@ watch( + +
+
+ + + {{ $t("pages.utility.create.placed") }} + + +
+ +
+ + + +
+ +

+ + {{ $t("pages.utility.create.repick_note") }} + +

+
+ diff --git a/components/utility/UtilityRadarBoard.vue b/components/utility/UtilityRadarBoard.vue index 25dce7f7..5023cbd4 100644 --- a/components/utility/UtilityRadarBoard.vue +++ b/components/utility/UtilityRadarBoard.vue @@ -62,6 +62,8 @@ const emit = defineEmits<{ (e: "select-meta", key: string | null): void; (e: "hover-meta", key: string | null): void; (e: "pick", point: { x: number; y: number; z: number }): void; + (e: "marker-grab", key: string): void; + (e: "marker-drag", key: string, point: { x: number; y: number; z: number }): void; (e: "select-segment", key: string): void; }>(); @@ -600,6 +602,7 @@ type DrawnMarker = { color: string; label: string | null; shape: "dot" | "cross" | "badge"; + draggable: boolean; point: { x: number; y: number }; }; @@ -615,6 +618,7 @@ const drawnMarkers = computed(() => { color: marker.color ?? "#ffffff", label: marker.label ?? null, shape: marker.shape ?? "dot", + draggable: marker.draggable ?? false, point, }); } @@ -634,11 +638,21 @@ const zoom = ref(1); const panX = ref(0); const panY = ref(0); const viewportRef = ref(null); +const svgRef = ref(null); const panning = ref(false); let dragged = false; +let captured = false; let lastX = 0; let lastY = 0; +let startX = 0; +let startY = 0; + +// How far the pointer may wander before a press counts as a pan rather than a +// pick. Measured from where the press started, not between consecutive move +// events -- a slow drag never moves far enough in any single event, so it used +// to pan the map and then register a pick at the end of it. +const DRAG_SLOP = 3; const boardTransform = computed( () => `translate(${panX.value}px, ${panY.value}px) scale(${zoom.value})`, @@ -736,15 +750,23 @@ function onWheel(event: WheelEvent) { ); } +// Capture is deliberately NOT taken here. While a pointer capture is set, the +// compatibility click is dispatched at the capturing element instead of the +// element under the pointer -- so capturing the viewport on press meant the +// click never reached the beneath it, and picking a point silently +// stopped working at any zoom above 1. It is taken on the first real movement +// instead, by which time the gesture is a pan and there is no click to lose. function onPointerDown(event: PointerEvent) { if (zoom.value <= MIN_ZOOM || event.button !== 0) { return; } panning.value = true; dragged = false; + captured = false; lastX = event.clientX; lastY = event.clientY; - (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + startX = event.clientX; + startY = event.clientY; } function onPointerMove(event: PointerEvent) { @@ -753,9 +775,16 @@ function onPointerMove(event: PointerEvent) { } const dx = event.clientX - lastX; const dy = event.clientY - lastY; - if (Math.abs(dx) > 2 || Math.abs(dy) > 2) { + if ( + Math.abs(event.clientX - startX) > DRAG_SLOP || + Math.abs(event.clientY - startY) > DRAG_SLOP + ) { dragged = true; } + if (dragged && !captured) { + captured = true; + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + } panX.value += dx; panY.value += dy; lastX = event.clientX; @@ -768,7 +797,10 @@ function onPointerUp(event: PointerEvent) { return; } panning.value = false; - (event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId); + if (captured) { + captured = false; + (event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId); + } } function resetView() { @@ -784,6 +816,28 @@ watch( ); // correction. + +// Where a pointer is, in world units. The rect comes from the , which sits +// INSIDE the zoom transform, so it already describes the transformed box and +// none of this has to know the zoom exists. +function worldAt(clientX: number, clientY: number) { + const target = svgRef.value; + if (!target) { + return null; + } + const rect = target.getBoundingClientRect(); + if (!rect.width || !rect.height) { + return null; + } + return unprojectCalibrated( + { + x: ((clientX - rect.left) / rect.width) * CANVAS, + y: ((clientY - rect.top) / rect.height) * CANVAS, + }, + props.pickZ, + ); +} + function onBoardClick(event: MouseEvent) { // A drag that ended on the map is a pan, not a pick. if (dragged) { @@ -799,25 +853,49 @@ function onBoardClick(event: MouseEvent) { emit("select", null); return; } - const target = event.currentTarget as SVGSVGElement | null; - if (!target) { + const world = worldAt(event.clientX, event.clientY); + if (!world) { return; } - const rect = target.getBoundingClientRect(); - if (!rect.width || !rect.height) { + emit("pick", world); +} + +// Grabbing one of the placed points. The press alone says "this is the end I +// mean" -- which is what makes the two ends re-pickable by clicking them, the +// thing the hint under the coordinates has always claimed -- and any movement +// after it drags that end instead of dropping a new point somewhere else. +const draggingKey = ref(null); + +function onMarkerDown(event: PointerEvent, marker: DrawnMarker) { + if (!props.picking || !marker.draggable) { return; } - const world = unprojectCalibrated( - { - x: ((event.clientX - rect.left) / rect.width) * CANVAS, - y: ((event.clientY - rect.top) / rect.height) * CANVAS, - }, - props.pickZ, - ); + event.stopPropagation(); + draggingKey.value = marker.key; + emit("marker-grab", marker.key); + (event.currentTarget as Element).setPointerCapture?.(event.pointerId); +} + +function onMarkerMove(event: PointerEvent) { + const key = draggingKey.value; + if (!key) { + return; + } + event.stopPropagation(); + const world = worldAt(event.clientX, event.clientY); if (!world) { return; } - emit("pick", world); + emit("marker-drag", key, world); +} + +function onMarkerUp(event: PointerEvent) { + if (!draggingKey.value) { + return; + } + event.stopPropagation(); + (event.currentTarget as Element).releasePointerCapture?.(event.pointerId); + draggingKey.value = null; } const activeId = computed(() => props.hoveredId ?? props.selectedId ?? null); @@ -931,6 +1009,7 @@ const orderedMarkers = computed(() => { { + +