Commit 068aebbb authored by Marek Veselý's avatar Marek Veselý
Browse files

Merge branch...

Merge branch '912-create-initial-cause-and-effect-based-on-what-s-already-on-the-backend' into 'main'

Added Cause and effect analyst page with tree graph

Closes #912

See merge request inject/frontend!787
parents e6fdf0e4 70acc8eb
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -33,11 +33,14 @@
    "d3-axis": "^3.0.0",
    "d3-brush": "^3.0.0",
    "d3-format": "^3.1.0",
    "d3-hierarchy": "^3.1.2",
    "d3-scale": "^4.0.2",
    "d3-scale-chromatic": "^3.1.0",
    "d3-selection": "^3.0.0",
    "d3-shape": "^3.2.0",
    "d3-time": "^3.1.0",
    "d3-time-format": "^4.1.0",
    "d3-zoom": "^3.0.0",
    "lodash": "4.17.21",
    "normalize.css": "8.0.1",
    "react": "18.3.1",
@@ -62,11 +65,14 @@
    "@types/d3-axis": "^3.0.6",
    "@types/d3-brush": "^3.0.6",
    "@types/d3-format": "^3.0.4",
    "@types/d3-hierarchy": "^3",
    "@types/d3-scale": "^4.0.8",
    "@types/d3-scale-chromatic": "^3.1.0",
    "@types/d3-selection": "^3.0.11",
    "@types/d3-shape": "^3",
    "@types/d3-time": "^3.0.4",
    "@types/d3-time-format": "^4.0.3",
    "@types/d3-zoom": "^3",
    "@types/lodash": "4.17.13",
    "@types/node": "^20.17.11",
    "@types/react": "18",
+70 −0
Original line number Diff line number Diff line
import { hierarchy, tree } from 'd3-hierarchy'
import { select } from 'd3-selection'
import type { D3ZoomEvent, ZoomBehavior } from 'd3-zoom'
import { zoom, zoomIdentity } from 'd3-zoom'
import type { FC } from 'react'
import { useEffect, useRef } from 'react'

import type { TreeNode } from '../dataHooks/useActionLogsRoot'
import useActionLogsRoot from '../dataHooks/useActionLogsRoot'
import { renderLinks, renderNodes } from './utils'

type CauseAndEffectTreeProps = {
  teamId: string
}

const HORIZONTAL_GAP = 420
const VERTICAL_GAP = 100
const INITIAL_SCALE = 0.75

export const CauseAndEffectTree: FC<CauseAndEffectTreeProps> = ({ teamId }) => {
  const svgRef = useRef<SVGSVGElement | null>(null)
  const gRef = useRef<SVGGElement | null>(null)

  const rootNode = useActionLogsRoot(teamId)
  const root = hierarchy<TreeNode>(rootNode)

  const treeLayout = tree<TreeNode>().nodeSize([VERTICAL_GAP, HORIZONTAL_GAP])
  treeLayout(root)

  const nodes = root.descendants()
  const links = root.links()

  useEffect(() => {
    if (!svgRef.current) return

    const svg = select(svgRef.current)
    const g = select(gRef.current)

    const zoomBehavior: ZoomBehavior<SVGSVGElement, unknown> = zoom<
      SVGSVGElement,
      unknown
    >()
      .scaleExtent([0.1, 2])
      .on('zoom', (event: D3ZoomEvent<SVGSVGElement, unknown>) => {
        g.attr('transform', event.transform.toString())
      })

    svg.call(zoomBehavior)

    // initial camera position
    svg.call(
      zoomBehavior.transform,
      zoomIdentity
        .translate(200, svgRef.current.clientHeight / 2)
        .scale(INITIAL_SCALE)
    )
  }, [rootNode])

  return (
    <svg
      ref={svgRef}
      style={{ width: '100%', height: '100%', display: 'block' }}
    >
      <g className='zoom-container' ref={gRef}>
        {renderLinks(links)}
        {renderNodes(nodes)}
      </g>
    </svg>
  )
}
+114 −0
Original line number Diff line number Diff line
import type { HierarchyLink, HierarchyNode } from 'd3-hierarchy'
import type { TreeNode } from '../dataHooks/useActionLogsRoot'
import { actionTypeColor } from '../utilities'

export const wrapText = (text: string, maxChars: number) => {
  const words = text.split(/\s+/)
  const lines: string[] = []

  let currentWords: string[] = []
  let currentLength = 0

  words.forEach(word => {
    const spaceNeeded = currentWords.length > 0 ? 1 : 0 // space before word
    const totalLength = currentLength + spaceNeeded + word.length

    if (totalLength > maxChars) {
      lines.push(currentWords.join(' '))
      currentWords = [word]
      currentLength = word.length
    } else {
      currentLength = totalLength
      currentWords.push(word)
    }
  })

  if (currentWords.length > 0) {
    lines.push(currentWords.join(' '))
  }

  return lines
}

const NODE_WIDTH = 250
const LINE_HEIGHT = 17
const CHARS_PER_LINE = 29
const CIRCLE_RADIUS = 8
const CIRCLE_TEXT_GAP = 6

