From 7f0fbf6c31b6d947e487a2fed0bf6901a761586f Mon Sep 17 00:00:00 2001 From: Jonah <47046556+jwbonner@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:30:20 -0400 Subject: [PATCH 1/5] Add 2D field camera --- docs/docs/tab-reference/2d-field.md | 10 + src/hub/ScrollSensor.ts | 43 ++- src/hub/Tabs.ts | 11 +- src/hub/hub.ts | 4 + src/main/electron/main.ts | 57 +++- src/main/lite/main.ts | 30 +++ src/satellite.ts | 8 +- src/shared/Field2dCameraMode.ts | 12 + src/shared/renderers/Field2dRenderer.ts | 314 ++++++++++++++++++++-- src/shared/renderers/LineGraphRenderer.ts | 31 ++- www/hub.html | 3 + www/renderers.css | 30 ++- www/satellite.html | 3 + 13 files changed, 502 insertions(+), 54 deletions(-) create mode 100644 src/shared/Field2dCameraMode.ts diff --git a/docs/docs/tab-reference/2d-field.md b/docs/docs/tab-reference/2d-field.md index ee1a9ddfe..5ab05954a 100644 --- a/docs/docs/tab-reference/2d-field.md +++ b/docs/docs/tab-reference/2d-field.md @@ -24,6 +24,16 @@ To zoom, place the cursor over the timeline and scroll up or down. A range can a +## Camera Controls + +Scroll up and down over the field to zoom in and out. Click and drag to pan across the field. Right-click anywhere on the field to open the camera menu with the following options: + +- **Unlocked**: Pan and zoom are manual. +- **Locked to Robot**: The camera automatically pans to track the robot. +- **Locked to Robot & Rotation**: The camera automatically pans and rotates to track the robot and its rotation. + +In either locked mode, the camera can be freely panned away from the robot. The orientation buttons in the control pane can be used to rotate the camera in any mode. + ## Adding Objects To get started, drag a field to the "Poses" section. Delete an object using the X button, or hide it temporarily by clicking the eye icon or double-clicking the field name. To remove all objects, click the trash can near the axis title and then `Clear All`. Objects can be rearranged in the list by clicking and dragging. diff --git a/src/hub/ScrollSensor.ts b/src/hub/ScrollSensor.ts index 03337a2f4..cb7747084 100644 --- a/src/hub/ScrollSensor.ts +++ b/src/hub/ScrollSensor.ts @@ -10,7 +10,7 @@ export default class ScrollSensor { private RESET_MS = 1000; private container: HTMLElement; - private callback: (x: number, y: number) => void; + private callback: (dx: number, dy: number, isPan: boolean, cursorX: number, cursorY: number) => void; private lastScrollUpdate = 0; private resetNext = false; @@ -19,13 +19,22 @@ export default class ScrollSensor { private panActive = false; private panLastCursorX = 0; + private panLastCursorY = 0; + + private lastCursorX = 0; + private lastCursorY = 0; /** * Creates a new ScrollSensor. * @param container The container element. The overflow should be "scroll" and the scrollbar should be hidden. The child element should have the dimensions 1000000x1000000px. * @param callback A function to be called after each scroll event, with the relative change in x and y. + * @param mouseControl The mouse control mode for drag-panning. */ - constructor(container: HTMLElement, callback: (dx: number, dy: number) => void, enableMouseControls = true) { + constructor( + container: HTMLElement, + callback: (dx: number, dy: number, isPan: boolean, cursorX: number, cursorY: number) => void, + mouseControl: ScrollSensorMouseControl = ScrollSensorMouseControl.PanX + ) { this.container = container; this.callback = callback; @@ -35,13 +44,22 @@ export default class ScrollSensor { this.update(); }); + // Prevent capturing spacebar + this.container.addEventListener("keydown", (event) => { + if (event.code === "Space") { + event.preventDefault(); + } + }); + // Mouse controls - if (enableMouseControls) { + if (mouseControl !== ScrollSensorMouseControl.None) { container.addEventListener("mousedown", (event) => { if (event.shiftKey) return; this.panActive = true; let x = event.clientX - container.getBoundingClientRect().x; + let y = event.clientY - container.getBoundingClientRect().y; this.panLastCursorX = x; + this.panLastCursorY = y; }); container.addEventListener("mouseleave", () => { this.panActive = false; @@ -50,10 +68,17 @@ export default class ScrollSensor { this.panActive = false; }); container.addEventListener("mousemove", (event) => { + let cursorX = event.clientX - container.getBoundingClientRect().x; + let cursorY = event.clientY - container.getBoundingClientRect().y; + this.lastCursorX = cursorX; + this.lastCursorY = cursorY; + if (this.panActive) { - let cursorX = event.clientX - container.getBoundingClientRect().x; - callback(this.panLastCursorX - cursorX, 0); + let dx = this.panLastCursorX - cursorX; + let dy = mouseControl === ScrollSensorMouseControl.PanXY ? this.panLastCursorY - cursorY : 0; + callback(dx, dy, true, cursorX, cursorY); this.panLastCursorX = cursorX; + this.panLastCursorY = cursorY; } }); } @@ -89,7 +114,7 @@ export default class ScrollSensor { let dy = this.container.scrollTop - this.lastScrollTop; this.lastScrollLeft = this.container.scrollLeft; this.lastScrollTop = this.container.scrollTop; - this.callback(dx, dy); + this.callback(dx, dy, false, this.lastCursorX, this.lastCursorY); } /** Moves the scroll position to the center. */ @@ -101,3 +126,9 @@ export default class ScrollSensor { this.lastScrollTop = middle; } } + +export enum ScrollSensorMouseControl { + None, + PanX, + PanXY +} diff --git a/src/hub/Tabs.ts b/src/hub/Tabs.ts index 9adc4bf93..b4172cdd9 100644 --- a/src/hub/Tabs.ts +++ b/src/hub/Tabs.ts @@ -26,7 +26,7 @@ import TableRenderer from "../shared/renderers/TableRenderer"; import VideoRenderer from "../shared/renderers/VideoRenderer"; import { Units } from "../shared/units"; import { clampValue } from "../shared/util"; -import ScrollSensor from "./ScrollSensor"; +import ScrollSensor, { ScrollSensorMouseControl } from "./ScrollSensor"; import Timeline from "./Timeline"; import ConsoleController from "./controllers/ConsoleController"; import Field2dController from "./controllers/Field2dController"; @@ -325,7 +325,7 @@ export default class Tabs { (dx: number, dy: number) => { this.TAB_BAR.scrollLeft += dx + dy; }, - false + ScrollSensorMouseControl.None ); // Add timeline @@ -747,6 +747,13 @@ export default class Tabs { } } + /** Switches the selected camera mode for the selected 2D field. */ + set2DCamera(index: number) { + if (this.tabList[this.selectedTab].type === TabType.Field2d) { + (this.tabList[this.selectedTab].renderer as Field2dRenderer).set2DCamera(index); + } + } + /** Switches the orbit FOV for the selected 3D field. */ setFov(fov: number) { if (this.tabList[this.selectedTab].type === TabType.Field3d) { diff --git a/src/hub/hub.ts b/src/hub/hub.ts index deb472c27..32ceba773 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -906,6 +906,10 @@ async function handleMainMessage(message: NamedMessage) { window.tabs.set3DCamera(message.data); break; + case "set-2d-camera": + window.tabs.set2DCamera(message.data); + break; + case "edit-fov": window.tabs.setFov(message.data); break; diff --git a/src/main/electron/main.ts b/src/main/electron/main.ts index a2ba1a5dd..a1a0448ec 100644 --- a/src/main/electron/main.ts +++ b/src/main/electron/main.ts @@ -37,6 +37,7 @@ import { AdvantageScopeAssets } from "../../shared/AdvantageScopeAssets"; import ButtonRect from "../../shared/ButtonRect"; import { ensureThemeContrast } from "../../shared/Colors"; import ExportOptions from "../../shared/ExportOptions"; +import { Field2dCameraMode } from "../../shared/Field2dCameraMode"; import LineGraphFilter from "../../shared/LineGraphFilter"; import NamedMessage from "../../shared/NamedMessage"; import Preferences, { DEFAULT_PREFS, getLiveModeName, LiveMode, mergePreferences } from "../../shared/Preferences"; @@ -1207,6 +1208,10 @@ async function handleHubMessage(window: BrowserWindow, message: NamedMessage) { ); break; + case "ask-2d-camera": + select2DCameraPopup(window, message.data.position, message.data.selectedIndex); + break; + case "export-console": dialog .showSaveDialog(window, { @@ -1512,6 +1517,45 @@ function select3DCameraPopup( }); } +function select2DCameraPopup(window: BrowserWindow, position: [number, number], selectedIndex: Field2dCameraMode) { + const cameraMenu = new Menu(); + cameraMenu.append( + new MenuItem({ + label: "Unlocked", + type: "checkbox", + checked: selectedIndex === Field2dCameraMode.Unlocked, + click() { + sendMessage(window, "set-2d-camera", Field2dCameraMode.Unlocked); + } + }) + ); + cameraMenu.append( + new MenuItem({ + label: "Locked to Robot", + type: "checkbox", + checked: selectedIndex === Field2dCameraMode.Robot, + click() { + sendMessage(window, "set-2d-camera", Field2dCameraMode.Robot); + } + }) + ); + cameraMenu.append( + new MenuItem({ + label: "Locked to Robot && Rotation", + type: "checkbox", + checked: selectedIndex === Field2dCameraMode.RobotAndRotation, + click() { + sendMessage(window, "set-2d-camera", Field2dCameraMode.RobotAndRotation); + } + }) + ); + cameraMenu.popup({ + window: window, + x: Math.round(position[0]), + y: Math.round(position[1]) + }); +} + /** * Process a message from a download window. * @param message The received message @@ -3087,7 +3131,8 @@ function createSatellite( let message: NamedMessage = event.data; switch (message.name) { case "set-aspect-ratio": - let aspectRatio = message.data; + let aspectRatio = message.data.aspectRatio; + let lock = message.data.lock; if (aspectRatio === null) { satellite.setAspectRatio(0); } else { @@ -3095,7 +3140,11 @@ function createSatellite( let originalArea = originalSize[0] * originalSize[1]; let newY = Math.sqrt(originalArea / aspectRatio); let newX = aspectRatio * newY; - satellite.setAspectRatio(aspectRatio); + if (lock) { + satellite.setAspectRatio(aspectRatio); + } else { + satellite.setAspectRatio(0); + } satellite.setContentSize(Math.round(newX), Math.round(newY)); } break; @@ -3111,6 +3160,10 @@ function createSatellite( ); break; + case "ask-2d-camera": + select2DCameraPopup(satellite, message.data.position, message.data.selectedIndex); + break; + case "add-table-range": hubWindows.forEach((window) => { sendMessage(window, "add-table-range", { diff --git a/src/main/lite/main.ts b/src/main/lite/main.ts index fab596f57..95a3e3915 100644 --- a/src/main/lite/main.ts +++ b/src/main/lite/main.ts @@ -10,6 +10,7 @@ import { AdvantageScopeAssets } from "../../shared/AdvantageScopeAssets"; import { BUILD_DATE, COPYRIGHT, Distribution, DISTRIBUTION, LITE_VERSION } from "../../shared/buildConstants"; import ButtonRect from "../../shared/ButtonRect"; import { ensureThemeContrast } from "../../shared/Colors"; +import { Field2dCameraMode } from "../../shared/Field2dCameraMode"; import { HubState } from "../../shared/HubState"; import LineGraphFilter from "../../shared/LineGraphFilter"; import NamedMessage from "../../shared/NamedMessage"; @@ -1205,6 +1206,35 @@ async function handleHubMessage(message: NamedMessage) { } break; + case "ask-2d-camera": + { + let position: [number, number] = message.data.position; + let selectedIndex: Field2dCameraMode = message.data.selectedIndex; + let menuItems: (MenuItem | Submenu | "-")[] = [ + { + content: (selectedIndex === Field2dCameraMode.Unlocked ? "\u2714 " : "") + "Unlocked", + callback() { + sendMessage(hubPort, "set-2d-camera", Field2dCameraMode.Unlocked); + } + }, + { + content: (selectedIndex === Field2dCameraMode.Robot ? "\u2714 " : "") + "Locked to Robot", + callback() { + sendMessage(hubPort, "set-2d-camera", Field2dCameraMode.Robot); + } + }, + { + content: + (selectedIndex === Field2dCameraMode.RobotAndRotation ? "\u2714 " : "") + "Locked to Robot && Rotation", + callback() { + sendMessage(hubPort, "set-2d-camera", Field2dCameraMode.RobotAndRotation); + } + } + ]; + openMenu({ x: position[0], y: position[1], width: 0, height: 0 }, menuItems); + } + break; + default: console.warn("Unknown message from hub", message); break; diff --git a/src/satellite.ts b/src/satellite.ts index 1ccca160b..61cd2b035 100644 --- a/src/satellite.ts +++ b/src/satellite.ts @@ -175,6 +175,12 @@ window.addEventListener("message", (event) => { } break; + case "set-2d-camera": + if (type === TabType.Field2d) { + (renderer as Field2dRenderer).set2DCamera(message.data); + } + break; + case "edit-fov": if (type === TabType.Field3d) { (renderer as Field3dRenderer).setFov(message.data); @@ -205,7 +211,7 @@ function processAspectRatio(aspectRatio: number | null) { if (aspectRatio > MAX_ASPECT_RATIO) aspectRatio = MAX_ASPECT_RATIO; if (aspectRatio < 1 / MAX_ASPECT_RATIO) aspectRatio = 1 / MAX_ASPECT_RATIO; } - window.sendMainMessage("set-aspect-ratio", aspectRatio); + window.sendMainMessage("set-aspect-ratio", { aspectRatio, lock: type !== TabType.Field2d }); } } diff --git a/src/shared/Field2dCameraMode.ts b/src/shared/Field2dCameraMode.ts new file mode 100644 index 000000000..a91d8332f --- /dev/null +++ b/src/shared/Field2dCameraMode.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2021-2026 Littleton Robotics +// http://github.com/Mechanical-Advantage +// +// Use of this source code is governed by a BSD +// license that can be found in the LICENSE file +// at the root directory of this project. + +export enum Field2dCameraMode { + Unlocked = 0, + Robot = 1, + RobotAndRotation = 2 +} diff --git a/src/shared/renderers/Field2dRenderer.ts b/src/shared/renderers/Field2dRenderer.ts index bf8289058..33e592acb 100644 --- a/src/shared/renderers/Field2dRenderer.ts +++ b/src/shared/renderers/Field2dRenderer.ts @@ -5,6 +5,8 @@ // license that can be found in the LICENSE file // at the root directory of this project. +import ScrollSensor, { ScrollSensorMouseControl } from "../../hub/ScrollSensor"; +import { Field2dCameraMode } from "../Field2dCameraMode"; import { AnnotatedPose2d, ModuleVelocity, Pose2d, Translation2d } from "../geometry"; import { Units } from "../units"; import { scaleValue, transformPx } from "../util"; @@ -16,6 +18,8 @@ export default class Field2dRenderer implements TabRenderer { private CANVAS: HTMLCanvasElement; private IMAGE: HTMLImageElement; private HEATMAP_CONTAINER: HTMLElement; + private SCROLL_CONTAINER: HTMLElement; + private scrollSensor: ScrollSensor; private heatmap: Heatmap; private lastImageSource = ""; @@ -23,6 +27,15 @@ export default class Field2dRenderer implements TabRenderer { private lastRenderState = ""; private imageLoadCount = 0; + private zoom = 1; + private pan: [number, number] = [0, 0]; + private trackingMode: Field2dCameraMode = Field2dCameraMode.Unlocked; + private jumpToRobot = false; + private lastTrackingMode: Field2dCameraMode = Field2dCameraMode.Unlocked; + private lastFieldCenter?: [number, number]; + private isFTC = false; + private lastCommand: Field2dRendererCommand | null = null; + constructor(root: HTMLElement) { this.CONTAINER = root.getElementsByClassName("field-2d-canvas-container")[0] as HTMLElement; this.CANVAS = root.getElementsByClassName("field-2d-canvas")[0] as HTMLCanvasElement; @@ -30,27 +43,188 @@ export default class Field2dRenderer implements TabRenderer { this.HEATMAP_CONTAINER = root.getElementsByClassName("field-2d-heatmap-container")[0] as HTMLElement; this.heatmap = new Heatmap(this.HEATMAP_CONTAINER); this.IMAGE.addEventListener("load", () => this.imageLoadCount++); + + this.SCROLL_CONTAINER = root.getElementsByClassName("field-2d-scroll")[0] as HTMLElement; + this.scrollSensor = new ScrollSensor( + this.SCROLL_CONTAINER, + (dx: number, dy: number, isPan: boolean, cursorX: number, cursorY: number) => { + if (!this.lastCommand) return; + let isVertical = + this.lastCommand.orientation === Orientation.DEG_90 || this.lastCommand.orientation === Orientation.DEG_270; + let containerWidth = this.CONTAINER.clientWidth; + let containerHeight = this.CONTAINER.clientHeight; + + let isSatellite = document.body.classList.contains("satellite"); + let visibleYOffset = isSatellite ? 0 : -13; + let boundingWidth = isVertical ? containerHeight - Math.abs(visibleYOffset * 2) : containerWidth; + let boundingHeight = isVertical ? containerWidth : containerHeight - Math.abs(visibleYOffset * 2); + + let fieldData = window.assets?.field2ds.find((field) => field.id === this.lastCommand?.field); + if (!fieldData) return; + + let fieldWidth = fieldData.bottomRight[0] - fieldData.topLeft[0]; + let fieldHeight = fieldData.bottomRight[1] - fieldData.topLeft[1]; + let topMargin = fieldData.topLeft[1]; + let bottomMargin = this.IMAGE.height - fieldData.bottomRight[1]; + let leftMargin = fieldData.topLeft[0]; + let rightMargin = this.IMAGE.width - fieldData.bottomRight[0]; + let margin = Math.min(topMargin, bottomMargin, leftMargin, rightMargin); + let extendedFieldWidth = fieldWidth + margin * 2; + let extendedFieldHeight = fieldHeight + margin * 2; + + let constrainHeight = boundingWidth / boundingHeight > extendedFieldWidth / extendedFieldHeight; + let baseImageScalar = constrainHeight + ? boundingHeight / extendedFieldHeight + : boundingWidth / extendedFieldWidth; + + let canvasCenterX = cursorX - containerWidth / 2; + let canvasCenterY = cursorY - containerHeight / 2; + + if (isPan) { + this.pan[0] += dx / (baseImageScalar * this.zoom); + this.pan[1] += dy / (baseImageScalar * this.zoom); + } else { + // Zoom + let zoomChange = dy * -0.005; + let oldZoom = this.zoom; + this.zoom = Math.min(10, Math.max(1, this.zoom + zoomChange)); + + if (this.zoom !== oldZoom) { + this.pan[0] += (canvasCenterX / baseImageScalar) * (1 / oldZoom - 1 / this.zoom); + this.pan[1] += (canvasCenterY / baseImageScalar) * (1 / oldZoom - 1 / this.zoom); + } + } + + window.requestAnimationFrame(() => { + if (this.lastCommand) this.render(this.lastCommand); + }); + }, + ScrollSensorMouseControl.PanXY + ); + + // Context menu for camera mode + let startPx: [number, number] | null = null; + this.SCROLL_CONTAINER.addEventListener("mousedown", (event) => { + if (event.button === 2) { + startPx = [event.clientX, event.clientY]; + } + }); + this.SCROLL_CONTAINER.addEventListener("mouseup", (event) => { + if (startPx && event.clientX === startPx[0] && event.clientY === startPx[1]) { + window.sendMainMessage("ask-2d-camera", { + position: [event.clientX, event.clientY], + selectedIndex: this.trackingMode + }); + } + startPx = null; + }); + } + + set2DCamera(index: number) { + if (index >= 0 && index <= 2) { + this.setTrackingMode(index as Field2dCameraMode); + if (this.lastCommand) { + this.render(this.lastCommand); + } + } } saveState(): unknown { - return null; + return { + zoom: this.zoom, + pan: this.pan, + trackingMode: this.trackingMode, + isFTC: this.isFTC + }; } - restoreState(state: unknown): void {} + restoreState(state: unknown): void { + if (typeof state !== "object" || state === null) return; + + if ("zoom" in state && typeof state.zoom === "number") { + this.zoom = state.zoom; + } + if ("pan" in state && Array.isArray(state.pan) && state.pan.length === 2) { + this.pan = state.pan as [number, number]; + } + if ("trackingMode" in state && typeof state.trackingMode === "number") { + this.setTrackingMode(state.trackingMode as Field2dCameraMode); + } + if ("isFTC" in state && typeof state.isFTC === "boolean") { + this.isFTC = state.isFTC; + } + } + + setTrackingMode(mode: Field2dCameraMode) { + if (mode !== Field2dCameraMode.Unlocked) { + this.jumpToRobot = true; + } + this.trackingMode = mode; + } getAspectRatio(): number | null { return this.aspectRatio; } render(command: Field2dRendererCommand): void { + this.scrollSensor.periodic(); + this.lastCommand = command; + + // Get field data + let fieldData = window.assets?.field2ds.find((field) => field.id === command.field); + if (!fieldData) return; + + if (fieldData.isFTC !== this.isFTC) { + this.isFTC = fieldData.isFTC; + this.zoom = 1; + this.pan = [0, 0]; + this.trackingMode = 0; + } + + // Find primary robot pose + let primaryRobotPose: Pose2d | null = null; + let primaryRobotObj = command.objects.find((obj) => obj.type === "robot") as Field2dRendererCommand_RobotObj; + if (primaryRobotObj && primaryRobotObj.poses.length > 0) { + primaryRobotPose = primaryRobotObj.poses[0].pose; + } + // Get setup let context = this.CANVAS.getContext("2d") as CanvasRenderingContext2D; + let isVertical = command.orientation === Orientation.DEG_90 || command.orientation === Orientation.DEG_270; + let isSatellite = document.body.classList.contains("satellite"); + let visibleYOffset = isSatellite ? 0 : -13; + let width = isVertical ? this.CONTAINER.clientHeight : this.CONTAINER.clientWidth; let height = isVertical ? this.CONTAINER.clientWidth : this.CONTAINER.clientHeight; + let boundingWidth = isVertical + ? this.CONTAINER.clientHeight - Math.abs(visibleYOffset * 2) + : this.CONTAINER.clientWidth; + let boundingHeight = isVertical + ? this.CONTAINER.clientWidth + : this.CONTAINER.clientHeight - Math.abs(visibleYOffset * 2); + + if (this.trackingMode === Field2dCameraMode.RobotAndRotation) { + let maxDim = Math.hypot(this.CONTAINER.clientWidth, this.CONTAINER.clientHeight); + width = maxDim; + height = maxDim; + boundingWidth = maxDim; + boundingHeight = maxDim; + } + // Exit if render state unchanged - let renderState: any[] = [width, height, window.devicePixelRatio, command, this.imageLoadCount]; + let renderState: any[] = [ + width, + height, + window.devicePixelRatio, + command, + this.imageLoadCount, + this.zoom, + this.pan[0], + this.pan[1], + this.trackingMode + ]; let renderStateString = JSON.stringify(renderState); if (renderStateString === this.lastRenderState) { return; @@ -68,24 +242,29 @@ export default class Field2dRenderer implements TabRenderer { context.lineJoin = "round"; // Set canvas transform + let canvasRotation = 0; switch (command.orientation) { case Orientation.DEG_0: - this.CANVAS.style.transform = "translate(-50%, -50%) rotate(0deg)"; + canvasRotation = 0; break; case Orientation.DEG_90: - this.CANVAS.style.transform = "translate(-50%, -50%) rotate(-90deg)"; + canvasRotation = -Math.PI / 2; break; case Orientation.DEG_180: - this.CANVAS.style.transform = "translate(-50%, -50%) rotate(180deg)"; + canvasRotation = Math.PI; break; case Orientation.DEG_270: - this.CANVAS.style.transform = "translate(-50%, -50%) rotate(90deg)"; + canvasRotation = Math.PI / 2; break; } - // Get field data and update image element - let fieldData = window.assets?.field2ds.find((field) => field.id === command.field); - if (!fieldData) return; + if (this.trackingMode === Field2dCameraMode.RobotAndRotation && primaryRobotPose) { + canvasRotation += primaryRobotPose.rotation; + } + + this.CANVAS.style.transform = `translate(-50%, -50%) rotate(${canvasRotation}rad)`; + + // Update image element if (fieldData.path !== this.lastImageSource) { this.lastImageSource = fieldData.path; this.IMAGE.src = fieldData.path; @@ -95,6 +274,47 @@ export default class Field2dRenderer implements TabRenderer { // Render background let fieldWidth = fieldData.bottomRight[0] - fieldData.topLeft[0]; let fieldHeight = fieldData.bottomRight[1] - fieldData.topLeft[1]; + let fieldCenterX = fieldWidth * 0.5 + fieldData.topLeft[0]; + let fieldCenterY = fieldHeight * 0.5 + fieldData.topLeft[1]; + + let targetFieldX = fieldCenterX; + let targetFieldY = fieldCenterY; + if (this.trackingMode > Field2dCameraMode.Unlocked && primaryRobotPose) { + let positionInches = [ + Units.convert(primaryRobotPose.translation[0], "meters", "inches"), + Units.convert(primaryRobotPose.translation[1], "meters", "inches") + ]; + targetFieldX = scaleValue( + positionInches[0], + [-fieldData.widthInches / 2, fieldData.widthInches / 2], + [fieldData.topLeft[0], fieldData.topLeft[0] + fieldWidth] + ); + targetFieldY = scaleValue( + positionInches[1], + [-fieldData.heightInches / 2, fieldData.heightInches / 2], + [fieldData.topLeft[1] + fieldHeight, fieldData.topLeft[1]] + ); + } + + if (this.jumpToRobot) { + this.pan = [0, 0]; + this.jumpToRobot = false; + } else if ( + this.lastTrackingMode !== this.trackingMode && + this.trackingMode === Field2dCameraMode.Unlocked && + this.lastFieldCenter + ) { + let dx = this.lastFieldCenter[0] - targetFieldX; + let dy = this.lastFieldCenter[1] - targetFieldY; + this.pan[0] = dx * Math.cos(canvasRotation) - dy * Math.sin(canvasRotation); + this.pan[1] = dx * Math.sin(canvasRotation) + dy * Math.cos(canvasRotation); + } + this.lastTrackingMode = this.trackingMode; + + let panFieldX = this.pan[0] * Math.cos(-canvasRotation) - this.pan[1] * Math.sin(-canvasRotation); + let panFieldY = this.pan[0] * Math.sin(-canvasRotation) + this.pan[1] * Math.cos(-canvasRotation); + fieldCenterX = targetFieldX + panFieldX; + fieldCenterY = targetFieldY + panFieldY; let topMargin = fieldData.topLeft[1]; let bottomMargin = this.IMAGE.height - fieldData.bottomRight[1]; @@ -104,24 +324,62 @@ export default class Field2dRenderer implements TabRenderer { let margin = Math.min(topMargin, bottomMargin, leftMargin, rightMargin); let extendedFieldWidth = fieldWidth + margin * 2; let extendedFieldHeight = fieldHeight + margin * 2; - let constrainHeight = width / height > extendedFieldWidth / extendedFieldHeight; - let imageScalar: number; - if (constrainHeight) { - imageScalar = height / extendedFieldHeight; - } else { - imageScalar = width / extendedFieldWidth; + let constrainHeight = boundingWidth / boundingHeight > extendedFieldWidth / extendedFieldHeight; + let baseImageScalar = constrainHeight ? boundingHeight / extendedFieldHeight : boundingWidth / extendedFieldWidth; + let imageScalar = baseImageScalar * this.zoom; + + if (this.trackingMode === Field2dCameraMode.Unlocked) { + let fieldDisplayWidth = extendedFieldWidth * imageScalar; + let fieldDisplayHeight = extendedFieldHeight * imageScalar; + + let panOffsetX = (Math.sin(canvasRotation) * visibleYOffset) / imageScalar; + let panOffsetY = (Math.cos(canvasRotation) * visibleYOffset) / imageScalar; + + let marginX = Math.max(0, (fieldDisplayWidth - boundingWidth) * 0.5) / imageScalar; + let minPanX = -marginX + panOffsetX; + let maxPanX = marginX + panOffsetX; + + let marginY = Math.max(0, (fieldDisplayHeight - boundingHeight) * 0.5) / imageScalar; + let minPanY = -marginY + panOffsetY; + let maxPanY = marginY + panOffsetY; + + panFieldX = Math.max(minPanX, Math.min(maxPanX, panFieldX)); + panFieldY = Math.max(minPanY, Math.min(maxPanY, panFieldY)); + + fieldCenterX = targetFieldX + panFieldX; + fieldCenterY = targetFieldY + panFieldY; + + this.pan[0] = panFieldX * Math.cos(canvasRotation) - panFieldY * Math.sin(canvasRotation); + this.pan[1] = panFieldX * Math.sin(canvasRotation) + panFieldY * Math.cos(canvasRotation); } - let fieldCenterX = fieldWidth * 0.5 + fieldData.topLeft[0]; - let fieldCenterY = fieldHeight * 0.5 + fieldData.topLeft[1]; - let renderValues = [ - Math.floor(width * 0.5 - fieldCenterX * imageScalar), // X (normal) - Math.floor(height * 0.5 - fieldCenterY * imageScalar), // Y (normal) - Math.ceil(width * -0.5 - fieldCenterX * imageScalar), // X (flipped) - Math.ceil(height * -0.5 - fieldCenterY * imageScalar), // Y (flipped) - this.IMAGE.width * imageScalar, // Width - this.IMAGE.height * imageScalar // Height + + this.lastFieldCenter = [fieldCenterX, fieldCenterY]; + + let renderValues: [number, number, number, number] = [ + width * 0.5 - fieldCenterX * imageScalar, + height * 0.5 - fieldCenterY * imageScalar, + this.IMAGE.width * imageScalar, + this.IMAGE.height * imageScalar ]; - context.drawImage(this.IMAGE, renderValues[0], renderValues[1], renderValues[4], renderValues[5]); + + // Draw field image + { + let imgDx = renderValues[0]; + let imgDy = renderValues[1]; + let imgDw = renderValues[2]; + let imgDh = renderValues[3]; + let drawL = Math.max(imgDx, 0); + let drawT = Math.max(imgDy, 0); + let drawR = Math.min(imgDx + imgDw, width); + let drawB = Math.min(imgDy + imgDh, height); + if (drawR > drawL && drawB > drawT) { + let sx = ((drawL - imgDx) / imgDw) * this.IMAGE.width; + let sy = ((drawT - imgDy) / imgDh) * this.IMAGE.height; + let sWidth = ((drawR - drawL) / imgDw) * this.IMAGE.width; + let sHeight = ((drawB - drawT) / imgDh) * this.IMAGE.height; + context.drawImage(this.IMAGE, sx, sy, sWidth, sHeight, drawL, drawT, drawR - drawL, drawB - drawT); + } + } this.aspectRatio = isVertical ? fieldHeight / fieldWidth : fieldWidth / fieldHeight; // Calculate field edges @@ -291,7 +549,7 @@ export default class Field2dRenderer implements TabRenderer { }); this.heatmap.update( heatmapTranslations, - [canvasFieldWidth, canvasFieldHeight], + [fieldWidth, fieldHeight], [ Units.convert(fieldData.widthInches, "inches", "meters"), Units.convert(fieldData.heightInches, "inches", "meters") @@ -299,7 +557,7 @@ export default class Field2dRenderer implements TabRenderer { ); let heatmapCanvas = this.heatmap.getCanvas(); if (heatmapCanvas !== null) { - context.drawImage(heatmapCanvas, canvasFieldLeft, canvasFieldTop); + context.drawImage(heatmapCanvas, canvasFieldLeft, canvasFieldTop, canvasFieldWidth, canvasFieldHeight); } // Draw objects diff --git a/src/shared/renderers/LineGraphRenderer.ts b/src/shared/renderers/LineGraphRenderer.ts index f008b5214..429454ff5 100644 --- a/src/shared/renderers/LineGraphRenderer.ts +++ b/src/shared/renderers/LineGraphRenderer.ts @@ -97,23 +97,26 @@ export default class LineGraphRenderer implements TabRenderer { this.HELP_OVERLAY = root.getElementsByClassName("line-graph-help")[0] as HTMLElement; // Scroll handling - this.scrollSensor = new ScrollSensor(this.SCROLL_OVERLAY, (dx: number, dy: number) => { - if (root.hidden) return; - window.selection.applyTimelineScroll(dx, dy, this.SCROLL_OVERLAY.clientWidth); - - if (window.preferences && !window.preferences.hasScrolledLineGraph && !this.isFadingOut) { - if (dy < 0) { - this.zoomInAccumulator += dy; - if (this.zoomInAccumulator <= -200) { - this.isFadingOut = true; - this.HELP_OVERLAY.classList.add("fading-out"); - setTimeout(() => { - window.sendMainMessage("update-preferences", { hasScrolledLineGraph: true }); - }, 1000); + this.scrollSensor = new ScrollSensor( + this.SCROLL_OVERLAY, + (dx: number, dy: number, isPan: boolean, _cursorX: number, _cursorY: number) => { + if (root.hidden) return; + window.selection.applyTimelineScroll(dx, isPan ? 0 : dy, this.SCROLL_OVERLAY.clientWidth); + + if (window.preferences && !window.preferences.hasScrolledLineGraph && !this.isFadingOut) { + if (dy < 0) { + this.zoomInAccumulator += dy; + if (this.zoomInAccumulator <= -200) { + this.isFadingOut = true; + this.HELP_OVERLAY.classList.add("fading-out"); + setTimeout(() => { + window.sendMainMessage("update-preferences", { hasScrolledLineGraph: true }); + }, 1000); + } } } } - }); + ); } getAspectRatio(): number | null { diff --git a/www/hub.html b/www/hub.html index 5ec41d54d..9c363849e 100644 --- a/www/hub.html +++ b/www/hub.html @@ -513,6 +513,9 @@
+
+
+
diff --git a/www/renderers.css b/www/renderers.css index 8f00f656a..a159ccd78 100644 --- a/www/renderers.css +++ b/www/renderers.css @@ -126,9 +126,14 @@ img.line-graph-help-icon { div.field-2d-canvas-container { position: absolute; - top: 0%; + bottom: 0%; left: 0%; width: 100%; + height: calc(100% + 26px); + overflow: hidden; +} + +body.satellite div.field-2d-canvas-container { height: 100%; } @@ -139,6 +144,29 @@ canvas.field-2d-canvas { transform: translate(-50%, -50%); } +div.field-2d-scroll { + position: absolute; + left: 0px; + right: 0px; + bottom: 0px; + height: calc(100% + 26px); + overflow: scroll; + scrollbar-width: none; /* Required for Firefox */ +} + +body.satellite div.field-2d-scroll { + height: 100%; +} + +div.field-2d-scroll::-webkit-scrollbar { + display: none; +} + +div.field-2d-scroll-content { + width: 1000000px; + height: 1000000px; +} + /* 3D Field */ div.field-3d-canvas-container { diff --git a/www/satellite.html b/www/satellite.html index 3031de495..bf74e0e5f 100644 --- a/www/satellite.html +++ b/www/satellite.html @@ -60,6 +60,9 @@
+
+
+
From 73eea29177be06f1b3e9530f06c0095ad4f0fa8e Mon Sep 17 00:00:00 2001 From: Jonah <47046556+jwbonner@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:30:25 -0400 Subject: [PATCH 2/5] Fix space shortcut and locked panning --- src/hub/SelectionImpl.ts | 8 +++++++- src/hub/controllers/VideoController.ts | 4 +++- src/satellite.ts | 8 +++++++- src/shared/renderers/ConsoleRenderer.ts | 10 +++++++++- src/shared/renderers/Field2dRenderer.ts | 6 ++++++ src/shared/renderers/Field3dRendererImpl.ts | 16 ++++++++++++++-- 6 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/hub/SelectionImpl.ts b/src/hub/SelectionImpl.ts index 67356a74f..f6a2743a8 100644 --- a/src/hub/SelectionImpl.ts +++ b/src/hub/SelectionImpl.ts @@ -58,7 +58,13 @@ export default class SelectionImpl implements Selection { }); window.addEventListener("keydown", (event) => { - if (event.target !== document.body && event.target !== window) return; + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) + ) { + return; + } switch (event.code) { case "Space": event.preventDefault(); diff --git a/src/hub/controllers/VideoController.ts b/src/hub/controllers/VideoController.ts index 48c8af141..37c03ff9f 100644 --- a/src/hub/controllers/VideoController.ts +++ b/src/hub/controllers/VideoController.ts @@ -181,7 +181,9 @@ export default class VideoController implements TabController { if ( root === null || root.hidden || - event.target !== document.body || + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) || (window.platform === "darwin" ? event.metaKey : event.ctrlKey) ) return; diff --git a/src/satellite.ts b/src/satellite.ts index 61cd2b035..f0833e876 100644 --- a/src/satellite.ts +++ b/src/satellite.ts @@ -226,7 +226,13 @@ window.addEventListener("beforeunload", () => { }); window.addEventListener("keydown", (event) => { - if (event.target !== document.body) return; + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) + ) { + return; + } switch (event.code) { case "Space": event.preventDefault(); diff --git a/src/shared/renderers/ConsoleRenderer.ts b/src/shared/renderers/ConsoleRenderer.ts index 0dc6a2a8a..d1a15acd6 100644 --- a/src/shared/renderers/ConsoleRenderer.ts +++ b/src/shared/renderers/ConsoleRenderer.ts @@ -124,7 +124,15 @@ export default class ConsoleRenderer implements TabRenderer { // Select filter window.addEventListener("keydown", (event) => { - if (root === null || root.hidden || (event.target !== document.body && event.target !== window)) return; + if ( + root === null || + root.hidden || + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) + ) { + return; + } if ((window.platform === "darwin" ? event.metaKey : event.ctrlKey) && event.key === "f") { this.FILTER_INPUT.select(); } diff --git a/src/shared/renderers/Field2dRenderer.ts b/src/shared/renderers/Field2dRenderer.ts index 33e592acb..401f0b24a 100644 --- a/src/shared/renderers/Field2dRenderer.ts +++ b/src/shared/renderers/Field2dRenderer.ts @@ -59,6 +59,12 @@ export default class Field2dRenderer implements TabRenderer { let boundingWidth = isVertical ? containerHeight - Math.abs(visibleYOffset * 2) : containerWidth; let boundingHeight = isVertical ? containerWidth : containerHeight - Math.abs(visibleYOffset * 2); + if (this.trackingMode === Field2dCameraMode.RobotAndRotation) { + let maxDim = Math.hypot(containerWidth, containerHeight); + boundingWidth = maxDim; + boundingHeight = maxDim; + } + let fieldData = window.assets?.field2ds.find((field) => field.id === this.lastCommand?.field); if (!fieldData) return; diff --git a/src/shared/renderers/Field3dRendererImpl.ts b/src/shared/renderers/Field3dRendererImpl.ts index 80edc1bbd..08d49f470 100644 --- a/src/shared/renderers/Field3dRendererImpl.ts +++ b/src/shared/renderers/Field3dRendererImpl.ts @@ -272,12 +272,24 @@ export default class Field3dRendererImpl implements TabRenderer { // Create key bindings window.addEventListener("keydown", (event) => { if (window.platform === "darwin" ? event.metaKey : event.ctrlKey) return; - if (event.target !== document.body && event.target !== window) return; + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) + ) { + return; + } if (canvasContainer.clientHeight === 0) return; this.keysPressed.add(event.code); }); window.addEventListener("keyup", (event) => { - if (event.target !== document.body && event.target !== window) return; + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) + ) { + return; + } this.keysPressed.delete(event.code); }); } From 27737e352c4d70ac99fc0c4eda0fe3a004fe3dda Mon Sep 17 00:00:00 2001 From: Jonah <47046556+jwbonner@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:34:36 -0400 Subject: [PATCH 3/5] Disable smoothing for 2D field image --- src/shared/renderers/Field2dRenderer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/renderers/Field2dRenderer.ts b/src/shared/renderers/Field2dRenderer.ts index 401f0b24a..6aa196b62 100644 --- a/src/shared/renderers/Field2dRenderer.ts +++ b/src/shared/renderers/Field2dRenderer.ts @@ -383,6 +383,7 @@ export default class Field2dRenderer implements TabRenderer { let sy = ((drawT - imgDy) / imgDh) * this.IMAGE.height; let sWidth = ((drawR - drawL) / imgDw) * this.IMAGE.width; let sHeight = ((drawB - drawT) / imgDh) * this.IMAGE.height; + context.imageSmoothingEnabled = false; context.drawImage(this.IMAGE, sx, sy, sWidth, sHeight, drawL, drawT, drawR - drawL, drawB - drawT); } } From c6b9c8b82ceac3c9da18de874adc317ebd7a16e3 Mon Sep 17 00:00:00 2001 From: Jonah <47046556+jwbonner@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:40:28 -0400 Subject: [PATCH 4/5] Fix menu option in Lite --- src/main/lite/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/lite/main.ts b/src/main/lite/main.ts index 95a3e3915..11b8b6fc6 100644 --- a/src/main/lite/main.ts +++ b/src/main/lite/main.ts @@ -1225,7 +1225,7 @@ async function handleHubMessage(message: NamedMessage) { }, { content: - (selectedIndex === Field2dCameraMode.RobotAndRotation ? "\u2714 " : "") + "Locked to Robot && Rotation", + (selectedIndex === Field2dCameraMode.RobotAndRotation ? "\u2714 " : "") + "Locked to Robot & Rotation", callback() { sendMessage(hubPort, "set-2d-camera", Field2dCameraMode.RobotAndRotation); } From 8514562bcd989850696c5728e140f4a19c8cceae Mon Sep 17 00:00:00 2001 From: Jonah <47046556+jwbonner@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:43:53 -0400 Subject: [PATCH 5/5] Fix max dimensions --- src/shared/renderers/Field2dRenderer.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/shared/renderers/Field2dRenderer.ts b/src/shared/renderers/Field2dRenderer.ts index 6aa196b62..73c69df26 100644 --- a/src/shared/renderers/Field2dRenderer.ts +++ b/src/shared/renderers/Field2dRenderer.ts @@ -59,12 +59,6 @@ export default class Field2dRenderer implements TabRenderer { let boundingWidth = isVertical ? containerHeight - Math.abs(visibleYOffset * 2) : containerWidth; let boundingHeight = isVertical ? containerWidth : containerHeight - Math.abs(visibleYOffset * 2); - if (this.trackingMode === Field2dCameraMode.RobotAndRotation) { - let maxDim = Math.hypot(containerWidth, containerHeight); - boundingWidth = maxDim; - boundingHeight = maxDim; - } - let fieldData = window.assets?.field2ds.find((field) => field.id === this.lastCommand?.field); if (!fieldData) return; @@ -215,8 +209,6 @@ export default class Field2dRenderer implements TabRenderer { let maxDim = Math.hypot(this.CONTAINER.clientWidth, this.CONTAINER.clientHeight); width = maxDim; height = maxDim; - boundingWidth = maxDim; - boundingHeight = maxDim; } // Exit if render state unchanged