diff --git a/biome.json b/biome.json index d204b39..1dfa2ac 100644 --- a/biome.json +++ b/biome.json @@ -29,7 +29,8 @@ "noDangerouslySetInnerHtml": "off" }, "a11y": { - "noSvgWithoutTitle": "off" + "noSvgWithoutTitle": "off", + "useKeyWithClickEvents": "off" }, "suspicious": { "noExplicitAny": "off", @@ -38,6 +39,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 new file mode 100644 index 0000000..f38f0ac --- /dev/null +++ b/site/components/InteractiveGraphics/ContextMenu.tsx @@ -0,0 +1,113 @@ +import { useEffect, useRef } from "react" + +type ContextMenuProps = { + x: number + y: number + onSaveCamera: () => void + onClearCamera: () => void + onAddMark: () => void + onClearMarks: () => void + onClose: () => void +} + +export const ContextMenu = ({ + x, + y, + onSaveCamera, + onClearCamera, + onAddMark, + onClearMarks, + 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: 3, + boxShadow: "0 2px 6px rgba(0,0,0,0.1)", + padding: 0, + zIndex: 1000, + minWidth: 160, + fontSize: "12px", + } + + const menuItemStyle: React.CSSProperties = { + padding: "4px 8px", + cursor: "pointer", + userSelect: "none", + lineHeight: "1.5", + } + 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 +
+
{ + 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 c8b2c96..dcf497b 100644 --- a/site/components/InteractiveGraphics/InteractiveGraphics.tsx +++ b/site/components/InteractiveGraphics/InteractiveGraphics.tsx @@ -1,6 +1,12 @@ -import { compose, scale, translate } from "transformation-matrix" +import { + compose, + scale, + translate, + inverse, + applyToPoint, +} 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 +26,8 @@ import { } from "./hooks" 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" @@ -40,6 +48,13 @@ 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 + 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) ?? []), @@ -61,8 +76,14 @@ export const InteractiveGraphics = ({ } }, [graphics]) - const { transform: realToScreen, ref } = useMouseMatrixTransform({ - initialTransform: compose( + const getStorageKey = useCallback(() => { + const path = window.location.pathname + const search = window.location.search + return `saved-camera-position-${path}${search}` + }, []) + + const getDefaultTransform = useCallback(() => { + return compose( translate(size.width / 2, size.height / 2), scale( Math.min( @@ -82,7 +103,37 @@ export const InteractiveGraphics = ({ -(graphicsBoundsWithPadding.maxX + graphicsBoundsWithPadding.minX) / 2, -(graphicsBoundsWithPadding.maxY + graphicsBoundsWithPadding.minY) / 2, ), - ), + ) + }, [size, graphicsBoundsWithPadding]) + + type SavedData = { + transform: any + markers: MarkerPoint[] + } + + const getSavedData = useCallback((): SavedData | null => { + try { + const savedData = localStorage.getItem(getStorageKey()) + if (savedData) { + return JSON.parse(savedData) + } + } catch (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, + setTransform, + } = useMouseMatrixTransform({ + initialTransform: getSavedTransform() || getDefaultTransform(), }) useResizeObserver(ref, (entry: ResizeObserverEntry) => { @@ -92,6 +143,120 @@ 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() + + // 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, + y, + clientX: mouseX, + clientY: mouseY, + }) + }, []) + + 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 { + const defaultTransform = getDefaultTransform() + saveToLocalStorage(defaultTransform, markers) + setTransform(defaultTransform) + } catch (error) { + console.error("Error clearing camera position:", error) + } + }, [saveToLocalStorage, getDefaultTransform, setTransform, markers]) + + const handleAddMark = useCallback(() => { + if (!contextMenu) return + + try { + // 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 adding marker:", error) + } + }, [contextMenu, ref, realToScreen, markers, saveToLocalStorage]) + + const handleClearMarks = useCallback(() => { + setMarkers([]) + saveToLocalStorage(realToScreen, []) + }, [realToScreen, saveToLocalStorage]) + const interactiveState: InteractiveState = { activeLayers: activeLayers, activeStep: showLastStep ? maxStep : activeStep, @@ -261,6 +426,7 @@ export const InteractiveGraphics = ({ height: 600, overflow: "hidden", }} + onContextMenu={handleContextMenu} > {filteredLines.map((line) => ( @@ -302,6 +468,25 @@ 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 ( + + + + + + + ) +} 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"