Skip to content

Commit

Permalink
Merge pull request #2 from alpaca-tc/render-method-ids
Browse files Browse the repository at this point in the history
Render method ids
  • Loading branch information
alpaca-tc authored Apr 9, 2024
2 parents b5b680b + 163a6be commit 3debb86
Show file tree
Hide file tree
Showing 15 changed files with 583 additions and 146 deletions.
2 changes: 2 additions & 0 deletions frontend/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
Button,
CheckBox,
Cluster,
DefinitionList,
EmptyTableBody,
FONT_FAMILY,
FaGearIcon,
Expand All @@ -14,6 +15,7 @@ export {
Input,
LineClamp,
Loader,
ModelessDialog,
NotificationBar,
PageHeading,
Section,
Expand Down
32 changes: 32 additions & 0 deletions frontend/models/combinedDefinition.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,40 @@
import { Module } from './module'

type BaseDotMetadata = {
id: string
}

type DotSourceMetadata = {
type: 'source'
sourceName: string
} & BaseDotMetadata

type DotDependencyMetadata = {
type: 'dependency'
sourceName: string
methodIds: Array<{
name: string
context: 'class' | 'instance'
human: string
}>
} & BaseDotMetadata

type DotModuleMetadata = {
type: 'module'
moduleName: string
} & BaseDotMetadata

export type DotMetadata = DotSourceMetadata | DotDependencyMetadata | DotModuleMetadata

export type CombinedDefinition = {
ids: number[]
titles: string[]
dot: string
dotMetadata: DotMetadata[]
sources: Array<{ sourceName: string; modules: Module[] }>
}

export type DotSource = {
type: 'source'
sourceName: string
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type Props = {
setGraphOptions: React.Dispatch<React.SetStateAction<GraphOptions>>
}

export const ConfigureViewOptionsDialog: React.FC<Props> = ({ isOpen, onClickClose, graphOptions, setGraphOptions }) => {
export const ConfigureGraphOptionsDialog: React.FC<Props> = ({ isOpen, onClickClose, graphOptions, setGraphOptions }) => {
const [temporaryViewOptions, setTemporaryViewOptions] = useState<GraphOptions>(graphOptions)

const handleDialogClose = () => {
Expand Down
31 changes: 8 additions & 23 deletions frontend/pages/Home/components/DefinitionGraph/DefinitionGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { FC, useCallback, useEffect, useState } from 'react'
import { FC, useCallback, useState } from 'react'
import styled from 'styled-components'

import { Button, FaGearIcon, Heading, LineClamp, Section, Text } from '@/components/ui'
import { color } from '@/constants/theme'
import { CombinedDefinition } from '@/models/combinedDefinition'
import { renderDot } from '@/utils/renderDot'

import { ConfigureViewOptionsDialog, GraphOptions } from './ConfigureGraphOptionsDialog'
import { ConfigureGraphOptionsDialog, GraphOptions } from './ConfigureGraphOptionsDialog'
import { ScrollableSvg } from './ScrollableSvg'

type Props = {
Expand All @@ -15,33 +14,19 @@ type Props = {
setGraphOptions: React.Dispatch<React.SetStateAction<GraphOptions>>
}

type DialogType = 'configureViewOptionsDiaglog'
type DialogType = 'configureGraphOptionsDiaglog'

export const DefinitionGraph: FC<Props> = ({ combinedDefinition, graphOptions, setGraphOptions }) => {
const [visibleDialog, setVisibleDialog] = useState<DialogType | null>(null)
const [svg, setSvg] = useState<string>('')

useEffect(() => {
const loadSvg = async () => {
if (combinedDefinition.dot) {
const newSvg = await renderDot(combinedDefinition.dot)
setSvg(newSvg)
} else {
setSvg('')
}
}

loadSvg()
}, [combinedDefinition.dot, setSvg])

const onClickCloseDialog = useCallback(() => {
setVisibleDialog(null)
}, [setVisibleDialog])

return (
<WrapperSection>
<ConfigureViewOptionsDialog
isOpen={visibleDialog === 'configureViewOptionsDiaglog'}
<ConfigureGraphOptionsDialog
isOpen={visibleDialog === 'configureGraphOptionsDiaglog'}
onClickClose={onClickCloseDialog}
graphOptions={graphOptions}
setGraphOptions={setGraphOptions}
Expand All @@ -57,14 +42,14 @@ export const DefinitionGraph: FC<Props> = ({ combinedDefinition, graphOptions, s
<Button
size="s"
square
onClick={() => setVisibleDialog('configureViewOptionsDiaglog')}
onClick={() => setVisibleDialog('configureGraphOptionsDiaglog')}
prefix={<FaGearIcon alt="Open Options" />}
>
Open View Options
Open Graph Options
</Button>
</FixedHeightHeading>
<FlexHeightSvgWrapper>
<ScrollableSvg svg={svg} />
<ScrollableSvg combinedDefinition={combinedDefinition} />
</FlexHeightSvgWrapper>
</WrapperSection>
)
Expand Down
77 changes: 77 additions & 0 deletions frontend/pages/Home/components/DefinitionGraph/MetadataDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { ComponentProps, FC } from 'react'
import styled from 'styled-components'

import { Link } from '@/components/Link'
import { Base, DefinitionList, Heading, ModelessDialog, Stack } from '@/components/ui'
import { path } from '@/constants/path'
import { spacing } from '@/constants/theme'
import { DotMetadata } from '@/models/combinedDefinition'

type Props = {
dotMetadata: DotMetadata | null
isOpen: boolean
onClose: () => void
top: number
left: number
}

export const MetadataDialog: FC<Props> = ({ dotMetadata, isOpen, onClose, top, left }) => {
const items: ComponentProps<typeof DefinitionList>['items'] = []

switch (dotMetadata?.type) {
case 'source': {
items.push({
term: 'Source Name',
description: <Link to={path.sources.show(dotMetadata.sourceName)}>{dotMetadata.sourceName}</Link>,
})
break
}
case 'dependency': {
items.push({
term: 'Dependency Name',
description: <Link to={path.sources.show(dotMetadata.sourceName)}>{dotMetadata.sourceName}</Link>,
})
items.push({
term: 'Method ID',
description: dotMetadata.methodIds.map((methodId) => (
<p key={`${methodId.context}-${methodId.name}`}>{methodId.human}</p>
)),
})
break
}
case 'module': {
items.push({
term: 'Module Name',
description: <Link to={path.modules.show(dotMetadata.moduleName)}>{dotMetadata.moduleName}</Link>,
})
break
}
}

return (
<ModelessDialog
isOpen={!!(isOpen && dotMetadata)}
header={<ModelessHeading>Description</ModelessHeading>}
onClickClose={onClose}
onPressEscape={onClose}
top={top}
left={left}
>
<Wrapper>
<Stack gap={0.5} as="section">
<DefinitionList items={items} />
</Stack>
</Wrapper>
</ModelessDialog>
)
}

const ModelessHeading = styled(Heading)`
font-size: 1em;
margin: 0;
font-weight: normal;
`

const Wrapper = styled.div`
padding: ${spacing.XS};
`
138 changes: 125 additions & 13 deletions frontend/pages/Home/components/DefinitionGraph/ScrollableSvg.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,141 @@
import React, { FC, useCallback, useState } from 'react'
import { ReactSVGPanZoom, TOOL_PAN } from 'react-svg-pan-zoom'
import React, { FC, useCallback, useEffect, useRef, useState } from 'react'
import { ReactSVGPanZoom, TOOL_NONE, TOOL_PAN } from 'react-svg-pan-zoom'
import { ReactSvgPanZoomLoader } from 'react-svg-pan-zoom-loader'
import styled from 'styled-components'

import { useRefSize } from '@/hooks/useRefSize'
import { CombinedDefinition, DotMetadata } from '@/models/combinedDefinition'
import { renderDot } from '@/utils/renderDot'
import { extractSvgSize, getClosestAndSmallestElement, toSVGPoint } from '@/utils/svgHelper'

import { MetadataDialog } from './MetadataDialog'

import type { Tool, Value } from 'react-svg-pan-zoom'

type Props = {
svg: string
combinedDefinition: CombinedDefinition
}

const extractSvgSize = (svg: string) => {
const html: SVGElement = new DOMParser().parseFromString(svg, 'text/html').body.querySelector('svg')!
// Return .cluster, .node, .edge or null
const findClosestElementOnCursor = (event: MouseEvent): SVGGElement | null => {
const svg = (event.target as HTMLElement).closest<SVGSVGElement>('svg')

if (html === null) {
return { width: 0, height: 0 }
// If outside svg, do nothing.
if (!svg) {
return null
}

const width = parseInt(html.getAttribute('width')!.replace(/pt/, ''), 10)!
const height = parseInt(html.getAttribute('height')!.replace(/pt/, ''), 10)!
const elementsUnderCursor = document.elementsFromPoint(event.clientX, event.clientY)
const point = toSVGPoint(svg, event.target! as Element, event.clientX, event.clientY)
const neastElement = getClosestAndSmallestElement(elementsUnderCursor, point)

const neastGeometryElement = neastElement?.closest<SVGGElement>('g.node, g.edge, g.cluster')

return neastGeometryElement ?? null
}

return { width, height }
type ClickedMetadata = {
metadata: DotMetadata
left: number
top: number
}

export const ScrollableSvg: FC<Props> = ({ svg }) => {
export const ScrollableSvg: FC<Props> = ({ combinedDefinition }) => {
const { observeRef, size } = useRefSize<HTMLDivElement>()
const viewerRef = useRef<ReactSVGPanZoom | null>(null)

const [value, setValue] = useState<Value>({} as Value) // NOTE: react-svg-pan-zoom supported blank object as a initial value. but types is not supported.
const [tool, setTool] = useState<Tool>(TOOL_PAN)
const [hoverMetadata, setHoverMetadata] = useState<DotMetadata | null>(null)
const [clickedMetadata, setClickedMetadata] = useState<ClickedMetadata | null>(null)
const [svg, setSvg] = useState<string>('')

const svgSize = extractSvgSize(svg)

const fitToViewerOnMount = useCallback((node: ReactSVGPanZoom) => {
if (node) {
node.fitToViewer('center', 'top')
viewerRef.current = node
} else {
viewerRef.current = null
}
}, [])

// Convert dot to SVG
useEffect(() => {
const loadSvg = async () => {
if (combinedDefinition.dot) {
const newSvg = await renderDot(combinedDefinition.dot)
setSvg(newSvg)
} else {
setSvg('')
}
}

loadSvg()
}, [combinedDefinition.dot, setSvg])

// On click .node, .edge, .cluster
useEffect(() => {
if (tool !== TOOL_NONE) {
setClickedMetadata(null)
return
}

const onClickGeometry = (event: MouseEvent) => {
if (hoverMetadata) {
event.preventDefault()
setClickedMetadata({ metadata: hoverMetadata, left: event.clientX, top: event.clientY })
}
}

document.addEventListener('click', onClickGeometry)

return () => {
document.removeEventListener('click', onClickGeometry)
}
}, [tool, hoverMetadata, setClickedMetadata])

// On hover .node, .edge, .cluster
useEffect(() => {
if (tool !== TOOL_NONE) {
setHoverMetadata(null)
return
}

const onMouseMove = (event: MouseEvent) => {
const element = findClosestElementOnCursor(event)

if (element) {
const metadata = combinedDefinition.dotMetadata.find(({ id }) => element.id === id)
setHoverMetadata(metadata ?? null)
} else {
setHoverMetadata(null)
}
}

document.addEventListener('mousemove', onMouseMove)

return () => {
document.removeEventListener('mousemove', onMouseMove)
}
}, [tool, combinedDefinition.dotMetadata])

const onCloseDialog = useCallback(() => {
setClickedMetadata(null)
}, [setClickedMetadata])

if (!svg) return null

return (
<Wrapper ref={observeRef}>
<Wrapper ref={observeRef} $idOnHover={hoverMetadata?.id}>
<MetadataDialog
dotMetadata={clickedMetadata?.metadata ?? null}
isOpen={!!clickedMetadata}
onClose={onCloseDialog}
top={clickedMetadata?.top ?? 0}
left={clickedMetadata?.left ?? 0}
/>
<ReactSvgPanZoomLoader
svgXML={svg}
render={(content) => (
Expand All @@ -67,7 +162,24 @@ export const ScrollableSvg: FC<Props> = ({ svg }) => {
)
}

const Wrapper = styled.div`
const Wrapper = styled.div<{ $idOnHover: string | undefined }>`
height: 100%;
width: 100%;
/* overwride pointer-events: none; for oncursormove events */
.node,
.edge,
.cluster {
pointer-events: all;
}
${(props) =>
props.$idOnHover &&
`
#${props.$idOnHover} {
stroke-width: 3;
}
cursor: pointer;
`}
`
Loading

0 comments on commit 3debb86

Please sign in to comment.