From 2335886e9e7b4a21ee873b0c6911f8ef7aa7bdc6 Mon Sep 17 00:00:00 2001 From: seveibar Date: Fri, 25 Apr 2025 15:50:26 -0700 Subject: [PATCH 1/6] add context menu with ability to save camera position --- .../InteractiveGraphics/ContextMenu.tsx | 86 +++++++++++++++++++ .../InteractiveGraphics.tsx | 72 +++++++++++++++- site/components/InteractiveGraphics/index.ts | 3 + 3 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 site/components/InteractiveGraphics/ContextMenu.tsx create mode 100644 site/components/InteractiveGraphics/index.ts diff --git a/site/components/InteractiveGraphics/ContextMenu.tsx b/site/components/InteractiveGraphics/ContextMenu.tsx new file mode 100644 index 0000000..b6347f4 --- /dev/null +++ b/site/components/InteractiveGraphics/ContextMenu.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef } from "react" + +type ContextMenuProps = { + x: number + y: number + onSaveCamera: () => void + onClearCamera: () => void + onClose: () => void +} + +export const ContextMenu = ({ + x, + y, + onSaveCamera, + onClearCamera, + onClose, +}: ContextMenuProps) => { + const menuRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose() + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => { + document.removeEventListener("mousedown", handleClickOutside) + } + }, [onClose]) + + const menuStyle: React.CSSProperties = { + position: "absolute", + left: x, + top: y, + backgroundColor: "white", + border: "1px solid #ccc", + borderRadius: 4, + boxShadow: "0 2px 10px rgba(0,0,0,0.1)", + padding: 0, + zIndex: 1000, + minWidth: 180, + } + + const menuItemStyle: React.CSSProperties = { + padding: "8px 12px", + cursor: "pointer", + userSelect: "none", + } + + const handleItemHover = (e: React.MouseEvent) => { + e.currentTarget.style.backgroundColor = "#f5f5f5" + } + + const handleItemLeave = (e: React.MouseEvent) => { + e.currentTarget.style.backgroundColor = "" + } + + return ( +
+
{ + onSaveCamera() + onClose() + }} + onMouseEnter={handleItemHover} + onMouseLeave={handleItemLeave} + > + Save Camera Position +
+
{ + onClearCamera() + onClose() + }} + onMouseEnter={handleItemHover} + onMouseLeave={handleItemLeave} + > + Clear Saved Camera Position +
+
+ ) +} diff --git a/site/components/InteractiveGraphics/InteractiveGraphics.tsx b/site/components/InteractiveGraphics/InteractiveGraphics.tsx index c8b2c96..9820e63 100644 --- a/site/components/InteractiveGraphics/InteractiveGraphics.tsx +++ b/site/components/InteractiveGraphics/InteractiveGraphics.tsx @@ -1,6 +1,6 @@ import { compose, scale, translate } from "transformation-matrix" import { GraphicsObject } from "../../../lib" -import { useMemo, useState } from "react" +import { useMemo, useState, useEffect, useCallback } from "react" import useMouseMatrixTransform from "use-mouse-matrix-transform" import { InteractiveState } from "./InteractiveState" import { SuperGrid } from "react-supergrid" @@ -20,6 +20,7 @@ import { } from "./hooks" import { DimensionOverlay } from "../DimensionOverlay" import { getMaxStep } from "site/utils/getMaxStep" +import { ContextMenu } from "./ContextMenu" export type GraphicsObjectClickEvent = { type: "point" | "line" | "rect" | "circle" @@ -40,6 +41,10 @@ export const InteractiveGraphics = ({ const [activeStep, setActiveStep] = useState(null) const [showLastStep, setShowLastStep] = useState(true) const [size, setSize] = useState({ width: 600, height: 600 }) + const [contextMenu, setContextMenu] = useState<{ + x: number + y: number + } | null>(null) const availableLayers: string[] = Array.from( new Set([ ...(graphics.lines?.map((l) => l.layer!).filter(Boolean) ?? []), @@ -61,8 +66,12 @@ export const InteractiveGraphics = ({ } }, [graphics]) - const { transform: realToScreen, ref } = useMouseMatrixTransform({ - initialTransform: compose( + const getStorageKey = useCallback(() => { + return `saved-camera-position-${window.location.pathname}` + }, []) + + const getDefaultTransform = useCallback(() => { + return compose( translate(size.width / 2, size.height / 2), scale( Math.min( @@ -82,7 +91,27 @@ export const InteractiveGraphics = ({ -(graphicsBoundsWithPadding.maxX + graphicsBoundsWithPadding.minX) / 2, -(graphicsBoundsWithPadding.maxY + graphicsBoundsWithPadding.minY) / 2, ), - ), + ) + }, [size, graphicsBoundsWithPadding]) + + const getSavedTransform = useCallback(() => { + try { + const savedTransform = localStorage.getItem(getStorageKey()) + if (savedTransform) { + return JSON.parse(savedTransform) + } + } catch (error) { + console.error("Error loading saved camera position:", error) + } + return null + }, [getStorageKey]) + + const { + transform: realToScreen, + ref, + setTransform, + } = useMouseMatrixTransform({ + initialTransform: getSavedTransform() || getDefaultTransform(), }) useResizeObserver(ref, (entry: ResizeObserverEntry) => { @@ -92,6 +121,31 @@ export const InteractiveGraphics = ({ }) }) + const handleContextMenu = useCallback((event: React.MouseEvent) => { + event.preventDefault() + setContextMenu({ + x: event.clientX, + y: event.clientY, + }) + }, []) + + const handleSaveCamera = useCallback(() => { + try { + localStorage.setItem(getStorageKey(), JSON.stringify(realToScreen)) + } catch (error) { + console.error("Error saving camera position:", error) + } + }, [getStorageKey, realToScreen]) + + const handleClearCamera = useCallback(() => { + try { + localStorage.removeItem(getStorageKey()) + setTransform(getDefaultTransform()) + } catch (error) { + console.error("Error clearing camera position:", error) + } + }, [getStorageKey, getDefaultTransform, setTransform]) + const interactiveState: InteractiveState = { activeLayers: activeLayers, activeStep: showLastStep ? maxStep : activeStep, @@ -261,6 +315,7 @@ export const InteractiveGraphics = ({ height: 600, overflow: "hidden", }} + onContextMenu={handleContextMenu} > {filteredLines.map((line) => ( @@ -302,6 +357,15 @@ export const InteractiveGraphics = ({ transform={realToScreen} /> + {contextMenu && ( + setContextMenu(null)} + /> + )} ) diff --git a/site/components/InteractiveGraphics/index.ts b/site/components/InteractiveGraphics/index.ts new file mode 100644 index 0000000..9c4bd37 --- /dev/null +++ b/site/components/InteractiveGraphics/index.ts @@ -0,0 +1,3 @@ +export { InteractiveGraphics } from "./InteractiveGraphics" +export { InteractiveState } from "./InteractiveState" +export { ContextMenu } from "./ContextMenu" From 9a5e3baab3fb70b6d737e80262c7229fe622ba92 Mon Sep 17 00:00:00 2001 From: seveibar Date: Fri, 25 Apr 2025 15:58:36 -0700 Subject: [PATCH 2/6] context menu location fix, support GET params having unique camera position --- site/components/InteractiveGraphics/ContextMenu.tsx | 1 + site/components/InteractiveGraphics/InteractiveGraphics.tsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/site/components/InteractiveGraphics/ContextMenu.tsx b/site/components/InteractiveGraphics/ContextMenu.tsx index b6347f4..bfaeb7a 100644 --- a/site/components/InteractiveGraphics/ContextMenu.tsx +++ b/site/components/InteractiveGraphics/ContextMenu.tsx @@ -34,6 +34,7 @@ export const ContextMenu = ({ position: "absolute", left: x, top: y, + transform: "translate(0, -100%)", backgroundColor: "white", border: "1px solid #ccc", borderRadius: 4, diff --git a/site/components/InteractiveGraphics/InteractiveGraphics.tsx b/site/components/InteractiveGraphics/InteractiveGraphics.tsx index 9820e63..b8d8bc7 100644 --- a/site/components/InteractiveGraphics/InteractiveGraphics.tsx +++ b/site/components/InteractiveGraphics/InteractiveGraphics.tsx @@ -67,7 +67,9 @@ export const InteractiveGraphics = ({ }, [graphics]) const getStorageKey = useCallback(() => { - return `saved-camera-position-${window.location.pathname}` + const path = window.location.pathname + const search = window.location.search + return `saved-camera-position-${path}${search}` }, []) const getDefaultTransform = useCallback(() => { From cc2afb5dcc218d2604363d8b329083c3f70eaaac Mon Sep 17 00:00:00 2001 From: seveibar Date: Fri, 25 Apr 2025 16:05:11 -0700 Subject: [PATCH 3/6] add support for adding markers --- biome.json | 3 + .../InteractiveGraphics/ContextMenu.tsx | 26 ++++ .../InteractiveGraphics.tsx | 115 +++++++++++++++--- .../components/InteractiveGraphics/Marker.tsx | 38 ++++++ 4 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 site/components/InteractiveGraphics/Marker.tsx diff --git a/biome.json b/biome.json index d204b39..53163ff 100644 --- a/biome.json +++ b/biome.json @@ -38,6 +38,9 @@ "complexity": { "noForEach": "off" }, + "correctness": { + "useExhaustiveDependencies": "off" + }, "style": { "useImportType": "off", "noUselessElse": "off", diff --git a/site/components/InteractiveGraphics/ContextMenu.tsx b/site/components/InteractiveGraphics/ContextMenu.tsx index bfaeb7a..0df2468 100644 --- a/site/components/InteractiveGraphics/ContextMenu.tsx +++ b/site/components/InteractiveGraphics/ContextMenu.tsx @@ -5,6 +5,8 @@ type ContextMenuProps = { y: number onSaveCamera: () => void onClearCamera: () => void + onAddMark: () => void + onClearMarks: () => void onClose: () => void } @@ -13,6 +15,8 @@ export const ContextMenu = ({ y, onSaveCamera, onClearCamera, + onAddMark, + onClearMarks, onClose, }: ContextMenuProps) => { const menuRef = useRef(null) @@ -82,6 +86,28 @@ export const ContextMenu = ({ > Clear Saved Camera Position +
{ + onAddMark() + onClose() + }} + onMouseEnter={handleItemHover} + onMouseLeave={handleItemLeave} + > + Add Mark +
+
{ + onClearMarks() + onClose() + }} + onMouseEnter={handleItemHover} + onMouseLeave={handleItemLeave} + > + Clear Marks +
) } diff --git a/site/components/InteractiveGraphics/InteractiveGraphics.tsx b/site/components/InteractiveGraphics/InteractiveGraphics.tsx index b8d8bc7..3d06f67 100644 --- a/site/components/InteractiveGraphics/InteractiveGraphics.tsx +++ b/site/components/InteractiveGraphics/InteractiveGraphics.tsx @@ -1,4 +1,10 @@ -import { compose, scale, translate } from "transformation-matrix" +import { + compose, + scale, + translate, + inverse, + applyToPoint, +} from "transformation-matrix" import { GraphicsObject } from "../../../lib" import { useMemo, useState, useEffect, useCallback } from "react" import useMouseMatrixTransform from "use-mouse-matrix-transform" @@ -21,6 +27,7 @@ import { import { DimensionOverlay } from "../DimensionOverlay" import { getMaxStep } from "site/utils/getMaxStep" import { ContextMenu } from "./ContextMenu" +import { Marker, MarkerPoint } from "./Marker" export type GraphicsObjectClickEvent = { type: "point" | "line" | "rect" | "circle" @@ -44,7 +51,10 @@ export const InteractiveGraphics = ({ const [contextMenu, setContextMenu] = useState<{ x: number y: number + clientX: number + clientY: number } | null>(null) + const [markers, setMarkers] = useState([]) const availableLayers: string[] = Array.from( new Set([ ...(graphics.lines?.map((l) => l.layer!).filter(Boolean) ?? []), @@ -96,18 +106,28 @@ export const InteractiveGraphics = ({ ) }, [size, graphicsBoundsWithPadding]) - const getSavedTransform = useCallback(() => { + type SavedData = { + transform: any + markers: MarkerPoint[] + } + + const getSavedData = useCallback((): SavedData | null => { try { - const savedTransform = localStorage.getItem(getStorageKey()) - if (savedTransform) { - return JSON.parse(savedTransform) + const savedData = localStorage.getItem(getStorageKey()) + if (savedData) { + return JSON.parse(savedData) } } catch (error) { - console.error("Error loading saved camera position:", error) + console.error("Error loading saved data:", error) } return null }, [getStorageKey]) + const getSavedTransform = useCallback(() => { + const savedData = getSavedData() + return savedData?.transform || null + }, [getSavedData]) + const { transform: realToScreen, ref, @@ -123,30 +143,87 @@ export const InteractiveGraphics = ({ }) }) + // Load saved markers on mount + useEffect(() => { + const savedData = getSavedData() + if (savedData?.markers) { + setMarkers(savedData.markers) + } + }, [getSavedData]) + const handleContextMenu = useCallback((event: React.MouseEvent) => { event.preventDefault() setContextMenu({ x: event.clientX, y: event.clientY, + clientX: event.clientX, + clientY: event.clientY, }) }, []) + const saveToLocalStorage = useCallback( + (transform: any, markerPoints: MarkerPoint[]) => { + try { + const dataToSave: SavedData = { + transform, + markers: markerPoints, + } + localStorage.setItem(getStorageKey(), JSON.stringify(dataToSave)) + } catch (error) { + console.error("Error saving data:", error) + } + }, + [getStorageKey], + ) + const handleSaveCamera = useCallback(() => { + saveToLocalStorage(realToScreen, markers) + }, [saveToLocalStorage, realToScreen, markers]) + + const handleClearCamera = useCallback(() => { try { - localStorage.setItem(getStorageKey(), JSON.stringify(realToScreen)) + const defaultTransform = getDefaultTransform() + saveToLocalStorage(defaultTransform, markers) + setTransform(defaultTransform) } catch (error) { - console.error("Error saving camera position:", error) + console.error("Error clearing camera position:", error) } - }, [getStorageKey, realToScreen]) + }, [saveToLocalStorage, getDefaultTransform, setTransform, markers]) + + const handleAddMark = useCallback(() => { + if (!contextMenu) return - const handleClearCamera = useCallback(() => { try { - localStorage.removeItem(getStorageKey()) - setTransform(getDefaultTransform()) + // Convert screen coordinates to real-world coordinates + const screenPoint = { x: contextMenu.clientX, y: contextMenu.clientY } + const rect = ref.current?.getBoundingClientRect() + + if (rect) { + const screenX = screenPoint.x - rect.left + const screenY = screenPoint.y - rect.top + + // Apply inverse transform to get real-world coordinates + const inverseTransform = inverse(realToScreen) + const [realX, realY] = applyToPoint(inverseTransform, [ + screenX, + screenY, + ]) + + const newMarker: MarkerPoint = { x: realX, y: realY } + const newMarkers = [...markers, newMarker] + + setMarkers(newMarkers) + saveToLocalStorage(realToScreen, newMarkers) + } } catch (error) { - console.error("Error clearing camera position:", error) + console.error("Error adding marker:", error) } - }, [getStorageKey, getDefaultTransform, setTransform]) + }, [contextMenu, ref, realToScreen, markers, saveToLocalStorage]) + + const handleClearMarks = useCallback(() => { + setMarkers([]) + saveToLocalStorage(realToScreen, []) + }, [realToScreen, saveToLocalStorage]) const interactiveState: InteractiveState = { activeLayers: activeLayers, @@ -359,12 +436,22 @@ export const InteractiveGraphics = ({ transform={realToScreen} /> + {markers.map((marker, index) => ( + + ))} {contextMenu && ( setContextMenu(null)} /> )} diff --git a/site/components/InteractiveGraphics/Marker.tsx b/site/components/InteractiveGraphics/Marker.tsx new file mode 100644 index 0000000..f8819d2 --- /dev/null +++ b/site/components/InteractiveGraphics/Marker.tsx @@ -0,0 +1,38 @@ +import { Matrix, applyToPoint } from "transformation-matrix" + +export type MarkerPoint = { + x: number + y: number +} + +type MarkerProps = { + marker: MarkerPoint + index: number + transform: Matrix +} + +export const Marker = ({ marker, transform }: MarkerProps) => { + const [screenX, screenY] = applyToPoint(transform, [marker.x, marker.y]) + + return ( + + + + + + + ) +} From bc58b39709cebc57fa437eadb3cc979b3507e813 Mon Sep 17 00:00:00 2001 From: seveibar Date: Fri, 25 Apr 2025 16:09:46 -0700 Subject: [PATCH 4/6] fix context menu position --- site/components/InteractiveGraphics/ContextMenu.tsx | 1 - site/components/InteractiveGraphics/InteractiveGraphics.tsx | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/site/components/InteractiveGraphics/ContextMenu.tsx b/site/components/InteractiveGraphics/ContextMenu.tsx index 0df2468..5e70f3a 100644 --- a/site/components/InteractiveGraphics/ContextMenu.tsx +++ b/site/components/InteractiveGraphics/ContextMenu.tsx @@ -38,7 +38,6 @@ export const ContextMenu = ({ position: "absolute", left: x, top: y, - transform: "translate(0, -100%)", backgroundColor: "white", border: "1px solid #ccc", borderRadius: 4, diff --git a/site/components/InteractiveGraphics/InteractiveGraphics.tsx b/site/components/InteractiveGraphics/InteractiveGraphics.tsx index 3d06f67..2b4ab4e 100644 --- a/site/components/InteractiveGraphics/InteractiveGraphics.tsx +++ b/site/components/InteractiveGraphics/InteractiveGraphics.tsx @@ -154,8 +154,8 @@ export const InteractiveGraphics = ({ const handleContextMenu = useCallback((event: React.MouseEvent) => { event.preventDefault() setContextMenu({ - x: event.clientX, - y: event.clientY, + x: event.clientX - (event.currentTarget as HTMLElement).offsetLeft, + y: event.clientY - (event.currentTarget as HTMLElement).offsetTop, clientX: event.clientX, clientY: event.clientY, }) From e8a369c39ce1a6454f7dee382f64ba46546c3c11 Mon Sep 17 00:00:00 2001 From: seveibar Date: Fri, 25 Apr 2025 16:12:40 -0700 Subject: [PATCH 5/6] make context menu text a little smaller --- .../InteractiveGraphics/ContextMenu.tsx | 10 +++-- .../InteractiveGraphics.tsx | 40 +++++++++++++++++-- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/site/components/InteractiveGraphics/ContextMenu.tsx b/site/components/InteractiveGraphics/ContextMenu.tsx index 5e70f3a..2cc050f 100644 --- a/site/components/InteractiveGraphics/ContextMenu.tsx +++ b/site/components/InteractiveGraphics/ContextMenu.tsx @@ -40,17 +40,19 @@ export const ContextMenu = ({ top: y, backgroundColor: "white", border: "1px solid #ccc", - borderRadius: 4, - boxShadow: "0 2px 10px rgba(0,0,0,0.1)", + borderRadius: 3, + boxShadow: "0 2px 6px rgba(0,0,0,0.1)", padding: 0, zIndex: 1000, - minWidth: 180, + minWidth: 160, + fontSize: "12px", } const menuItemStyle: React.CSSProperties = { - padding: "8px 12px", + padding: "4px 8px", cursor: "pointer", userSelect: "none", + lineHeight: "1.5", } const handleItemHover = (e: React.MouseEvent) => { diff --git a/site/components/InteractiveGraphics/InteractiveGraphics.tsx b/site/components/InteractiveGraphics/InteractiveGraphics.tsx index 2b4ab4e..dcf497b 100644 --- a/site/components/InteractiveGraphics/InteractiveGraphics.tsx +++ b/site/components/InteractiveGraphics/InteractiveGraphics.tsx @@ -153,11 +153,43 @@ export const InteractiveGraphics = ({ const handleContextMenu = useCallback((event: React.MouseEvent) => { event.preventDefault() + + // Get mouse position + const mouseX = event.clientX + const mouseY = event.clientY + + // Get element position + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() + const elementX = rect.left + const elementY = rect.top + + // Get viewport dimensions + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + + // Menu dimensions (approximate) + const menuWidth = 160 + const menuHeight = 100 + + // Position based on quadrant of the screen + let x = mouseX - elementX + let y = mouseY - elementY + + // If mouse is in right half of viewport, position menu to the left + if (mouseX > viewportWidth / 2) { + x = x - menuWidth + } + + // If mouse is in bottom half of viewport, position menu above + if (mouseY > viewportHeight / 2) { + y = y - menuHeight + } + setContextMenu({ - x: event.clientX - (event.currentTarget as HTMLElement).offsetLeft, - y: event.clientY - (event.currentTarget as HTMLElement).offsetTop, - clientX: event.clientX, - clientY: event.clientY, + x, + y, + clientX: mouseX, + clientY: mouseY, }) }, []) From 6f3291ec55b71ffa223dca3cd8899524f3543b2a Mon Sep 17 00:00:00 2001 From: seveibar Date: Fri, 25 Apr 2025 16:14:01 -0700 Subject: [PATCH 6/6] typefix --- biome.json | 3 ++- site/components/InteractiveGraphics/ContextMenu.tsx | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/biome.json b/biome.json index 53163ff..1dfa2ac 100644 --- a/biome.json +++ b/biome.json @@ -29,7 +29,8 @@ "noDangerouslySetInnerHtml": "off" }, "a11y": { - "noSvgWithoutTitle": "off" + "noSvgWithoutTitle": "off", + "useKeyWithClickEvents": "off" }, "suspicious": { "noExplicitAny": "off", diff --git a/site/components/InteractiveGraphics/ContextMenu.tsx b/site/components/InteractiveGraphics/ContextMenu.tsx index 2cc050f..f38f0ac 100644 --- a/site/components/InteractiveGraphics/ContextMenu.tsx +++ b/site/components/InteractiveGraphics/ContextMenu.tsx @@ -54,12 +54,11 @@ export const ContextMenu = ({ userSelect: "none", lineHeight: "1.5", } - - const handleItemHover = (e: React.MouseEvent) => { + const handleItemHover = (e: React.MouseEvent) => { e.currentTarget.style.backgroundColor = "#f5f5f5" } - const handleItemLeave = (e: React.MouseEvent) => { + const handleItemLeave = (e: React.MouseEvent) => { e.currentTarget.style.backgroundColor = "" }