Skip to content

Commit

Permalink
feat(client): renders and laysout the module (#391)
Browse files Browse the repository at this point in the history
This PR just handles formatting module data, and laying out dom nodes
for the module visualization from the wireframes.

It mostly create a new data type `Item[]` that's used to render the
modules for properly spaced for the module page.
```ts
interface Call {
  module: string
  name: string
}

interface VerbItem {
  name?: string
  'data-id': string
  calls: Call[]
}

interface Item {
  'data-id': string
  name: string
  style: { marginLeft: number }
  verbs: VerbItem[]
}

``` 

It also sorts them based on depth. It take the first found ancestor and
places a dependent module under it. It should handle cycles and
recursion correctly.

In the next PR I submit I'll be drawing directional lines from the
ancestors to dependents and also from from low depth nodes high depth
modules and from high depth modules to any low depth modules (verbs)
they call. That's outside of the scope of this PR. However what it
should do is show for example as seen in the attached video a line being
drawn from `recommendation` to `"productcatalog"` and from `checkout` to
`"productcatalog"` even though shipping is indented under checkout.

This PR mostly tackled the initial styles of the modules in the viz and
the sorting algorithm to lay them out correctly. I still think that
could use some work but It's generating the structure correctly with our
test data right now.

Note in the types in this comment I'm including data-id those are going
to be used to target the correct node to draw a line with. I'll be using
at transparent no pointer event canvas to reduce node count and allow
interactions.
  • Loading branch information
EdwardIrby authored Sep 18, 2023
1 parent aaef01c commit b3c9c63
Show file tree
Hide file tree
Showing 2 changed files with 194 additions and 1 deletion.
43 changes: 42 additions & 1 deletion console/client/src/features/modules/ModulesPage.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,52 @@
import React from 'react'
import { Square3Stack3DIcon } from '@heroicons/react/24/outline'
import { PageHeader } from '../../components/PageHeader'
import { modulesContext } from '../../providers/modules-provider'
import { Disclosure } from '@headlessui/react'
import { createLayoutDataStructure } from './create-layout-data-structure'

export const ModulesPage = () => {
const modules = React.useContext(modulesContext)
const data = createLayoutDataStructure(modules)

return (
<>
<PageHeader icon={<Square3Stack3DIcon />} title='Modules' />
<div className='flex h-full'>Modules</div>
<div role='list' className='flex flex-col space-y-3 p-2'>
{data?.map(({ name, style, verbs, 'data-id': dataId }) => (
<Disclosure
as='div'
key={name}
style={{ ...style }}
data-id={dataId}
className='min-w-fit w-44 border border-gray-100 dark:border-slate-700 rounded overflow-hidden inline-block'
>
<Disclosure.Button
as='button'
className='text-gray-600 dark:text-gray-300 p-1 w-full text-left flex justify-between items-center'
>
{name}
</Disclosure.Button>
<Disclosure.Panel as='ul' className='text-gray-400 dark:text-gray-400 text-xs p-1 space-y-1 list-inside'>
{verbs.map(({ name, 'data-id': dataId }) => (
<li key={name} className='flex items-center text-gray-900 dark:text-gray-400'>
<svg
data-id={dataId}
className='w-3.5 h-3.5 mr-2 text-gray-500 dark:text-gray-400 flex-shrink-0'
aria-hidden='true'
xmlns='http://www.w3.org/2000/svg'
fill='currentColor'
viewBox='0 0 20 20'
>
<circle cx='10' cy='10' r='4.5' />
</svg>
{name}
</li>
))}
</Disclosure.Panel>
</Disclosure>
))}
</div>
</>
)
}
152 changes: 152 additions & 0 deletions console/client/src/features/modules/create-layout-data-structure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { GetModulesResponse } from '../../protos/xyz/block/ftl/v1/console/console_pb'

interface Call {
module: string
name: string
}

interface VerbItem {
name?: string
'data-id': string
calls: Call[]
}

interface Item {
'data-id': string
name: string
style: { marginLeft: number }
verbs: VerbItem[]
}

type ModuleMap = Map<number, Set<Item>>
interface Graph {
[key: string]: Set<string>
}

const flattenMap = (map: ModuleMap, graph: Graph): Item[] => {
const sortedKeys = Array.from(map.keys()).sort((a, b) => a - b)
const flattenedList: Item[] = []

for (const key of sortedKeys) {
for (const item of map.get(key)!) {
if (key === 0) {
// Items with key 0 have no ancestors, so we just add them directly to the list
flattenedList.push(item)
} else if (graph[item.name]) {
// Find the ancestor for the current item
const ancestorName = Array.from(graph[item.name])[0]

// Find the index of the ancestor in the flattenedList
let insertionIndex = flattenedList.findIndex((i) => i.name === ancestorName)

// If ancestor is found, find the position after the last dependent of the ancestor
if (insertionIndex !== -1) {
while (
insertionIndex + 1 < flattenedList.length &&
graph[flattenedList[insertionIndex + 1].name] &&
Array.from(graph[flattenedList[insertionIndex + 1].name])[0] === ancestorName
) {
insertionIndex++
}
flattenedList.splice(insertionIndex + 1, 0, item)
} else {
// If ancestor is not found, this is a fallback, though ideally this shouldn't happen
flattenedList.push(item)
}
} else {
// If no ancestor is found in the graph, simply push the item to the list
flattenedList.push(item)
}
}
}

return flattenedList
}

export const createLayoutDataStructure = (data: GetModulesResponse): Item[] => {
const graph: { [key: string]: Set<string> } = {}

// Initialize graph with all module names
data.modules.forEach((module) => {
graph[module.name] = new Set()
})

// Populate graph with relationships based on verbs' metadata
data.modules.forEach((module) => {
module.verbs.forEach((verbEntry) => {
const verb = verbEntry.verb
verb?.metadata.forEach((metadataEntry) => {
if (metadataEntry.value.case === 'calls') {
metadataEntry.value.value.calls.forEach((call) => {
if (call.module) {
graph[call.module].add(module.name)
}
})
}
})
})
})

// Helper function to determine depth of a node in the graph
const determineDepth = (
node: string,
visited: Set<string> = new Set(),
ancestors: Set<string> = new Set(),
): number => {
if (ancestors.has(node)) {
// Cycle detected
return 0
}

let depth = 0
ancestors.add(node)
graph[node].forEach((neighbor) => {
if (!visited.has(neighbor)) {
visited.add(neighbor)
depth = Math.max(depth, 1 + determineDepth(neighbor, visited, ancestors))
}
})
ancestors.delete(node)

return depth
}

const sortedKeys = Object.keys(graph).sort(new Intl.Collator().compare)
const map: Map<number, Set<Item>> = new Map()

sortedKeys.forEach((moduleName) => {
const moduleData = data.modules.find((mod) => mod.name === moduleName)
if (!moduleData) return

const depth = determineDepth(moduleName)
const item: Item = {
'data-id': moduleName,
name: moduleName,
style: { marginLeft: 20 * (depth + 1) },
verbs: [],
}

moduleData.verbs.forEach((verbEntry) => {
const verb = verbEntry.verb
const verbItem: VerbItem = {
name: verb?.name,
'data-id': `${moduleName}.${verb?.name}`,
calls: [],
}
verb?.metadata.forEach((metadataEntry) => {
if (metadataEntry.value.case === 'calls') {
metadataEntry.value.value.calls.forEach((call) => {
verbItem.calls.push({
module: call.module,
name: call.name,
})
})
}
})
item.verbs.push(verbItem)
})
map.has(depth) ? map.get(depth)?.add(item) : map.set(depth, new Set([item]))
})

return flattenMap(map, graph)
}

0 comments on commit b3c9c63

Please sign in to comment.