diff --git a/docs/_toc.yml b/docs/_toc.yml index dba5c7e0..b4d283d0 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -4,6 +4,7 @@ parts: - caption: User Guide chapters: - file: usage/overview + - file: usage/keyboard-shortcuts - file: usage/rapid-building-assessment - file: usage/damage-mapping - file: usage/building-blocks diff --git a/docs/usage/damage-mapping.md b/docs/usage/damage-mapping.md index 3ce0cc92..41eefefb 100644 --- a/docs/usage/damage-mapping.md +++ b/docs/usage/damage-mapping.md @@ -57,8 +57,8 @@ labels, or the arrow next to **Save** to save and start training in one step. To make damage easier to see while you label, tune the view of the pre-/post-event imagery: **Opacity**, **Contrast**, **Hue Rotation**, and **Saturation** sliders (with **Reset**). -Toggle between post- and pre-event imagery with the imagery switch or `Ctrl+Alt+C` — if you -didn't upload pre-event imagery, the tool falls back to the Azure Basemap. +Toggle between post- and pre-event imagery with the imagery switch. Press `A` for pre-event +(or the Azure Basemap fallback) and `D` for post-event imagery. ![Labeling imagery properties panel](../_static/usage/labeling/labeling-imagery-properties.png) @@ -138,7 +138,8 @@ use the predictions. control, the predicted damage layer overlaid on both, and an optional **raw predictions** layer you can toggle on. Imagery sliders (opacity, contrast, hue, saturation) and keyboard shortcuts — `A` (all pre-event), `S` (split), `D` (all post-event) — help you inspect and -share the result. +share the result. See {doc}`Keyboard shortcuts ` for the complete +reference. ![The raw per-pixel predictions layer toggled on](../_static/usage/results/raw-predictions-layer.png) diff --git a/docs/usage/keyboard-shortcuts.md b/docs/usage/keyboard-shortcuts.md new file mode 100644 index 00000000..c69c062d --- /dev/null +++ b/docs/usage/keyboard-shortcuts.md @@ -0,0 +1,66 @@ +# Keyboard Shortcuts + +HASTE provides keyboard shortcuts for imagery comparison, labeling, and +building validation. Each supported view also includes a collapsible +**Keyboard shortcuts** section. + +## Contents + +- [General behavior](#general-behavior) +- [Results viewer](#results-viewer) +- [Interactive Labeler](#interactive-labeler) +- [Labeling Tool](#labeling-tool) +- [Building Validation](#building-validation) + +## General Behavior + +Letter shortcuts are not case-sensitive. HASTE ignores global shortcuts while +you type in a field or interact with a button, link, dropdown, or switch. + +The imagery comparison keys use the same direction across views: + +- `A` shows pre-event imagery or moves a swipe divider left. +- `S` centers a swipe divider when the view supports split comparison. +- `D` shows post-event imagery or moves a swipe divider right. + +## Results Viewer + +| Shortcut | Action | +|---|---| +| `A` | Move the swipe divider left to show pre-event imagery. | +| `S` | Center the swipe divider for an even comparison. | +| `D` | Move the swipe divider right to show post-event imagery. | + +## Interactive Labeler + +| Shortcut | Action | +|---|---| +| `1` | Select **Intact**. | +| `2` | Select **Damaged**. | +| `3` | Select **Cloudy**. | +| `T` | Cycle through the available classes. | +| `P` | Toggle between labeled and predicted views when the model can train. | +| `Space` | Show or hide building footprints. | +| `Ctrl` + drag | Box-label buildings. | +| `A` | Move the enabled swipe divider left. | +| `S` | Center the enabled swipe divider. | +| `D` | Move the enabled swipe divider right. | + +## Labeling Tool + +| Shortcut | Action | +|---|---| +| `A` | Show pre-event imagery, or the basemap when pre-event imagery is unavailable. | +| `D` | Show post-event imagery. | + +## Building Validation + +| Shortcut | Action | +|---|---| +| `1` | Label the selected building **Damaged**. | +| `2` | Label the selected building **Not Damaged**. | +| `3` | Label the selected building **Unknown**. | +| `Left Arrow` | Select the previous building in the current filter. | +| `Right Arrow` | Select the next building in the current filter. | +| `A` | Show pre-event imagery, or the basemap when pre-event imagery is unavailable. | +| `D` | Show post-event imagery. | diff --git a/docs/usage/overview.md b/docs/usage/overview.md index 24f38bb3..ec4e9cf3 100644 --- a/docs/usage/overview.md +++ b/docs/usage/overview.md @@ -37,3 +37,5 @@ continuous, pixel-level damage raster. Both workflows start the same way — create a project and add an image layer — then diverge. Those shared building blocks have their own pages: {doc}`Projects `, {doc}`Image layers `, and the {doc}`Model catalog `. +See {doc}`Keyboard shortcuts ` for the controls available +across map and labeling views. diff --git a/docs/usage/rapid-building-assessment.md b/docs/usage/rapid-building-assessment.md index b499ca57..f4cbcf74 100644 --- a/docs/usage/rapid-building-assessment.md +++ b/docs/usage/rapid-building-assessment.md @@ -83,9 +83,11 @@ panel with the class selector, counts, a view toggle, and quality metrics. - **Classes:** **Intact** (green), **Damaged** (red), **Cloudy** (purple) for obscured buildings; unlabeled buildings are gray. - **Shortcuts:** `1`/`2`/`3` pick a class, `T` cycles, `P` toggles Labeled/Predicted view, - `Space` shows/hides footprints. + `Space` shows/hides footprints, and `Ctrl+drag` box-labels buildings. Swipe comparison is + on by default; `A`/`S`/`D` move its divider left/to an even split/right. + See {doc}`Keyboard shortcuts ` for the complete reference. -Once you've labeled at least **3 buildings across 2+ classes**, an in-browser model +Once you've labeled at least **3 buildings in each of 2+ classes**, an in-browser model (logistic regression, WebGPU-accelerated when available) trains automatically and predicts damage for every building in view. Toggle **View: Labeled ↔ Predicted** to compare. The panel shows holdout **precision / recall / F1 for the Damaged class** so you can watch @@ -93,6 +95,11 @@ quality improve as you label diverse examples. ![The Interactive Labeler in Predicted view, with holdout metrics](../_static/usage/interactive/labeler-predicted.png) +Under **Advanced**, **Show misclassified buildings** trains or reuses the current in-browser +model and highlights only human-labeled buildings whose current prediction differs. Correctly +classified and unlabeled buildings are not highlighted. This view is mutually exclusive with +Predicted and Uncertainty views and turns off if labels fall below the training threshold. + ## Step 3 — Predict all buildings - **Save labels** persists your manual labels so you can resume later (no full prediction). @@ -115,7 +122,8 @@ sample of footprints (~200 by default) with the pre-/post-event imagery. - Select a building and label it **Damaged**, **Not Damaged**, or **Unknown**. - **Shortcuts:** `1`/`2`/`3` to label; arrow keys to move Prev/Next (auto-advances to the - next unlabeled). Filter by label status; a progress bar tracks your coverage. + next unlabeled); `A` shows pre-event imagery (or basemap) and `D` shows post-event imagery. + Filter by label status; a progress bar tracks your coverage. - **Save Labels** to persist, or **Download GeoJSON** to export the labeled sample. These human labels are the **ground truth** the reports compare predictions against diff --git a/ui/src/AppContext.jsx b/ui/src/AppContext.jsx index 1e454762..c9c12d92 100644 --- a/ui/src/AppContext.jsx +++ b/ui/src/AppContext.jsx @@ -140,7 +140,7 @@ export const AppProvider = ({ children }) => { target: `#postEventImagery`, title: "Imagery toggle", content: - "Click on the toggle to switch between post event and pre event imagery. If you did not upload pre event imagery, the tool will default to Azure Basemap. You can also use the keyboard shortcut - Ctrl+Alt+c", + "Click the toggle to switch imagery. Press A for pre event (or Azure Basemap) and D for post event.", }, { type: "teachingBubble", diff --git a/ui/src/Components/BuildingValidation/BuildingValidation.jsx b/ui/src/Components/BuildingValidation/BuildingValidation.jsx index 05fcea25..3ba38b89 100644 --- a/ui/src/Components/BuildingValidation/BuildingValidation.jsx +++ b/ui/src/Components/BuildingValidation/BuildingValidation.jsx @@ -311,9 +311,11 @@ const BuildingValidation = () => { } }, [showFill, isMapReady]); - // Toggle post-event imagery layer so the user can compare the - // post-event view against the basemap satellite underneath. + // A single-map comparison: post-event on, or pre-event/basemap when off. useEffect(() => { + if (preImageryRef.current) { + preImageryRef.current.setOptions({ visible: !showPostImagery }); + } if (postImageryRef.current) { postImageryRef.current.setOptions({ visible: showPostImagery }); } @@ -487,12 +489,14 @@ const BuildingValidation = () => { // Keyboard shortcuts: // 1 / 2 / 3 — assign Damaged / NotDamaged / Unknown // ArrowLeft / ArrowRight — move through the filtered list + // A / D — show pre (or basemap) / post imagery // The right-panel toggles and dropdown remain focusable; the - // INPUT/TEXTAREA/SELECT guard keeps shortcuts from hijacking typing. + // editable-target guard keeps shortcuts from hijacking typing. useEffect(() => { const keyMap = { "1": "Damaged", "2": "NotDamaged", "3": "Unknown" }; function onKeyDown(e) { - if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return; + if (shouldIgnoreShortcut(e)) return; + if (e.ctrlKey || e.altKey || e.metaKey) return; const labelValue = keyMap[e.key]; if (labelValue) { handleLabel(labelValue); @@ -504,6 +508,10 @@ const BuildingValidation = () => { } else if (e.key === "ArrowRight") { e.preventDefault(); navigateInFilter(1); + } else { + const key = e.key.toLowerCase(); + if (key === "a") setShowPostImagery(false); + else if (key === "d") setShowPostImagery(true); } } window.addEventListener("keydown", onKeyDown); @@ -596,6 +604,7 @@ const BuildingValidation = () => { setShowFill={setShowFill} showPostImagery={showPostImagery} setShowPostImagery={setShowPostImagery} + hasPreImagery={!!preImageryRef.current} hasPostImagery={!!postImageryRef.current} /> )} diff --git a/ui/src/Components/BuildingValidation/BuildingValidationRightPanel.jsx b/ui/src/Components/BuildingValidation/BuildingValidationRightPanel.jsx index 1bcdc932..7de3eb9a 100644 --- a/ui/src/Components/BuildingValidation/BuildingValidationRightPanel.jsx +++ b/ui/src/Components/BuildingValidation/BuildingValidationRightPanel.jsx @@ -10,6 +10,8 @@ import { makeStyles, tokens, } from "@fluentui/react-components"; +import KeyboardShortcutHelp from "../KeyboardShortcutHelp"; +import { BUILDING_VALIDATION_SHORTCUTS } from "../keyboardShortcuts"; const LABEL_OPTIONS = [ { value: "Damaged", label: "Damaged (1)", color: "#C50F1F" }, @@ -143,6 +145,7 @@ const BuildingValidationRightPanel = ({ setShowFill, showPostImagery, setShowPostImagery, + hasPreImagery, hasPostImagery, }) => { const styles = useStyles(); @@ -202,7 +205,13 @@ const BuildingValidationRightPanel = ({ onChange={(_e, data) => setShowFill(!!data.checked)} /> setShowPostImagery(!!data.checked)} disabled={!hasPostImagery} @@ -287,13 +296,15 @@ const BuildingValidationRightPanel = ({ {/* Legend */}
-
Legend · Hotkeys: 1 / 2 / 3 · ← →
+
Legend
Unlabeled
Damaged
Not Damaged
Unknown
+ + {/* Actions */}
)} diff --git a/ui/src/Components/KeyboardShortcutHelp.jsx b/ui/src/Components/KeyboardShortcutHelp.jsx new file mode 100644 index 00000000..bb575c00 --- /dev/null +++ b/ui/src/Components/KeyboardShortcutHelp.jsx @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import { useState } from "react"; +import { Button } from "@fluentui/react-components"; +import { FluentIcon } from "../util/icons"; +import PropTypes from "prop-types"; + +const rootStyle = { + color: "#605e5c", + fontSize: 11, + marginTop: 8, +}; + +const itemStyle = { + display: "block", + height: "auto", + margin: 0, + padding: "0 0 8px", + position: "relative", + width: "100%", +}; + +const keysStyle = { + alignItems: "center", + display: "flex", + flexWrap: "wrap", + gap: 4, + lineHeight: "20px", + minHeight: 20, + position: "relative", + width: "100%", +}; + +const keyGroupStyle = { + alignItems: "center", + display: "inline-flex", + gap: 4, +}; + +const keyStyle = { + background: "#f3f2f1", + border: "1px solid #c8c6c4", + borderRadius: 3, + color: "#323130", + display: "inline-block", + fontFamily: "inherit", + fontSize: 10, + fontWeight: 600, + lineHeight: "16px", + minWidth: 18, + padding: "0 4px", + textAlign: "center", + whiteSpace: "nowrap", +}; + +const separatorStyle = { + color: "#8a8886", + display: "inline-block", + lineHeight: "16px", + whiteSpace: "pre", +}; + +const descriptionStyle = { + clear: "both", + display: "block", + fontSize: 11, + height: "auto", + lineHeight: "16px", + margin: "3px 0 0", + overflow: "visible", + overflowWrap: "break-word", + position: "relative", + whiteSpace: "normal", + width: "100%", +}; + +const KeyboardShortcutHelp = ({ + shortcuts, + title = "Keyboard shortcuts", +}) => { + const [isExpanded, setIsExpanded] = useState(true); + + return ( +
+ + {isExpanded && ( +
+ {shortcuts.map((shortcut) => ( +
+
+ {shortcut.keys.map((key, index) => ( + + {index > 0 && ( + + {shortcut.separator || "/"} + + )} + {key} + + ))} +
+
{shortcut.description}
+
+ ))} +
+ )} +
+ ); +}; + +KeyboardShortcutHelp.propTypes = { + shortcuts: PropTypes.arrayOf( + PropTypes.shape({ + keys: PropTypes.arrayOf(PropTypes.string).isRequired, + separator: PropTypes.string, + description: PropTypes.string.isRequired, + }) + ).isRequired, + title: PropTypes.string, +}; + +export default KeyboardShortcutHelp; diff --git a/ui/src/Components/LabelingTool/LabelingToolLeftPanel.jsx b/ui/src/Components/LabelingTool/LabelingToolLeftPanel.jsx index 348b912e..da11902d 100644 --- a/ui/src/Components/LabelingTool/LabelingToolLeftPanel.jsx +++ b/ui/src/Components/LabelingTool/LabelingToolLeftPanel.jsx @@ -13,6 +13,11 @@ import { saveLabels, checkLabelsState } from "./LabelingToolHelper"; import PropType from "prop-types"; import { useNavigate } from "react-router-dom"; +import KeyboardShortcutHelp from "../KeyboardShortcutHelp"; +import { + LABELING_TOOL_SHORTCUTS, + shouldIgnoreShortcut, +} from "../keyboardShortcuts"; const LabelingToolLeftPanel = ({ mapRef, @@ -168,19 +173,21 @@ const LabelingToolLeftPanel = ({ }, [eventImageryVisibilityState]); - // need a listener to update the post event imagery layer visibility on ctrl + p + // A/D are the standard pre/post controls. useEffect(() => { const handleKeyDown = (e) => { - if (e.ctrlKey && e.altKey && e.key === "c") { - setEventImageryVisibilityState((prevState) => !prevState); - } + if (shouldIgnoreShortcut(e)) return; + const key = e.key.toLowerCase(); + if (e.ctrlKey || e.altKey || e.metaKey) return; + if (key === "a") setEventImageryVisibilityState(false); + else if (key === "d") setEventImageryVisibilityState(true); }; window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [eventImageryVisibilityState]); + }, []); // Close the imagery settings panel when the user clicks on the map. useEffect(() => { @@ -374,6 +381,7 @@ const LabelingToolLeftPanel = ({ } /> + )} diff --git a/ui/src/Components/LabelingTool/LeftPanel.jsx b/ui/src/Components/LabelingTool/LeftPanel.jsx index d3a98119..40330df3 100644 --- a/ui/src/Components/LabelingTool/LeftPanel.jsx +++ b/ui/src/Components/LabelingTool/LeftPanel.jsx @@ -164,21 +164,6 @@ const LeftPanel = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventImageryVisibilityState]); - - // need a listener to update the post event imagery layer visibility on ctrl + p - useEffect(() => { - const handleKeyDown = (e) => { - if (e.ctrlKey && e.altKey && e.key === "c") { - setEventImageryVisibilityState((prevState) => !prevState); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [eventImageryVisibilityState]); - const navigate = useNavigate(); const handleBackNavigation = () => { diff --git a/ui/src/Components/Visualizer/InfoPanel.jsx b/ui/src/Components/Visualizer/InfoPanel.jsx index 8aa3d9fc..4406b477 100644 --- a/ui/src/Components/Visualizer/InfoPanel.jsx +++ b/ui/src/Components/Visualizer/InfoPanel.jsx @@ -12,6 +12,8 @@ import { FluentIcon } from "../../util/icons"; import { useState, useContext } from "react"; import PropType from "prop-types"; import { AppContext } from "../../AppContext"; +import KeyboardShortcutHelp from "../KeyboardShortcutHelp"; +import { VISUALIZER_SHORTCUTS } from "../keyboardShortcuts"; const DAMAGE_LEGEND = [ { label: "0 - 20% damaged", color: "#FFFFFF" }, @@ -143,6 +145,7 @@ const InfoPanel = ({ Reset map position + diff --git a/ui/src/Components/Visualizer/Visualizer.jsx b/ui/src/Components/Visualizer/Visualizer.jsx index 9126ce4c..6b79cfd6 100644 --- a/ui/src/Components/Visualizer/Visualizer.jsx +++ b/ui/src/Components/Visualizer/Visualizer.jsx @@ -11,6 +11,7 @@ import { convertDateToString } from "../../util/conversion"; import VisualizerImageryControls from "./VisualizerImageryControls" import "../../assets/css/visualizer.css"; import { getAzureMapsAuthOptions } from "../../util/azureMapsAuth"; +import { shouldIgnoreShortcut } from "../keyboardShortcuts"; const Visualizer = ({ setModalComponent }) => { @@ -228,16 +229,7 @@ const Visualizer = ({ setModalComponent }) => { }, []); const handleKeyboardShortcuts = (event) => { - // Ignore when typing in an input/textarea/contenteditable element. - const target = event.target; - const tag = target?.tagName; - if ( - tag === "INPUT" || - tag === "TEXTAREA" || - target?.isContentEditable - ) { - return; - } + if (shouldIgnoreShortcut(event)) return; if (event.ctrlKey || event.altKey || event.metaKey) { return; } diff --git a/ui/src/Components/Visualizer/VisualizerInformationMobile.jsx b/ui/src/Components/Visualizer/VisualizerInformationMobile.jsx index 0fe8e914..45efa7c7 100644 --- a/ui/src/Components/Visualizer/VisualizerInformationMobile.jsx +++ b/ui/src/Components/Visualizer/VisualizerInformationMobile.jsx @@ -11,6 +11,8 @@ import { FluentIcon } from "../../util/icons"; import { useState } from "react"; import PropType from "prop-types"; +import KeyboardShortcutHelp from "../KeyboardShortcutHelp"; +import { VISUALIZER_SHORTCUTS } from "../keyboardShortcuts"; const VisualizerInformationMobile = ({ visualizerResults, @@ -120,6 +122,8 @@ const VisualizerInformationMobile = ({ ) } /> +
+ diff --git a/ui/src/Components/keyboardShortcuts.js b/ui/src/Components/keyboardShortcuts.js new file mode 100644 index 00000000..3e507b28 --- /dev/null +++ b/ui/src/Components/keyboardShortcuts.js @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +export const VISUALIZER_SHORTCUTS = [ + { + keys: ["A", "S", "D"], + description: "Move the swipe divider left / split / right", + }, +]; + +export const INTERACTIVE_LABELER_SHORTCUTS = [ + { + keys: ["1", "2", "3"], + description: "Set Intact / Damaged / Cloudy", + }, + { keys: ["T"], description: "Cycle the selected class" }, + { keys: ["P"], description: "Toggle Labeled / Predicted view" }, + { keys: ["Space"], description: "Show / hide footprints" }, + { + keys: ["Ctrl", "drag"], + separator: " + ", + description: "Box-label buildings", + }, + { + keys: ["A", "S", "D"], + description: "With Swipe on: move the divider left / split / right", + }, +]; + +export const LABELING_TOOL_SHORTCUTS = [ + { + keys: ["A", "D"], + description: "Show pre (or basemap) / post imagery", + }, +]; + +export const BUILDING_VALIDATION_SHORTCUTS = [ + { + keys: ["1", "2", "3"], + description: "Label Damaged / Not Damaged / Unknown", + }, + { + keys: ["←", "→"], + description: "Previous / next building", + }, + { + keys: ["A", "D"], + description: "Show pre (or basemap) / post imagery", + }, +]; + +export function shouldIgnoreShortcut(event) { + const target = event?.target; + const tagName = target?.tagName?.toUpperCase(); + return ( + ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A"].includes(tagName) || + target?.isContentEditable === true || + target?.closest?.( + "button, a, [role='button'], [role='link'], [role='switch']" + ) != null + ); +}