Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(editor-3001): dependency tree #27183

Merged
merged 6 commits into from
Dec 31, 2024
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
8 changes: 0 additions & 8 deletions frontend/src/layout/navigation-3000/navigationLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -530,14 +530,6 @@ export const navigation3000Logic = kea<navigation3000LogicType>([
logic: editorSidebarLogic,
}
: null,
featureFlags[FEATURE_FLAGS.DATA_MODELING] && hasOnboardedAnyProduct
? {
identifier: Scene.DataModel,
label: 'Data model',
icon: <IconServer />,
to: isUsingSidebar ? undefined : urls.dataModel(),
}
: null,
hasOnboardedAnyProduct
? {
identifier: Scene.Pipeline,
Expand Down
1 change: 0 additions & 1 deletion frontend/src/scenes/appScenes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ export const appScenes: Record<Scene, () => any> = {
[Scene.Survey]: () => import('./surveys/Survey'),
[Scene.CustomCss]: () => import('./themes/CustomCssScene'),
[Scene.SurveyTemplates]: () => import('./surveys/SurveyTemplates'),
[Scene.DataModel]: () => import('./data-model/DataModelScene'),
[Scene.DataWarehouse]: () => import('./data-warehouse/external/DataWarehouseExternalScene'),
[Scene.SQLEditor]: () => import('./data-warehouse/editor/EditorScene'),
[Scene.DataWarehouseTable]: () => import('./data-warehouse/new/NewSourceWizard'),
Expand Down
27 changes: 0 additions & 27 deletions frontend/src/scenes/data-model/DataModelScene.tsx

This file was deleted.

311 changes: 311 additions & 0 deletions frontend/src/scenes/data-model/NodeCanvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
import { clsx } from 'clsx'
import { useEffect, useRef, useState } from 'react'

import { Edge, Node, NodePosition, NodePositionWithBounds, NodeWithDepth } from './types'

const VERTICAL_SPACING = 150
const HORIZONTAL_SPACING = 250

// Core graph layout calculation functions
const assignDepths = (nodes: Node[]): NodeWithDepth[] => {
const nodeMap: { [id: string]: NodeWithDepth } = {}

nodes.forEach((node) => {
nodeMap[node.nodeId] = { ...node, depth: -1 }
})

const assignDepthRecursive = (nodeId: string, currentDepth: number): void => {
const node = nodeMap[nodeId]
if (!node) {
return
}
node.depth = currentDepth

node.leaf.forEach((leafId) => {
if (nodeMap[leafId]) {
assignDepthRecursive(leafId, currentDepth + 1)
}
})
}

nodes.forEach((node) => {
if (nodeMap[node.nodeId].depth === -1) {
assignDepthRecursive(node.nodeId, 0)
}
})

return Object.values(nodeMap)
}

const calculateNodePositions = (nodesWithDepth: NodeWithDepth[]): NodePosition[] => {
const padding = 50
nodesWithDepth.sort((a, b) => a.depth - b.depth)

const nodePositions: NodePosition[] = []
const visited: string[] = []

const dfs = (nodeId: string, row: number = 0): number => {
if (visited.includes(nodeId)) {
return row
}
visited.push(nodeId)

const node = nodesWithDepth.find((n) => n.nodeId === nodeId)
if (!node) {
return row
}

const nodePosition = {
...node,
position: {
x: padding + node.depth * HORIZONTAL_SPACING,
y: padding + row * VERTICAL_SPACING,
},
}

nodePositions.push(nodePosition)

let maxRow = row
node.leaf
.filter((leafId) => !leafId.includes('_joined'))
.forEach((leafId, index) => {
dfs(leafId, row + index)
maxRow = Math.max(maxRow, row + index)
})

return maxRow
}

let maxRow = 0
nodesWithDepth.forEach((node) => {
if (node.depth === 0) {
maxRow = dfs(node.nodeId, maxRow) + 1
}
})

return nodePositions
}

const calculateBound = (node: NodePosition, ref: HTMLDivElement | null): NodePositionWithBounds => {
if (!ref) {
return {
...node,
left: null,
right: null,
}
}

const { x, y } = node.position
const { width, height } = ref.getBoundingClientRect()
return {
...node,
left: { x, y: y + height / 2 },
right: { x: x + width, y: y + height / 2 },
}
}

const calculateEdgesFromTo = (from: NodePositionWithBounds, to: NodePositionWithBounds): Edge[] => {
if (!from.right || !to.left) {
return []
}

const edges = []
edges.push({
from: from.right,
to: to.left,
})

return edges
}

const calculateEdges = (nodeRefs: (HTMLDivElement | null)[], nodes: NodePosition[]): Edge[] => {
const nodes_map = nodes.reduce((acc: Record<string, NodePosition>, node) => {
acc[node.nodeId] = node
return acc
}, {})

const dfs = (nodeId: string, visited: Set<string> = new Set()): Edge[] => {
if (visited.has(nodeId)) {
return []
}
visited.add(nodeId)

const node = nodes_map[nodeId]
if (!node) {
return []
}

const nodeRef = nodeRefs.find((ref) => ref?.id === nodeId)
if (!nodeRef) {
return []
}

const edges: Edge[] = []
const fromWithBounds = calculateBound(node, nodeRef)

for (const leafId of node.leaf) {
const toNode = nodes_map[leafId]
const toRef = nodeRefs.find((ref) => ref?.id === leafId)
if (toNode && toRef) {
const toWithBounds = calculateBound(toNode, toRef)
edges.push(...calculateEdgesFromTo(fromWithBounds, toWithBounds))
}

edges.push(...dfs(leafId, visited))
}

return edges
}

const edges: Edge[] = []
const visited = new Set<string>()

for (const node of nodes) {
if (!visited.has(node.nodeId)) {
edges.push(...dfs(node.nodeId, visited))
}
}

return edges
}

interface NodeCanvasProps<T extends Node> {
nodes: T[]
renderNode: (node: T & NodePosition, ref: (el: HTMLDivElement | null) => void) => JSX.Element
}

export function NodeCanvas<T extends Node>({ nodes, renderNode }: NodeCanvasProps<T>): JSX.Element {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
const nodeRefs = useRef<(HTMLDivElement | null)[]>(Array(nodes.length).fill(null))
const [nodePositions, setNodePositions] = useState<NodePosition[]>([])
const [edges, setEdges] = useState<Edge[]>([])

useEffect(() => {
const nodesWithDepth = assignDepths(nodes)
const positions = calculateNodePositions(nodesWithDepth)
setNodePositions(positions)
}, [nodes, offset])

useEffect(() => {
const allNodes = [...nodePositions]
const calculatedEdges = calculateEdges([...nodeRefs.current], allNodes)
setEdges(calculatedEdges)
}, [nodePositions])

const drawGrid = (ctx: CanvasRenderingContext2D, canvasWidth: number, canvasHeight: number): void => {
ctx.fillStyle = '#000000'
ctx.imageSmoothingEnabled = true
const dotSize = 0.5
const spacing = 10

for (let x = offset.x % spacing; x < canvasWidth; x += spacing) {
for (let y = offset.y % spacing; y < canvasHeight; y += spacing) {
ctx.fillRect(x, y, dotSize, dotSize)
}
}
}

useEffect(() => {
const canvas = canvasRef.current
if (!canvas) {
return
}

const ctx = canvas.getContext('2d')
if (!ctx) {
return
}

const { width, height } = canvas.getBoundingClientRect()
canvas.width = width
canvas.height = height
drawGrid(ctx, width, height)

const handleResize = (): void => {
if (canvas) {
const { width, height } = canvas.getBoundingClientRect()
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (ctx) {
drawGrid(ctx, width, height)
}
}
}

window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [offset, nodePositions])

const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>): void => {
setIsDragging(true)
setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y })
}

const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>): void => {
if (!isDragging) {
return
}
const newOffset = {
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
}
setOffset(newOffset)
}

const handleMouseUp = (): void => {
setIsDragging(false)
}

return (
<div className="w-full h-full relative">
<canvas
ref={canvasRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className={clsx('w-full h-full absolute inset-0', isDragging ? 'cursor-grabbing' : 'cursor-grab')}
/>
<svg className="absolute inset-0 w-full h-full pointer-events-none">
{edges.map((edge, index) => {
const controlPoint1X = edge.from.x + offset.x + (edge.to.x - edge.from.x) / 3
const controlPoint1Y = edge.from.y + offset.y
const controlPoint2X = edge.to.x + offset.x - (edge.to.x - edge.from.x) / 3
const controlPoint2Y = edge.to.y + offset.y
return (
<path
key={index}
d={`M ${edge.from.x + offset.x} ${edge.from.y + offset.y}
C ${controlPoint1X} ${controlPoint1Y},
${controlPoint2X} ${controlPoint2Y},
${edge.to.x + offset.x} ${edge.to.y + offset.y}`}
stroke="var(--text-3000)"
strokeWidth="2"
fill="none"
/>
)
})}
</svg>
{nodePositions.map((nodePosition, idx) => (
<div
key={nodePosition.nodeId}
className="absolute"
// eslint-disable-next-line react/forbid-dom-props
style={{
left: `${nodePosition.position.x + offset.x}px`,
top: `${nodePosition.position.y + offset.y}px`,
}}
>
{renderNode(nodePosition as T & NodePosition, (el) => {
nodeRefs.current[idx] = el
nodeRefs.current[idx]?.setAttribute('id', nodePosition.nodeId)
})}
</div>
))}
</div>
)
}
Loading
Loading