Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexGustafsson committed Dec 27, 2024
1 parent 51669f6 commit 82a0466
Show file tree
Hide file tree
Showing 8 changed files with 299 additions and 82 deletions.
21 changes: 0 additions & 21 deletions web/components/CustomGraphNode.tsx

This file was deleted.

98 changes: 54 additions & 44 deletions web/components/Graph.tsx
Original file line number Diff line number Diff line change
@@ -1,87 +1,97 @@
import { useMemo, type JSX } from 'react'
import type { Edge, Node } from '../graph'
import { CustomGraphNode } from './CustomGraphNode'
import { Surface } from './Surface'

export type GraphNode = Node

export function GraphNode({
width,
height,
data: { subtitle, title, label },
}: GraphNode): JSX.Element {
return (
<div
className="px-4 py-2 shadow-md rounded-md bg-white dark:bg-[#262626] border-2 border-[#ebebeb] dark:border-[#333333]"
style={{ width: `${width}px`, height: `${height}px` }}
>
<div className="flex">
<div className="rounded-full w-12 h-12 flex justify-center items-center bg-gray-100 dark:bg-[#363a3a] shrink-0">
{label}
</div>
<div className="ml-2 grow min-w-0">
<div className="text-lg font-bold truncate">{title}</div>
<div className="text-gray-500 truncate">{subtitle}</div>
</div>
</div>
</div>
)
}

type EdgesParams = {
edges: Edge[]
}

function Edges({ edges }: EdgesParams): JSX.Element {
const beziers = useMemo(() => {
const s = 1
const c = 1
return edges.map((edge) => {
const pathStart = `M${edge.start.x},${edge.start.y}`
const pathStartControl = `C${edge.start.x + s / 3}, ${edge.start.y + c}`
const pathStartControl = `C${edge.start.x}, ${edge.start.y + 30}`

const y = 1
const pathEnd = `${edge.end.x},${edge.end.y}`
const pathEndControl = `${edge.start.x + s},${edge.start.y + y}`
const pathEndControl = `${edge.end.x},${edge.end.y - 30}`

return `${pathStart}, ${pathStartControl}, ${pathEnd}, ${pathEndControl}`
return `${pathStart}, ${pathStartControl}, ${pathEndControl}, ${pathEnd}, `
})
}, [edges])

return (
<svg role="img" aria-label="Graph edge" className="w-full h-full">
<g>
{/* <path
d="M415,117.5 C415,140 190,140 190,162.5"
className="stroke-black fill-none stroke-2"
/> */}
{beziers.map((bezier) => (
<path
key={bezier}
d={bezier}
className="stroke-black fill-none stroke-2"
className="fill-none stroke-2 stroke-[#ebebeb] dark:stroke-[#333333]"
/>
))}
</g>
</svg>
)
}

// ;<svg style="z-index: 0;">
// <g
// class="react-flow__edge react-flow__edge-default nopan inactive"
// role="img"
// data-id="kubernetes/6afc2100-bf6d-4a93-9238-5a62087178d1->kubernetes/your-spotify"
// data-testid="rf__edge-kubernetes/6afc2100-bf6d-4a93-9238-5a62087178d1->kubernetes/your-spotify"
// aria-label="Edge from kubernetes/your-spotify to kubernetes/6afc2100-bf6d-4a93-9238-5a62087178d1"
// >
// <path
// d="M415,117.5 C415,140 190,140 190,162.5"
// fill="none"
// class="react-flow__edge-path"
// ></path>
// <path
// d="M415,117.5 C415,140 190,140 190,162.5"
// fill="none"
// stroke-opacity="0"
// stroke-width="20"
// class="react-flow__edge-interaction"
// ></path>
// </g>
// </svg>

type GraphProps = {
nodes: Node[]
edges: Edge[]
bounds: { width: number; height: number }
}

