Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"noDangerouslySetInnerHtml": "off"
},
"a11y": {
"noSvgWithoutTitle": "off"
"noSvgWithoutTitle": "off",
"useKeyWithClickEvents": "off"
},
"suspicious": {
"noExplicitAny": "off",
Expand All @@ -38,6 +39,9 @@
"complexity": {
"noForEach": "off"
},
"correctness": {
"useExhaustiveDependencies": "off"
},
"style": {
"useImportType": "off",
"noUselessElse": "off",
Expand Down
113 changes: 113 additions & 0 deletions site/components/InteractiveGraphics/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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<HTMLElement>) => {
e.currentTarget.style.backgroundColor = "#f5f5f5"
}

const handleItemLeave = (e: React.MouseEvent<HTMLElement>) => {
e.currentTarget.style.backgroundColor = ""
}

return (
<div ref={menuRef} style={menuStyle}>
<div
style={menuItemStyle}
onClick={() => {
onSaveCamera()
onClose()
}}
onMouseEnter={handleItemHover}
onMouseLeave={handleItemLeave}
>
Save Camera Position
</div>
<div
style={menuItemStyle}
onClick={() => {
onClearCamera()
onClose()
}}
onMouseEnter={handleItemHover}
onMouseLeave={handleItemLeave}
>
Clear Saved Camera Position
</div>
<div
style={menuItemStyle}
onClick={() => {
onAddMark()
onClose()
}}
onMouseEnter={handleItemHover}
onMouseLeave={handleItemLeave}
>
Add Mark
</div>
<div
style={menuItemStyle}
onClick={() => {
onClearMarks()
onClose()
}}
onMouseEnter={handleItemHover}
onMouseLeave={handleItemLeave}
>
Clear Marks
</div>
</div>
)
}
195 changes: 190 additions & 5 deletions site/components/InteractiveGraphics/InteractiveGraphics.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand All @@ -40,6 +48,13 @@ export const InteractiveGraphics = ({
const [activeStep, setActiveStep] = useState<number | null>(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<MarkerPoint[]>([])
const availableLayers: string[] = Array.from(
new Set([
...(graphics.lines?.map((l) => l.layer!).filter(Boolean) ?? []),
Expand All @@ -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(
Expand All @@ -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) => {
Expand All @@ -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,
Expand Down Expand Up @@ -261,6 +426,7 @@ export const InteractiveGraphics = ({
height: 600,
overflow: "hidden",
}}
onContextMenu={handleContextMenu}
>
<DimensionOverlay transform={realToScreen}>
{filteredLines.map((line) => (
Expand Down Expand Up @@ -302,6 +468,25 @@ export const InteractiveGraphics = ({
transform={realToScreen}
/>
</DimensionOverlay>
{markers.map((marker, index) => (
<Marker
key={index}
marker={marker}
index={index}
transform={realToScreen}
/>
))}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
onSaveCamera={handleSaveCamera}
onClearCamera={handleClearCamera}
onAddMark={handleAddMark}
onClearMarks={handleClearMarks}
onClose={() => setContextMenu(null)}
/>
)}
</div>
</div>
)
Expand Down
Loading