export const renderLinks = (links: HierarchyLink<TreeNode>[]) =>
  links.map((link, i) => {
    const x1 = link.source.x
    const y1 = link.source.y! + NODE_WIDTH / 2
    const x2 = link.target.x
    const y2 = link.target.y! - NODE_WIDTH / 2
    const midY = (y1 + y2) / 2
    const d = `M${y1},${x1}C${midY},${x1} ${midY},${x2} ${y2},${x2}`
    return (
      <path
        key={`path-${i}`}
        d={d}
        className='link'
        fill='none'
        stroke='#555'
        strokeWidth={1.5}
      />
    )
  })

export const renderNodes = (nodes: HierarchyNode<TreeNode>[]) =>
  nodes.map((node, nodeIndex) => {
    const lines = wrapText(node.data.name, CHARS_PER_LINE)
    const extraBlockHeight = node.data.type ? 19 : 0 // space for dot + type
    const rectHeight = LINE_HEIGHT * lines.length + extraBlockHeight + 19
    const startY = -rectHeight / 2 + 10 + LINE_HEIGHT / 2

    const typeBlockY = startY + lines.length * LINE_HEIGHT + 4
    const groupWidth =
      CIRCLE_RADIUS * 2 + CIRCLE_TEXT_GAP + (node.data.type?.length ?? 0) * 7

    return (
      <g
        key={`node-${nodeIndex}`}
        className='node'
        transform={`translate(${node.y},${node.x})`}
      >
        <rect
          width={NODE_WIDTH}
          height={rectHeight}
          x={-NODE_WIDTH / 2}
          y={-rectHeight / 2}
          rx={4}
          fill='#f4f4f9'
          stroke='#ccc'
        />

        <text textAnchor='middle' dominantBaseline='middle'>
          {lines.map((line, lineIndex) => (
            <tspan
              key={`textLine-${node.data.name}-${lineIndex}`}
              x={0}
              y={startY + lineIndex * LINE_HEIGHT}
            >
              {line}
            </tspan>
          ))}
        </text>

        {node.data.type && (
          <g transform={`translate(${-groupWidth / 2}, ${typeBlockY})`}>
            <circle
              r={CIRCLE_RADIUS}
              cy={0}
              cx={CIRCLE_RADIUS}
              stroke='#555'
              fill={actionTypeColor(node.data.type)}
            />
            <text x={CIRCLE_RADIUS * 2 + CIRCLE_TEXT_GAP} y={4} fontSize={12}>
              {node.data.type}
            </text>
          </g>
        )}
      </g>
    )
  })
+12 −1
Original line number Diff line number Diff line
@@ -29,6 +29,7 @@ import { useCallback, useContext, useMemo } from 'react'

import { ExercisePageRoute } from '../../routes/_layout/$exerciseId'
import { ActionLogsRoute } from '../../routes/_layout/$exerciseId/action-logs'
import { CauseAndEffectRoute } from '../../routes/_layout/$exerciseId/cuase-and-effect'
import { EmailPageRoute } from '../../routes/_layout/$exerciseId/emails'
import { LearningObjectivesPageRoute } from '../../routes/_layout/$exerciseId/learning-objectives'
import { MilestonesPageRoute } from '../../routes/_layout/$exerciseId/milestones'
@@ -129,7 +130,7 @@ export const NavigationBar: FC<NavigationBarProps> = ({
        },
      },
      icon: 'learning',
      text: 'Learning objectives',
      text: 'Learning Objectives',
    })
    if (emailsEnabled) {
      paths.push({
@@ -167,6 +168,16 @@ export const NavigationBar: FC<NavigationBarProps> = ({
        text: 'Questionnaires',
      })
    }
    paths.push({
      link: {
        to: CauseAndEffectRoute.to,
        params: {
          exerciseId,
        },
      },
      icon: 'one-to-many',
      text: 'Cause and Effect',
    })
    return paths
  }, [emailsEnabled, exerciseId, questionnairesEnabled, toolsEnabled])

+2 −11
Original line number Diff line number Diff line
@@ -8,7 +8,7 @@ import {
} from '@blueprintjs/core'
import { css } from '@emotion/css'
import type { Team } from '@inject/graphql'
import { Container, TabButton } from '@inject/shared'
import { TabButton } from '@inject/shared'
import type { FC, ReactNode } from 'react'
import { useEffect, useState } from 'react'

@@ -19,15 +19,6 @@ const wrapper = css`
  overflow: auto;
`

const container = css`
  flex: 1;
  padding: 1rem;
  overflow: auto;
  display: flex;
  flex-direction: column;
  gap: 1rem;
`

const buttonGroupContainer = css`
  padding: 0.5rem;
  min-width: max-content;
@@ -94,7 +85,7 @@ export const TeamSelectPage: FC<TeamSelectPageProps> = ({
        visibleItemRenderer={visibleItemRenderer}
      />
      <Divider />
      <Container className={container}>{getChildren(selectedTeam)}</Container>
      {getChildren(selectedTeam)}
    </div>
  )
}
Loading