From 3b76bd0cb47cd17b12b2f751c5984569545e2469 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Thu, 4 Apr 2024 00:22:18 +0900 Subject: [PATCH 1/4] Render metadata to id --- lib/diver_down/web/definition_to_dot.rb | 12 +++++++----- spec/diver_down/web/definition_to_dot_spec.rb | 8 ++++---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/diver_down/web/definition_to_dot.rb b/lib/diver_down/web/definition_to_dot.rb index cb443c0..f652835 100644 --- a/lib/diver_down/web/definition_to_dot.rb +++ b/lib/diver_down/web/definition_to_dot.rb @@ -156,12 +156,10 @@ def insert_modules(source) last_module_writer = proc do io.puts %(#{' ' unless modules.empty?}subgraph "#{module_label(last_module)}" {), indent: false io.indented do - source_attributes = build_attributes(label: last_module.module_name, _wrap: false) - module_attributes = build_attributes(label: source.source_name) + module_attributes = build_attributes(label: last_module.module_name, _wrap: false) + source_attributes = build_attributes(label: source.source_name, id: build_id(:source, source.source_name)) - io.write %(#{source_attributes} "#{source.source_name}") - io.write(" #{module_attributes}", indent: false) if module_attributes - io.write "\n" + io.puts %(#{module_attributes} "#{source.source_name}" #{source_attributes}) end io.puts '}' end @@ -222,6 +220,10 @@ def module_label(*modules) "cluster_#{modules[0].module_name}" end + + def build_id(type, identity) + "#{type}-#{identity}" + end end end end diff --git a/spec/diver_down/web/definition_to_dot_spec.rb b/spec/diver_down/web/definition_to_dot_spec.rb index 12706ef..48af4b3 100644 --- a/spec/diver_down/web/definition_to_dot_spec.rb +++ b/spec/diver_down/web/definition_to_dot_spec.rb @@ -91,7 +91,7 @@ def build_definition(title: 'title', sources: []) strict digraph "title" { subgraph "cluster_A" { label="A" subgraph "cluster_B" { - label="B" "a.rb" [label="a.rb"] + label="B" "a.rb" [label="a.rb" id="source-a.rb"] } } } @@ -139,14 +139,14 @@ def build_definition(title: 'title', sources: []) strict digraph "title" { compound=true subgraph "cluster_A" { - label="A" "a.rb" [label="a.rb"] + label="A" "a.rb" [label="a.rb" id="source-a.rb"] } "a.rb" -> "b.rb" [ltail="cluster_A" lhead="cluster_B" minlen="3"] subgraph "cluster_B" { - label="B" "b.rb" [label="b.rb"] + label="B" "b.rb" [label="b.rb" id="source-b.rb"] } subgraph "cluster_B" { - label="B" "c.rb" [label="c.rb"] + label="B" "c.rb" [label="c.rb" id="source-c.rb"] } } DOT From 5dfa5130cca22527a6dfaf2ae63e516946ae14c9 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Thu, 4 Apr 2024 00:31:07 +0900 Subject: [PATCH 2/4] Render method_ids on click --- frontend/components/ui/index.ts | 2 + frontend/models/combinedDefinition.ts | 32 ++++ .../ConfigureGraphOptionsDialog.tsx | 2 +- .../DefinitionGraph/DefinitionGraph.tsx | 31 +--- .../DefinitionGraph/MetadataDialog.tsx | 77 ++++++++++ .../DefinitionGraph/ScrollableSvg.tsx | 138 ++++++++++++++++-- .../combinedDefinitionRepository.ts | 59 +++++++- frontend/types/react-svg-pan-zoom.d.ts | 2 +- frontend/utils/svgHelper.ts | 73 +++++++++ lib/diver_down/definition/method_id.rb | 2 +- lib/diver_down/web/action.rb | 5 +- lib/diver_down/web/definition_to_dot.rb | 135 ++++++++--------- spec/diver_down/web/definition_to_dot_spec.rb | 94 ++++++++++-- 13 files changed, 526 insertions(+), 126 deletions(-) create mode 100644 frontend/pages/Home/components/DefinitionGraph/MetadataDialog.tsx create mode 100644 frontend/utils/svgHelper.ts diff --git a/frontend/components/ui/index.ts b/frontend/components/ui/index.ts index 6e2f91f..8a7ba74 100644 --- a/frontend/components/ui/index.ts +++ b/frontend/components/ui/index.ts @@ -6,6 +6,7 @@ export { Button, CheckBox, Cluster, + DefinitionList, EmptyTableBody, FONT_FAMILY, FaGearIcon, @@ -14,6 +15,7 @@ export { Input, LineClamp, Loader, + ModelessDialog, NotificationBar, PageHeading, Section, diff --git a/frontend/models/combinedDefinition.ts b/frontend/models/combinedDefinition.ts index e313b1e..93b4c1e 100644 --- a/frontend/models/combinedDefinition.ts +++ b/frontend/models/combinedDefinition.ts @@ -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 +} diff --git a/frontend/pages/Home/components/DefinitionGraph/ConfigureGraphOptionsDialog.tsx b/frontend/pages/Home/components/DefinitionGraph/ConfigureGraphOptionsDialog.tsx index 08364b1..790bd9c 100644 --- a/frontend/pages/Home/components/DefinitionGraph/ConfigureGraphOptionsDialog.tsx +++ b/frontend/pages/Home/components/DefinitionGraph/ConfigureGraphOptionsDialog.tsx @@ -16,7 +16,7 @@ type Props = { setGraphOptions: React.Dispatch> } -export const ConfigureViewOptionsDialog: React.FC = ({ isOpen, onClickClose, graphOptions, setGraphOptions }) => { +export const ConfigureGraphOptionsDialog: React.FC = ({ isOpen, onClickClose, graphOptions, setGraphOptions }) => { const [temporaryViewOptions, setTemporaryViewOptions] = useState(graphOptions) const handleDialogClose = () => { diff --git a/frontend/pages/Home/components/DefinitionGraph/DefinitionGraph.tsx b/frontend/pages/Home/components/DefinitionGraph/DefinitionGraph.tsx index 3a89352..eb06d23 100644 --- a/frontend/pages/Home/components/DefinitionGraph/DefinitionGraph.tsx +++ b/frontend/pages/Home/components/DefinitionGraph/DefinitionGraph.tsx @@ -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 = { @@ -15,24 +14,10 @@ type Props = { setGraphOptions: React.Dispatch> } -type DialogType = 'configureViewOptionsDiaglog' +type DialogType = 'configureGraphOptionsDiaglog' export const DefinitionGraph: FC = ({ combinedDefinition, graphOptions, setGraphOptions }) => { const [visibleDialog, setVisibleDialog] = useState(null) - const [svg, setSvg] = useState('') - - 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) @@ -40,8 +25,8 @@ export const DefinitionGraph: FC = ({ combinedDefinition, graphOptions, s return ( - = ({ combinedDefinition, graphOptions, s - + ) diff --git a/frontend/pages/Home/components/DefinitionGraph/MetadataDialog.tsx b/frontend/pages/Home/components/DefinitionGraph/MetadataDialog.tsx new file mode 100644 index 0000000..f6f3630 --- /dev/null +++ b/frontend/pages/Home/components/DefinitionGraph/MetadataDialog.tsx @@ -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 = ({ dotMetadata, isOpen, onClose, top, left }) => { + const items: ComponentProps['items'] = [] + + switch (dotMetadata?.type) { + case 'source': { + items.push({ + term: 'Source Name', + description: {dotMetadata.sourceName}, + }) + break + } + case 'dependency': { + items.push({ + term: 'Dependency Name', + description: {dotMetadata.sourceName}, + }) + items.push({ + term: 'Method ID', + description: dotMetadata.methodIds.map((methodId) => ( +

{methodId.human}

+ )), + }) + break + } + case 'module': { + items.push({ + term: 'Module Name', + description: {dotMetadata.moduleName}, + }) + break + } + } + + return ( + Description} + onClickClose={onClose} + onPressEscape={onClose} + top={top} + left={left} + > + + + + + + + ) +} + +const ModelessHeading = styled(Heading)` + font-size: 1em; + margin: 0; + font-weight: normal; +` + +const Wrapper = styled.div` + padding: ${spacing.XS}; +` diff --git a/frontend/pages/Home/components/DefinitionGraph/ScrollableSvg.tsx b/frontend/pages/Home/components/DefinitionGraph/ScrollableSvg.tsx index 5b5d8bf..a245181 100644 --- a/frontend/pages/Home/components/DefinitionGraph/ScrollableSvg.tsx +++ b/frontend/pages/Home/components/DefinitionGraph/ScrollableSvg.tsx @@ -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('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('g.node, g.edge, g.cluster') + + return neastGeometryElement ?? null +} - return { width, height } +type ClickedMetadata = { + metadata: DotMetadata + left: number + top: number } -export const ScrollableSvg: FC = ({ svg }) => { +export const ScrollableSvg: FC = ({ combinedDefinition }) => { const { observeRef, size } = useRefSize() + const viewerRef = useRef(null) + const [value, setValue] = useState({} as Value) // NOTE: react-svg-pan-zoom supported blank object as a initial value. but types is not supported. const [tool, setTool] = useState(TOOL_PAN) + const [hoverMetadata, setHoverMetadata] = useState(null) + const [clickedMetadata, setClickedMetadata] = useState(null) + const [svg, setSvg] = useState('') 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 ( - + + ( @@ -67,7 +162,24 @@ export const ScrollableSvg: FC = ({ 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; + `} ` diff --git a/frontend/repositories/combinedDefinitionRepository.ts b/frontend/repositories/combinedDefinitionRepository.ts index ea8a266..733da43 100644 --- a/frontend/repositories/combinedDefinitionRepository.ts +++ b/frontend/repositories/combinedDefinitionRepository.ts @@ -1,16 +1,41 @@ import useSWR from 'swr' import { path } from '@/constants/path' -import { CombinedDefinition } from '@/models/combinedDefinition' +import { CombinedDefinition, DotMetadata } from '@/models/combinedDefinition' import { bitIdToIds } from '@/utils/bitId' import { stringify } from '@/utils/queryString' import { get } from './httpRequest' +type DotSourceMetadataResponse = { + id: string + type: 'source' + source_name: string +} + +type DotDependencyMetadataResponse = { + id: string + type: 'dependency' + source_name: string + method_ids: Array<{ + name: string + context: 'class' | 'instance' + }> +} + +type DotModuleMetadataResponse = { + id: string + type: 'module' + module_name: string +} + +type DotMetadataResponse = DotSourceMetadataResponse | DotDependencyMetadataResponse | DotModuleMetadataResponse + type CombinedDefinitionReponse = { bit_id: string titles: string[] dot: string + dot_metadata: DotMetadataResponse[] sources: Array<{ source_name: string modules: Array<{ @@ -19,6 +44,37 @@ type CombinedDefinitionReponse = { }> } +const parseDotMetadata = (metadata: DotMetadataResponse): DotMetadata => { + switch (metadata.type) { + case 'source': { + return { + id: metadata.id, + type: metadata.type, + sourceName: metadata.source_name, + } + } + case 'dependency': { + return { + id: metadata.id, + type: metadata.type, + sourceName: metadata.source_name, + methodIds: metadata.method_ids.map((methodId) => ({ + name: methodId.name, + context: methodId.context, + human: `${methodId.context === 'class' ? '.' : '#'}${methodId.name}`, + })), + } + } + case 'module': { + return { + id: metadata.id, + type: metadata.type, + moduleName: metadata.module_name, + } + } + } +} + const fetchDefinitionShow = async (requestPath: string): Promise => { const response = await get(requestPath) @@ -26,6 +82,7 @@ const fetchDefinitionShow = async (requestPath: string): Promise parseDotMetadata(res)), sources: response.sources.map((source) => ({ sourceName: source.source_name, modules: source.modules.map((module) => ({ diff --git a/frontend/types/react-svg-pan-zoom.d.ts b/frontend/types/react-svg-pan-zoom.d.ts index 4bd7e6c..0dca2c2 100644 --- a/frontend/types/react-svg-pan-zoom.d.ts +++ b/frontend/types/react-svg-pan-zoom.d.ts @@ -1,9 +1,9 @@ -import * as React from 'react' import { ReactSVGPanZoom as original } from 'react-svg-pan-zoom' declare module 'react-svg-pan-zoom' { interface ReactSVGPanZoom { // @types/react-svg-pan-zoom is not supported alignX and alignY fitToViewer(alignX: 'left' | 'center' | 'right', alignY: 'top' | 'center' | 'bottom'): void + ViewerDOM: SVGElement | undefined } } diff --git a/frontend/utils/svgHelper.ts b/frontend/utils/svgHelper.ts new file mode 100644 index 0000000..df5fa6b --- /dev/null +++ b/frontend/utils/svgHelper.ts @@ -0,0 +1,73 @@ +const BLANK_TRANSLATE = { x: 0, y: 0 } as const + +// Get the translate value of the element +const getTranslate = (el: Element): { x: number; y: number } => { + const transform = el.getAttribute('transform') + const translate = /translate\(([^, ]+)(?:,|\s+)([^)]+)\)/.exec(transform ?? '') + + if (translate) { + return { x: parseFloat(translate[1]), y: parseFloat(translate[2]) } + } else { + return BLANK_TRANSLATE + } +} + +// Convert the Point of clientX and clientY to SVG coordinate +export const toSVGPoint = (svg: SVGSVGElement, el: Element, x: number, y: number) => { + let point = svg.createSVGPoint() + point.x = x + point.y = y + const ctm = svg.getScreenCTM()!.inverse() + point = point.matrixTransform(ctm) + + let current: Element | null = el + while (current && svg.contains(current)) { + const translate = getTranslate(current) + point.x -= translate.x + point.y -= translate.y + + current = current.parentElement?.closest('[transform]') ?? null + } + + return point +} + +export const extractSvgSize = (svg: string) => { + const html: SVGElement = new DOMParser().parseFromString(svg, 'text/html').body.querySelector('svg')! + + if (html === null) { + return { width: 0, height: 0 } + } + + const width = parseInt(html.getAttribute('width')!.replace(/pt/, ''), 10)! + const height = parseInt(html.getAttribute('height')!.replace(/pt/, ''), 10)! + + return { width, height } +} + +const SVGGraphTagNames = ['ellipse', 'path', 'polygon', 'polyline', 'rect', 'circle', 'line'] +export const isSVGGeometryElement = (el: Element): el is SVGGeometryElement => SVGGraphTagNames.includes(el.tagName) + +export const getClosestAndSmallestElement = (elements: Element[], point: DOMPoint): Element | null => { + let closestElement: Element | null = null + let minDistance = Infinity + let minArea = Infinity + + elements.forEach((element) => { + if (isSVGGeometryElement(element)) { + const bbox = element.getBBox() + const centerX = bbox.x + bbox.width / 2 + const centerY = bbox.y + bbox.height / 2 + const distance = Math.sqrt(Math.pow(centerX - point.x, 2) + Math.pow(centerY - point.y, 2)) + const area = bbox.width * bbox.height + + if (element.isPointInFill(point) && (area < minArea || (area === minArea && distance < minDistance))) { + closestElement = element + minDistance = distance + minArea = area + } + } + }) + + return closestElement +} diff --git a/lib/diver_down/definition/method_id.rb b/lib/diver_down/definition/method_id.rb index accdb29..f92a7aa 100644 --- a/lib/diver_down/definition/method_id.rb +++ b/lib/diver_down/definition/method_id.rb @@ -60,7 +60,7 @@ def hash # @param other [DiverDown::Definition::MethodId] # @return [Integer] def <=>(other) - [name, context] <=> [other.name, other.context] + [context, name] <=> [other.context, other.name] end # @param other [Object, DiverDown::Definition::Source] diff --git a/lib/diver_down/web/action.rb b/lib/diver_down/web/action.rb index 8bb8e6e..85191c0 100644 --- a/lib/diver_down/web/action.rb +++ b/lib/diver_down/web/action.rb @@ -166,10 +166,13 @@ def combine_definitions(bit_id, compound, concentrate) end if definition + definition_to_dot = DiverDown::Web::DefinitionToDot.new(definition, compound:, concentrate:) + json( titles:, bit_id: DiverDown::Web::BitId.ids_to_bit_id(valid_ids).to_s, - dot: DiverDown::Web::DefinitionToDot.new(definition, compound:, concentrate:).to_s, + dot: definition_to_dot.to_s, + dot_metadata: definition_to_dot.metadata, sources: definition.sources.map do { source_name: _1.source_name, diff --git a/lib/diver_down/web/definition_to_dot.rb b/lib/diver_down/web/definition_to_dot.rb index f652835..f805d99 100644 --- a/lib/diver_down/web/definition_to_dot.rb +++ b/lib/diver_down/web/definition_to_dot.rb @@ -1,82 +1,71 @@ # frozen_string_literal: true +require 'json' +require 'cgi' + module DiverDown class Web class DefinitionToDot ATTRIBUTE_DELIMITER = ' ' - class SourceDecorator < BasicObject - attr_reader :dependencies - - # @param source [DiverDown::Definition::Source] - def initialize(source) - @source = source - @dependencies = source.dependencies.map { DependencyDecorator.new(_1) } - end + class MetadataStore + attr_reader :to_a - # @return [String] - def label - @source.source_name + def initialize + @prefix = 'graph_' + @to_a = [] end - # @return [String, nil] - def tooltip - nil + # @param type [Symbol] + # @param record [DiverDown::Definition::Source, DiverDown::Definition::Dependency, DiverDown::Definition::Modulee] + def issue_id(record) + metadata = case record + when DiverDown::Definition::Source + source_to_metadata(record) + when DiverDown::Definition::Dependency + dependency_to_metadata(record) + when DiverDown::Definition::Modulee + module_to_metadata(record) + else + raise NotImplementedError, "not implemented yet #{record.class}" + end + + id = "#{@prefix}#{@to_a.length + 1}" + @to_a.push(metadata.merge(id:)) + id end - # @param action [Symbol] - # @param args [Array] - # @param options [Hash, nil] - # @param block [Proc, nil] - def method_missing(action, ...) - if @source.respond_to?(action, true) - @source.send(action, ...) - else - super - end - end + private - # @param action [Symbol] - # @param include_private [Boolean] - # @return [Boolean] - def respond_to_missing?(action, include_private = false) - super || @source.respond_to?(action, include_private) + def length + @to_a.length end - end - class DependencyDecorator < BasicObject - # @param dependency [DiverDown::Definition::dependency] - def initialize(dependency) - @dependency = dependency + def source_to_metadata(record) + { + type: 'source', + source_name: record.source_name, + } end - # @return [String] - def label - @dependency.dependency + def dependency_to_metadata(record) + { + type: 'dependency', + source_name: record.source_name, + method_ids: record.method_ids.sort.map do + { + name: _1.name, + context: _1.context, + } + end, + } end - # @return [String, nil] - def tooltip - nil - end - - # @param action [Symbol] - # @param args [Array] - # @param options [Hash, nil] - # @param block [Proc, nil] - def method_missing(action, ...) - if @dependency.respond_to?(action, true) - @dependency.send(action, ...) - else - super - end - end - - # @param action [Symbol] - # @param include_private [Boolean] - # @return [Boolean] - def respond_to_missing?(action, include_private = false) - super || @dependency.respond_to?(action, include_private) + def module_to_metadata(record) + { + type: 'module', + module_name: record.module_name, + } end end @@ -90,13 +79,17 @@ def initialize(definition, compound: false, concentrate: false) @compound = compound @compound_map = Hash.new { |h, k| h[k] = Set.new } # { ltail => Set } @concentrate = concentrate + @metadata_store = MetadataStore.new + end + + # @return [Array] + def metadata + @metadata_store.to_a end # @return [String] def to_s - sources = definition.sources - .sort_by(&:source_name) - .map { SourceDecorator.new(_1) } + sources = definition.sources.sort_by(&:source_name) io.puts %(strict digraph "#{definition.title}" {) io.indented do @@ -116,13 +109,15 @@ def to_s def insert_source(source) if source.modules.empty? - io.puts %("#{source.source_name}" #{build_attributes(label: source.label)}) + io.puts %("#{source.source_name}" #{build_attributes(label: source.source_name, id: @metadata_store.issue_id(source))}) else insert_modules(source) end source.dependencies.each do - attributes = {} + attributes = { + id: @metadata_store.issue_id(_1) + } ltail = module_label(*source.modules) lhead = module_label(*definition.source(_1.source_name).modules) skip_rendering = false @@ -156,8 +151,8 @@ def insert_modules(source) last_module_writer = proc do io.puts %(#{' ' unless modules.empty?}subgraph "#{module_label(last_module)}" {), indent: false io.indented do - module_attributes = build_attributes(label: last_module.module_name, _wrap: false) - source_attributes = build_attributes(label: source.source_name, id: build_id(:source, source.source_name)) + module_attributes = build_attributes(label: last_module.module_name, id: @metadata_store.issue_id(last_module), _wrap: false) + source_attributes = build_attributes(label: source.source_name, id: @metadata_store.issue_id(source)) io.puts %(#{module_attributes} "#{source.source_name}" #{source_attributes}) end @@ -169,7 +164,7 @@ def insert_modules(source) proc do io.puts %(subgraph "#{module_label(mod)}" {) io.indented do - attributes = build_attributes(label: mod.module_name, _wrap: false) + attributes = build_attributes(label: mod.module_name, id: @metadata_store.issue_id(mod), _wrap: false) io.write attributes next_writer.call end @@ -220,10 +215,6 @@ def module_label(*modules) "cluster_#{modules[0].module_name}" end - - def build_id(type, identity) - "#{type}-#{identity}" - end end end end diff --git a/spec/diver_down/web/definition_to_dot_spec.rb b/spec/diver_down/web/definition_to_dot_spec.rb index 48af4b3..10d59f8 100644 --- a/spec/diver_down/web/definition_to_dot_spec.rb +++ b/spec/diver_down/web/definition_to_dot_spec.rb @@ -34,11 +34,22 @@ def build_definition(title: 'title', sources: []) ] ) - expect(described_class.new(definition).to_s).to eq(<<~DOT) + instance = described_class.new(definition) + expect(instance.to_s).to eq(<<~DOT) strict digraph "title" { - "a.rb" [label="a.rb"] + "a.rb" [label="a.rb" id="graph_1"] } DOT + + expect(instance.metadata).to eq( + [ + { + id: 'graph_1', + type: 'source', + source_name: 'a.rb', + }, + ] + ) end end @@ -60,13 +71,28 @@ def build_definition(title: 'title', sources: []) ] ) - expect(described_class.new(definition).to_s).to eq(<<~DOT) + instance = described_class.new(definition) + expect(instance.to_s).to eq(<<~DOT) strict digraph "title" { - "a.rb" [label="a.rb"] + "a.rb" [label="a.rb" id="graph_1"] "a.rb" -> "b.rb" - "b.rb" [label="b.rb"] + "b.rb" [label="b.rb" id="graph_2"] } DOT + + expect(instance.metadata).to eq( + [ + { + id: 'graph_1', + type: 'source', + source_name: 'a.rb', + }, { + id: 'graph_2', + type: 'source', + source_name: 'b.rb', + }, + ] + ) end end @@ -87,15 +113,27 @@ def build_definition(title: 'title', sources: []) ] ) - expect(described_class.new(definition).to_s).to eq(<<~DOT) + instance = described_class.new(definition) + + expect(instance.to_s).to eq(<<~DOT) strict digraph "title" { subgraph "cluster_A" { label="A" subgraph "cluster_B" { - label="B" "a.rb" [label="a.rb" id="source-a.rb"] + label="B" "a.rb" [label="a.rb" id="graph_1"] } } } DOT + + expect(instance.metadata).to eq( + [ + { + id: 'graph_1', + type: 'source', + source_name: 'a.rb', + }, + ] + ) end it 'returns compound digraph if compound = true' do @@ -135,21 +173,40 @@ def build_definition(title: 'title', sources: []) ] ) - expect(described_class.new(definition, compound: true).to_s).to eq(<<~DOT) + instance = described_class.new(definition, compound: true) + expect(instance.to_s).to eq(<<~DOT) strict digraph "title" { compound=true subgraph "cluster_A" { - label="A" "a.rb" [label="a.rb" id="source-a.rb"] + label="A" "a.rb" [label="a.rb" id="graph_1"] } "a.rb" -> "b.rb" [ltail="cluster_A" lhead="cluster_B" minlen="3"] subgraph "cluster_B" { - label="B" "b.rb" [label="b.rb" id="source-b.rb"] + label="B" "b.rb" [label="b.rb" id="graph_2"] } subgraph "cluster_B" { - label="B" "c.rb" [label="c.rb" id="source-c.rb"] + label="B" "c.rb" [label="c.rb" id="graph_3"] } } DOT + + expect(instance.metadata).to eq( + [ + { + id: 'graph_1', + type: 'source', + source_name: 'a.rb', + }, { + id: 'graph_2', + type: 'source', + source_name: 'b.rb', + }, { + id: 'graph_3', + type: 'source', + source_name: 'c.rb', + }, + ] + ) end it 'returns concentrate digraph if concentrate = true' do @@ -161,12 +218,23 @@ def build_definition(title: 'title', sources: []) ] ) - expect(described_class.new(definition, concentrate: true).to_s).to eq(<<~DOT) + instance = described_class.new(definition, concentrate: true) + expect(instance.to_s).to eq(<<~DOT) strict digraph "title" { concentrate=true - "a.rb" [label="a.rb"] + "a.rb" [label="a.rb" id="graph_1"] } DOT + + expect(instance.metadata).to eq( + [ + { + id: 'graph_1', + type: 'source', + source_name: 'a.rb', + }, + ] + ) end end end From 8e5f3864a1dd3b6d4371dcbedc70b3f4150bb10c Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Tue, 9 Apr 2024 12:17:53 +0900 Subject: [PATCH 3/4] fix rubocop --- lib/diver_down/web/definition_to_dot.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/diver_down/web/definition_to_dot.rb b/lib/diver_down/web/definition_to_dot.rb index f805d99..fc9b036 100644 --- a/lib/diver_down/web/definition_to_dot.rb +++ b/lib/diver_down/web/definition_to_dot.rb @@ -116,7 +116,7 @@ def insert_source(source) source.dependencies.each do attributes = { - id: @metadata_store.issue_id(_1) + id: @metadata_store.issue_id(_1), } ltail = module_label(*source.modules) lhead = module_label(*definition.source(_1.source_name).modules) From 163a6bed791b0c64748e7ed8102e9369a32a6bd5 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Tue, 9 Apr 2024 12:28:16 +0900 Subject: [PATCH 4/4] fix rspec --- spec/diver_down/definition/method_id_spec.rb | 4 +- spec/diver_down/trace/tracer_spec.rb | 32 +++++------ spec/diver_down/web/definition_to_dot_spec.rb | 55 +++++++++++++++---- 3 files changed, 63 insertions(+), 28 deletions(-) diff --git a/spec/diver_down/definition/method_id_spec.rb b/spec/diver_down/definition/method_id_spec.rb index 3a173b0..955a3e2 100644 --- a/spec/diver_down/definition/method_id_spec.rb +++ b/spec/diver_down/definition/method_id_spec.rb @@ -62,8 +62,8 @@ ), ].shuffle - expect(array.sort.map(&:name)).to eq(%w[a b b c]) - expect(array.sort.map(&:context)).to eq(%w[class class instance class]) + expect(array.sort.map(&:name)).to eq(%w[a b c b]) + expect(array.sort.map(&:context)).to eq(%w[class class class instance]) end end diff --git a/spec/diver_down/trace/tracer_spec.rb b/spec/diver_down/trace/tracer_spec.rb index 0953bee..625cd47 100644 --- a/spec/diver_down/trace/tracer_spec.rb +++ b/spec/diver_down/trace/tracer_spec.rb @@ -378,15 +378,15 @@ def fill_default(hash) source_name: 'AntipollutionModule::B', method_ids: [ { - name: 'call_c', - context: 'instance', + name: 'new', + context: 'class', paths: [ match(/tracer_instance\.rb:\d+/), ], }, { - name: 'new', - context: 'class', + name: 'call_c', + context: 'instance', paths: [ match(/tracer_instance\.rb:\d+/), ], @@ -402,15 +402,15 @@ def fill_default(hash) source_name: 'AntipollutionModule::C', method_ids: [ { - name: 'call_d', - context: 'instance', + name: 'new', + context: 'class', paths: [ match(/tracer_instance\.rb:\d+/), ], }, { - name: 'new', - context: 'class', + name: 'call_d', + context: 'instance', paths: [ match(/tracer_instance\.rb:\d+/), ], @@ -462,15 +462,15 @@ def fill_default(hash) source_name: 'AntipollutionModule::B', method_ids: [ { - name: 'call_c', - context: 'instance', + name: 'new', + context: 'class', paths: [ match(/tracer_subclass\.rb:\d+/), ], }, { - name: 'new', - context: 'class', + name: 'call_c', + context: 'instance', paths: [ match(/tracer_subclass\.rb:\d+/), ], @@ -486,15 +486,15 @@ def fill_default(hash) source_name: 'AntipollutionModule::C', method_ids: [ { - name: 'call_d', - context: 'instance', + name: 'new', + context: 'class', paths: [ match(/tracer_subclass\.rb:\d+/), ], }, { - name: 'new', - context: 'class', + name: 'call_d', + context: 'instance', paths: [ match(/tracer_subclass\.rb:\d+/), ], diff --git a/spec/diver_down/web/definition_to_dot_spec.rb b/spec/diver_down/web/definition_to_dot_spec.rb index 10d59f8..d92cf38 100644 --- a/spec/diver_down/web/definition_to_dot_spec.rb +++ b/spec/diver_down/web/definition_to_dot_spec.rb @@ -75,8 +75,8 @@ def build_definition(title: 'title', sources: []) expect(instance.to_s).to eq(<<~DOT) strict digraph "title" { "a.rb" [label="a.rb" id="graph_1"] - "a.rb" -> "b.rb" - "b.rb" [label="b.rb" id="graph_2"] + "a.rb" -> "b.rb" [id="graph_2"] + "b.rb" [label="b.rb" id="graph_3"] } DOT @@ -88,6 +88,11 @@ def build_definition(title: 'title', sources: []) source_name: 'a.rb', }, { id: 'graph_2', + type: 'dependency', + source_name: 'b.rb', + method_ids: [], + }, { + id: 'graph_3', type: 'source', source_name: 'b.rb', }, @@ -118,8 +123,8 @@ def build_definition(title: 'title', sources: []) expect(instance.to_s).to eq(<<~DOT) strict digraph "title" { subgraph "cluster_A" { - label="A" subgraph "cluster_B" { - label="B" "a.rb" [label="a.rb" id="graph_1"] + label="A" id="graph_1" subgraph "cluster_B" { + label="B" id="graph_2" "a.rb" [label="a.rb" id="graph_3"] } } } @@ -129,6 +134,14 @@ def build_definition(title: 'title', sources: []) [ { id: 'graph_1', + type: 'module', + module_name: 'A', + }, { + id: 'graph_2', + type: 'module', + module_name: 'B', + }, { + id: 'graph_3', type: 'source', source_name: 'a.rb', }, @@ -178,14 +191,14 @@ def build_definition(title: 'title', sources: []) strict digraph "title" { compound=true subgraph "cluster_A" { - label="A" "a.rb" [label="a.rb" id="graph_1"] + label="A" id="graph_1" "a.rb" [label="a.rb" id="graph_2"] } - "a.rb" -> "b.rb" [ltail="cluster_A" lhead="cluster_B" minlen="3"] + "a.rb" -> "b.rb" [id="graph_3" ltail="cluster_A" lhead="cluster_B" minlen="3"] subgraph "cluster_B" { - label="B" "b.rb" [label="b.rb" id="graph_2"] + label="B" id="graph_5" "b.rb" [label="b.rb" id="graph_6"] } subgraph "cluster_B" { - label="B" "c.rb" [label="c.rb" id="graph_3"] + label="B" id="graph_7" "c.rb" [label="c.rb" id="graph_8"] } } DOT @@ -194,14 +207,36 @@ def build_definition(title: 'title', sources: []) [ { id: 'graph_1', + type: 'module', + module_name: 'A', + }, { + id: 'graph_2', type: 'source', source_name: 'a.rb', }, { - id: 'graph_2', + id: 'graph_3', + type: 'dependency', + source_name: 'b.rb', + method_ids: [], + }, { + id: 'graph_4', + type: 'dependency', + source_name: 'c.rb', + method_ids: [], + }, { + id: 'graph_5', + type: 'module', + module_name: 'B', + }, { + id: 'graph_6', type: 'source', source_name: 'b.rb', }, { - id: 'graph_3', + id: 'graph_7', + type: 'module', + module_name: 'B', + }, { + id: 'graph_8', type: 'source', source_name: 'c.rb', },