export function Graph({ nodes, edges }: GraphProps): JSX.Element {
export function Graph({
nodes,
edges,
bounds: { width, height },
}: GraphProps): JSX.Element {
return (
<div className="relative w-full h-full bg-red-400 overflow-hidden">
<Edges edges={edges} />
{nodes.map((node) => (
<div className="w-full h-full">
<Surface>
<div
key={node.id}
className="absolute"
style={{ top: `${node.position.y}px`, left: `${node.position.x}px` }}
className="relative"
style={{ width: `${width}px`, height: `${height}px` }}
>
<CustomGraphNode {...node.data} />
<Edges edges={edges} />
{nodes.map((node) => (
<div
key={node.id}
className="absolute"
style={{
top: `${node.position.y}px`,
left: `${node.position.x}px`,
}}
>
<GraphNode {...node} />
</div>
))}
</div>
))}
</Surface>
</div>
)
}
141 changes: 141 additions & 0 deletions web/components/Surface.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {
type JSX,
type PropsWithChildren,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { FluentFullScreenMaximize16Regular } from './icons/fluent-full-screen-maximize-16-regular'
import { FluentAdd16Regular } from './icons/fluent-plus-16-regular'
import { FluentSubtract16Regular } from './icons/fluent-subtract-16-regular'

export function Surface({
children,
}: PropsWithChildren<Record<never, never>>): JSX.Element {
const surfaceRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)

const [offset, setOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
const [scale, setScale] = useState<number>(1.0)
const [isDragging, setIsDragging] = useState(false)

const onMouseMove = useCallback((e: MouseEvent) => {
setOffset((current) => ({
x: current.x + e.movementX,
y: current.y + e.movementY,
}))
}, [])
const onMouseUp = useCallback(
(e: MouseEvent) => {
document.removeEventListener('mousemove', onMouseMove)

setIsDragging(false)
},
[onMouseMove]
)
const onMouseDown = useCallback(
(e: MouseEvent) => {
if (e.buttons !== 1) {
return
}

document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)

setIsDragging(true)
},
[onMouseMove, onMouseUp]
)

const onZoom = useCallback((delta: number) => {
setScale((current) => Math.min(Math.max(current + delta * 0.1, 0.4), 1))
}, [])

const onCenter = useCallback(() => {
if (!contentRef.current || !surfaceRef.current) {
return
}

const surfaceWidth = surfaceRef.current.offsetWidth
const surfaceHeight = surfaceRef.current.offsetHeight

const contentWidth = contentRef.current.offsetWidth
const contentHeight = contentRef.current.offsetHeight

const scale = Math.min(
surfaceWidth / contentWidth,
surfaceHeight / contentHeight
)

setScale(Math.min(Math.max(scale, 0.3), 1))
setOffset({
x: surfaceWidth / 2 - contentWidth / 2,
y: surfaceHeight / 2 - contentHeight / 2,
})
}, [])

const onWheel = useCallback((e: WheelEvent) => {
setScale((current) =>
Math.min(Math.max(current - e.deltaY * 0.001, 0.3), 1)
)
e.preventDefault()
}, [])

useEffect(() => {
surfaceRef.current?.addEventListener('mousedown', onMouseDown)
surfaceRef.current?.addEventListener('wheel', onWheel)

return () => {
surfaceRef.current?.removeEventListener('mousedown', onMouseDown)
surfaceRef.current?.removeEventListener('wheel', onWheel)
}
}, [onMouseDown, onWheel])

// Center on child re-size (which should only happen a couple of times the
// first few renders)
const onContentResize = useCallback(
(entries: ResizeObserverEntry[], observer: ResizeObserver) => {
onCenter()
},
[onCenter]
)

useEffect(() => {
const observer = new ResizeObserver(onContentResize)
if (contentRef.current) {
observer.observe(contentRef.current)
}
return () => {
observer.disconnect()
}
}, [onContentResize])

return (
<div
ref={surfaceRef}
className={`relative w-full h-full overflow-hidden select-none ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
>
<div className="absolute left-0 bottom-0 m-2 rounded bg-white dark:bg-[#1e1e1e] flex flex-col p-2 z-50 shadow-md gap-y-2">
<button type="button" onClick={() => onZoom(1)}>
<FluentAdd16Regular />
</button>
<button type="button" onClick={() => onZoom(-1)}>
<FluentSubtract16Regular />
</button>
<button type="button" onClick={() => onCenter()}>
<FluentFullScreenMaximize16Regular />
</button>
</div>
<div
ref={contentRef}
className="pointer-events-none w-fit h-fit"
style={{
transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale}, ${scale})`,
}}
>
{children}
</div>
</div>
)
}
22 changes: 22 additions & 0 deletions web/components/icons/fluent-full-screen-maximize-16-regular.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { SVGProps } from 'react'

export function FluentFullScreenMaximize16Regular(
props: SVGProps<SVGSVGElement>
) {
return (
<svg
role="img"
aria-label="icon"
xmlns="http://www.w3.org/2000/svg"
width="16px"
height="16px"
viewBox="0 0 16 16"
{...props}
>
<path
fill="currentColor"
d="M3.75 3a.75.75 0 0 0-.75.75V5.5a.5.5 0 0 1-1 0V3.75C2 2.784 2.784 2 3.75 2H5.5a.5.5 0 0 1 0 1zM10 2.5a.5.5 0 0 1 .5-.5h1.75c.966 0 1.75.784 1.75 1.75V5.5a.5.5 0 0 1-1 0V3.75a.75.75 0 0 0-.75-.75H10.5a.5.5 0 0 1-.5-.5M2.5 10a.5.5 0 0 1 .5.5v1.75c0 .414.336.75.75.75H5.5a.5.5 0 0 1 0 1H3.75A1.75 1.75 0 0 1 2 12.25V10.5a.5.5 0 0 1 .5-.5m11 0a.5.5 0 0 1 .5.5v1.75A1.75 1.75 0 0 1 12.25 14H10.5a.5.5 0 0 1 0-1h1.75a.75.75 0 0 0 .75-.75V10.5a.5.5 0 0 1 .5-.5"
/>
</svg>
)
}
20 changes: 20 additions & 0 deletions web/components/icons/fluent-plus-16-regular.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { SVGProps } from 'react'

export function FluentAdd16Regular(props: SVGProps<SVGSVGElement>) {
return (
<svg
role="img"
aria-label="icon"
xmlns="http://www.w3.org/2000/svg"
width="16px"
height="16px"
viewBox="0 0 16 16"
{...props}
>
<path
fill="currentColor"
d="M8 2.5a.5.5 0 0 0-1 0V7H2.5a.5.5 0 0 0 0 1H7v4.5a.5.5 0 0 0 1 0V8h4.5a.5.5 0 0 0 0-1H8z"
/>
</svg>
)
}
20 changes: 20 additions & 0 deletions web/components/icons/fluent-subtract-16-regular.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { SVGProps } from 'react'

export function FluentSubtract16Regular(props: SVGProps<SVGSVGElement>) {
return (
<svg
role="img"
aria-label="icon"
xmlns="http://www.w3.org/2000/svg"
width="16px"
height="16px"
viewBox="0 0 16 16"
{...props}
>
<path
fill="currentColor"
d="M3 8a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9A.5.5 0 0 1 3 8"
/>
</svg>
)
}
Loading

0 comments on commit 82a0466

Please sign in to comment.